Orchestrata Ultimate › Plans › briefs › 2026-10-22-maps-delta-builder
Maps step B1 — per-file cache for gen_codebase_map.py (builder brief, 2026-10-22)
Source: plans/briefs/2026-10-22-maps-delta-builder.md at 8a2cb42ad · last changed 2026-10-22 · first committed 2026-10-22 · 161 lines.
Prose pending.
Plan text
Maps step B1 — per-file cache for gen_codebase_map.py (builder brief, 2026-10-22)
GOAL: make scripts/gen_codebase_map.py --with-full re-parse only the files whose content changed since its last
run, by keeping a per-file cache of symbol rows and module docstring lines, so a run with no source changes takes
about 3 s or less instead of 11.7–13.9 s, and both maps stay byte-identical. This is step B of Mohamed's ruling
"maps should use delta" (step A, commit e1f3a5047, put both maps in one process).
FILES: scripts/gen_codebase_map.py only. Nothing else changes in this increment (no tests yet: they are the
next increment). Worktree /home/mohamed/workspace/wt-MAP-PRECOMMIT, branch feat/maps-delta (already created
from e1f3a5047). Do not stage or commit.
Measured state (do not re-measure)
cProfile of one --with-full run on this tree (21.8 s under the profiler; 11.7–13.9 s unprofiled):
| where | cumulative | what |
|---|---|---|
toolkit.list_symbols, 11 calls |
16.4 s | 1 from _class_map over src/ (kind="class") + 10 from build_full_map |
_extract_symbols (2283 calls) |
12.8 s | the per-file AST walk; ast.iter_child_nodes alone 7.1 s |
builtins.compile (3589 calls) |
6.1 s | ast.parse: once per file in _parse_cached, again in first_doc_line |
_section → first_doc_line (2203 calls) |
4.0 s | parses every scripts/, entrypoint, package __init__, tests/test_*.py file |
| imports incl. loading the toolkit | 0.6 s |
So nearly all the time is parsing and walking unchanged files. Nothing is cached across runs today: the generator
loads the toolkit in-process, where _CACHE_STATE["enabled"] is False, so _cached_extract just calls compute()
(deliberately: the toolkit's own warm cache is a 200 MB pickle that costs ~9 s to load, which was step A's problem).
Code facts (read with the AST toolkit; line numbers are in the current tree)
scripts/gen_codebase_map.py:
first_doc_line(path) -> str(29-38):ast.parse(path.read_text(encoding="utf-8")); returns""onOSError, SyntaxError, ValueErroror no docstring; elsedoc.strip().splitlines()[0].strip()ofast.get_docstring(mod).rel(p)(41-42):str(p.relative_to(ROOT))._section(add, title, paths, *, with_counts=False)(45-54): per path,first_doc_line(p if p.is_file() else p / "__init__.py") or "—"; with counts it also doeslen(list(p.rglob("*.py")))(no parse)._load_ast_toolkit()(57-93): loadsSCRIPTS / "serialize_codebase_ast.py"by file path as module_gen_map_ast_toolkit, cached insys.moduleskeyed on__file__; returnsNone(and prints why) on failure._class_map(add)(128-211):toolkit = _load_ast_toolkit(); None → the "UNAVAILABLE" heading. Elserows = toolkit.list_symbols([str(ROOT / "src")], kind="class")(absolute source → absolutefilestrings), then counts subclasses by bare base name and groups by package; it already normalises relative or absolutefilevalues against ROOT.build_full_map(toolkit) -> str(228-251):toolkit.configure_repo_root(ROOT), header lines, then for each tree inFULL_TREESthat is a dir:rows = toolkit.list_symbols([tree])(relative source → root-relativefilestrings), emitting### symbols: {tree}/, onef"{r['file']}:{r['line']}: {r['signature']}"per row,"",f"{len(rows)} symbol(s)."; then a closing fence line.build_map() -> str(254-300): header lines,_sectionoverscripts/*.py, src entrypoints, packages, then_class_map(add), then_sectionovertests/**/test_*.py.main()(303-317):with_full = "--with-full" in sys.argv[1:]; writesdocs/CODEBASE_MAP.mdwithdump_text_atomic(fromorchestrata_ultimate.core.atomic_json, signature(path, text)), echoes it to stdout, then_write_full_map(dump_text_atomic)whenwith_full._write_full_map(320-326) and_guard_and_write(text, dest, dump_text_atomic) -> int(329-): the 1000-signature guard. Unchanged by this increment.
scripts/serialize_codebase_ast.py (do not edit it):
list_symbols(sources, pattern=None, kind=None)(1807-1827): rows from_iter_py_files(sources)via_cached_extract(path, text, "symbols", lambda: _extract_symbols(tree, path)), filtered by kind/pattern, thensorted(out, key=lambda r: (r["file"], r["line"]))._iter_py_files(sources)(1709-1739), per source:src = _locate(source);rel_base = _DATA_ROOTif the source is relative else None; a.pyfile →[src], a dir →sorted(src.rglob("*.py")); skips a path whenany(skip in path.parts for skip in _DEP_SKIP_DIRS); de-dupes onpath.resolve()within the call; skips the file when_parse_cached(path)is None; yieldspath.relative_to(rel_base)(orpath), text, tree._parse_cached(path)(1646-1706): returns None onOSError, UnicodeDecodeErrorreading (path.read_text(encoding="utf-8")) and onSyntaxError, UnicodeDecodeErrorfromast.parse(text)._extract_symbols(tree, path) -> list[dict](1768-1804): rows{"kind","name","signature","file": str(path), "line"}in AST visit order.configure_repo_root(path)(193-201) sets_DATA_ROOTto the resolved path.
The design (decided — implement exactly this)
- A cache class in
gen_codebase_map.py, e.g._MapCache, persisted as JSON (stdlibjson,hashlib):{"version": <str>, "files": {<absolute path str>: {"sha1": <hex>, "symbols": [rows] | null, "doc": <str>}}}.version= sha1 overPath(__file__).read_bytes()+ the toolkit file's bytes +sys.version. A mismatch discards every entry (a toolkit or generator edit means one cold run).- Load: a missing file, unreadable file, bad JSON or wrong shape → start empty. A damaged cache degrades to a cold build; it never breaks the map.
- Per file, read
path.read_text(encoding="utf-8")and sha1 its UTF-8 bytes. An entry is a hit when its sha1 matches. A missing field on a hit entry is computed and stored. A file is parsed at most once per run: oneast.parseserves both the symbols and the doc fields. symbols: the rows oftoolkit._extract_symbols(tree, rel), whererel = path.relative_to(ROOT), so the storedfileis root-relative. Usenullwhen the toolkit would skip the file: reading fails withOSError/UnicodeDecodeError, or the parse fails withSyntaxError/UnicodeDecodeError.doc: exactlyfirst_doc_line's result, including its""cases. Note its broader catch,OSError, SyntaxError, ValueError.- Save only when something changed: a parse, a new field, a version reset, or an entry pruned. Keep only the
entries used this run, so deleted files drop out. Write with
dump_text_atomic(cache_path, json.dumps(...))aftercache_path.parent.mkdir(parents=True, exist_ok=True). - Counters:
hitsandparsed.
- A cached
list_symbolstwin in the generator, used only when a cache is given. It enumerates exactly as_iter_py_filesdoes for one source:src = ROOT / treefor the full map, orROOT / "src"for the class map;- a file gives
[src], a dir givessorted(src.rglob("*.py")); - skip on
toolkit._DEP_SKIP_DIRSand de-dupe onresolve(); - the rows are
cache.symbols(...)for each file, skippingNone; - filter by
kind, then apply the samesorted(key=(file, line)). For the class map, the stored root-relativefilevalues sort in the same order as the toolkit's absolute ones, because they all share the ROOT prefix, and_class_mapalready normalises them.
- Wiring.
build_map(cache=None),_class_map(add, cache=None),_section(..., cache=None)andbuild_full_map(toolkit, cache=None)take an optional cache.Nonemust run today's code unchanged:toolkit.list_symbolsandfirst_doc_line. Existing tests call these with no cache and must still pass, and--no-cacheis the reference the proof compares against.main()builds the cache from flags:--cache PATHoverrides the defaultROOT / ".cache" / "codebase-map.json"(.cacheis gitignored,.gitignore:75);--no-cachepassesNone;--cache-statsprints one stderr line in exactly this format:gen_codebase_map: cache: {hits} hit, {parsed} parsed, {n} files, {path}.
- One cache object serves the whole run: header map, class map and full map. Save it once at the end of
main(). - When the toolkit fails to load, keep today's behaviour. The class map shows UNAVAILABLE, and the full map
goes through the guard with
"". - Without
--cache-stats, print nothing new. The SessionStart hook now shows the generator's stderr.
Traps
- Do not hand-read or grep
.pyfiles. Read code with the deployed toolkit, run from the scratch dir:cd /tmp/claude-1001/-home-mohamed/829ea13c-8ceb-4f51-9b28-672fef74daca/scratchpad && env PYTHONPATH=/opt/orchestrata-tools/lib /opt/orchestrata-tools/.venv/bin/python /opt/orchestrata-tools/lib/scripts/serialize_codebase_ast.py source <name> --source /home/mohamed/workspace/wt-MAP-PRECOMMIT/scripts/gen_codebase_map.pyThe other subcommands aresymbolsandrefs. Use Edit to change the file. - The generator rewrites
docs/CODEBASE_MAP.mdwithout the hook's pointer block. The proof script restores both maps withgit checkout HEAD -- docs/.... Never commit maps written by a bare generator run. - Bash guards block
rm,sed -i, a redirect into a.pyfile, and any command whose text contains a.claudepath. Do not try to route around them. - Do not run
run_tests.pyor pytest in this increment. Every source edit triggers a 180–234 s trace_intel preflight, and the tests are the next increment. - Expected and never staged:
plans/ast-*.jsonandreports/run-tests-latest.jsonshow as modified. The hooks regenerate them. - Output order must match exactly: the rglob order, the sort key,
"—"for an empty doc, and the{n} symbol(s).lines. The byte comparison is the judge.
Run budget
Run the proof script at most 3 times. If it is still not green after the third run, STOP and report the failing
lines and your diagnosis from /tmp/claude-delta-proof/*.err. If the warm run is green but slower than 3.0 s,
STOP and report the timings rather than optimising further.
CONSTRAINTS:
- Edit only
scripts/gen_codebase_map.py. - Stdlib only, no new dependency. No pickle: JSON only.
- No change to
serialize_codebase_ast.pyor to either hook copy. - Keep the file's existing comment density and style, and its WHY-comments.
- No mocks, no skips.
- Do not stage, commit, push or stash.
DONE WHEN: one run of
bash /tmp/claude-1001/-home-mohamed/829ea13c-8ceb-4f51-9b28-672fef74daca/scratchpad/prove-delta.sh /home/mohamed/workspace/wt-MAP-PRECOMMIT
prints all of the following:
COLD-SAME,WARM-SAME,DELTA-SAME,DELTA-SEENandBACK-SAME;- every
rc=0; coldstats showing0 hit;warmstats showing0 parsedwith a wall time ≤ 3.0 s;deltaandbackstats each showing1 parsed.
Separately, ruff check scripts/gen_codebase_map.py and ruff format --check scripts/gen_codebase_map.py must be
clean, using the worktree's .venv/bin/ruff or uv run ruff.
VERIFY: bash /tmp/claude-1001/-home-mohamed/829ea13c-8ceb-4f51-9b28-672fef74daca/scratchpad/prove-delta.sh /home/mohamed/workspace/wt-MAP-PRECOMMIT > /tmp/claude-delta-proof.log 2>&1; echo "PROOF-EXIT $?" >> /tmp/claude-delta-proof.log
- It takes about 60 s: two no-cache builds at about 13 s each, one cold build at about 14 s, and three warm builds.
- Run it in the background and read
/tmp/claude-delta-proof.logonce it printsPROOF-EXIT. - Report the log verbatim, plus
git diff --stat.
DESIGN_WAIVER: none — the design above is decided by the orchestrator under Mohamed's ruling "maps should use delta".