AtlatestRepositoryfolio
1
#!/usr/bin/env python32
"""Enumerate the source fields that have NOWHERE TO GO in the frozen vocabulary.4
WHY THIS EXISTS5
---------------6
In M0 an unrepresentable field is a warning in an audit record. In M2/M3 it is7
PERMANENT DATA LOSS on the real corpus, discovered at cutover. The list this tool8
produces is the input to a vocabulary decision that has to happen before M1, not9
before M3.11
READ-ONLY. It opens no file for writing and never touches the estate it scans. Run12
it against the canonical estate to size the problem, or against any corpus copy.14
python3 tools/m0_vocabulary_gap.py /home/daviwil/Ops/folio15
python3 tools/m0_vocabulary_gap.py docs/f0/pressure-corpus --json out.json17
The FIELD SETS ARE IMPORTED FROM THE CONVERTER, which transcribes them from18
`src/folio/doc/model.sgl`'s define-struct forms. Restating them here would be a19
second list that goes stale the moment the model moves.20
"""22
from __future__ import annotations24
import argparse25
import json26
import sys27
from collections import Counter, defaultdict28
from pathlib import Path30
sys.path.insert(0, str(Path(__file__).resolve().parent))32
from f0_inventory import META_RE, TASK_RE, classify, comment_mask # noqa: E40233
from m0_convert import ( # noqa: E40234
NOTE_FIELDS,35
PROJECT_FIELDS,36
TASK_FIELDS,37
Source,38
parse_metadata,39
)42
def scan(root: Path) -> dict:43
task_keys: Counter[str] = Counter()44
fm_note_keys: Counter[str] = Counter()45
fm_project_keys: Counter[str] = Counter()46
examples: dict[str, list[str]] = defaultdict(list)47
files = 048
skipped: Counter[str] = Counter()50
for path in sorted(root.rglob("*.md")):51
rel = str(path.relative_to(root)).replace("\\", "/")52
kind, _ = classify(rel)53
if kind not in {"inbox", "note", "project", "archived-note", "archived-project"}:54
skipped[kind] += 155
continue56
try:57
data = path.read_bytes()58
src = Source(rel, data)59
except (UnicodeDecodeError, ValueError):60
skipped["unreadable"] += 161
continue62
files += 164
masked = comment_mask(src.lines)65
for i, line in enumerate(masked):66
if src.in_fence(i) or i in src.frontmatter_lines:67
continue68
if not TASK_RE.match(line):69
continue70
body = TASK_RE.match(src.lines[i]).group("body")71
m = META_RE.search(body)72
if not m:73
continue74
for key, value in parse_metadata(m.group(1)):75
task_keys[key] += 176
if key not in TASK_FIELDS and len(examples[f"task:{key}"]) < 3:77
examples[f"task:{key}"].append(f"{rel}:{i + 1}: {key}: {value}")79
target = fm_project_keys if kind in ("project", "archived-project") else fm_note_keys80
allowed = PROJECT_FIELDS if kind in ("project", "archived-project") else NOTE_FIELDS81
for key, value in src.frontmatter.items():82
target[key] += 183
if key not in allowed and len(examples[f"frontmatter:{key}"]) < 3:84
examples[f"frontmatter:{key}"].append(f"{rel}: {key}: {value}")86
def split(counter: Counter[str], allowed: set[str]) -> dict:87
has_home = {k: v for k, v in sorted(counter.items()) if k in allowed}88
no_home = {k: v for k, v in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))89
if k not in allowed}90
return {91
"population": sum(counter.values()),92
"distinct_keys": len(counter),93
"has_a_frozen_field": has_home,94
"NO_HOME": no_home,95
"no_home_occurrences": sum(no_home.values()),96
}98
return {99
"files_scanned": files,100
"files_skipped_by_class": dict(sorted(skipped.items())),101
"task_metadata": split(task_keys, TASK_FIELDS),102
"note_frontmatter": split(fm_note_keys, NOTE_FIELDS),103
"project_frontmatter": split(fm_project_keys, PROJECT_FIELDS),104
"frozen_fields": {105
"task-record": sorted(TASK_FIELDS),106
"note-record": sorted(NOTE_FIELDS),107
"project-record": sorted(PROJECT_FIELDS),108
},109
"examples": {k: v for k, v in sorted(examples.items())},110
}113
def main() -> int:114
ap = argparse.ArgumentParser(description=__doc__)115
ap.add_argument("root", type=Path)116
ap.add_argument("--json", type=Path)117
args = ap.parse_args()119
report = scan(args.root)120
if args.json:121
args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")123
print(f"files scanned: {report['files_scanned']}")124
for section in ("task_metadata", "note_frontmatter", "project_frontmatter"):125
d = report[section]126
print(f"\n== {section} == population {d['population']} across "127
f"{d['distinct_keys']} distinct keys")128
print(f" representable: {d['has_a_frozen_field']}")129
print(f" NO HOME ({d['no_home_occurrences']} occurrences): {d['NO_HOME']}")130
total = sum(report[s]["no_home_occurrences"]131
for s in ("task_metadata", "note_frontmatter", "project_frontmatter"))132
print(f"\nM0_VOCAB_GAP unrepresentable_occurrences={total} files={report['files_scanned']}")133
return 0136
if __name__ == "__main__":137
raise SystemExit(main())