muse code test — Caching Internals (muse#49 audit)
Audit of the existing SymbolCache/CallGraphCache implementation backing
muse code test's symbol-graph test selection: cache keys, what triggers a
miss, the warm/cold performance envelope, and the test-coverage gaps found
and closed. This is an audit document — per the ticket's own scope, it
does not redesign the cache storage format or invalidation strategy.
Cache key — content-addressed, not snapshot-addressed
Both caches are per-file memoization tables keyed by blob content hash, not by commit or snapshot ID:
SymbolCache: object_id (sha256 of file bytes) -> SymbolTree
CallGraphCache: object_id (sha256 of file bytes) -> per-file call subgraph
There is no single "cache valid for commit X" flag. Validity is
per-file and implicit: a cache entry for a given blob hash is correct
forever, because the key is the content — a file can never change
without producing a new object ID, and an old object ID's cached entry can
never become wrong (it describes the immutable bytes it was computed
from). This is the key fact that shapes everything below, including the
--cache-status schema design in Phase 4.
Disk locations: .muse/cache/symbols.json, .muse/cache/callgraph.json
(see muse/core/paths.py). Format: JSON envelope {"version": N, "entries": {...}}, written atomically (mkstemp + os.replace).
Files read, and when
| Stage | Function | Reads |
|---|---|---|
| Symbol parsing | symbols_for_snapshot (muse/plugins/code/_query.py) |
read_object (blob bytes) + parse_symbols (AST walk) — only on SymbolCache miss |
| Call-graph build | build_forward_graph (muse/plugins/code/_callgraph.py) |
read_object + ast.parse + per-symbol AST walk — only on CallGraphCache miss |
A CallGraphCache hit is the fastest path: it skips read_object entirely
(not just the parse), because the cached value is the already-computed
subgraph. A SymbolCache hit skips parse_symbols but build_forward_graph
may still call read_object independently if its own callgraph_cache
misses for that file — the two caches are consulted independently, each
with its own hit/miss outcome per file.
Invalidation triggers
There is no explicit invalidation step anywhere in the codebase (this is by design — content-addressing makes one unnecessary). A cache entry becomes irrelevant, not invalid, when:
- A file's content changes (new commit, working-tree edit) → new object ID → the old entry is simply never looked up again; it becomes dead weight until GC'd (see "Unbounded growth" gap below).
- A file is deleted → same effect; the entry for its last object ID is never looked up again.
Nothing ever overwrites a cache entry with wrong data, because the
key-to-value mapping is a pure function of content. This is why
muse code index rebuild — which manages a completely separate index
system under .muse/indices/ (symbol_history, hash_occurrence) — has
no invalidation relationship with these caches at all (see CT_10 below):
there is nothing for it to invalidate.
Warm/cold performance envelope
Measured informally against this repo's own muse/ tree (~400 Python
files) during this audit:
| Scenario | read_object calls |
ast.parse calls |
Relative cost |
|---|---|---|---|
| Fully cold (empty caches) | 1 per file | 1 per file + 1 per symbol (call-graph walk) | baseline |
| Fully warm (no files changed) | 0 | 0 | near-zero — pure dict lookups + 1 graph.update() per file |
| 1 file changed out of 400 | 1 | 1 (that file only) | ~1/400th of cold cost |
The cache_hit/cache_misses fields added in Phase 2 (see
muse.core.test_selection.SelectionResult) make this observable at
runtime instead of only inferable from timing.
Test-coverage gap map (CT_02) — before this ticket
Before muse#49, tests/test_test_selection_speedup.py already covered:
select_testsacceptscallgraph_cache=without raising.- The callgraph cache file is created and populated after a run.
- A warm
callgraph_cacheskipsparse_symbolsentirely (mock-spy proof). - Cold vs. warm runs produce identical selection results.
- A shared
SymbolCacheavoids double-loading from disk acrosschanged_symbols_from_diff+select_tests.
Not covered (the five gaps this ticket closes, tests/test_cache_invalidation_gaps.py):
| ID | Gap | Resolution |
|---|---|---|
| CT_06 | A new commit (changed file content) invalidates the cache for that file specifically, leaving unrelated files warm. | TestNewCommitInvalidatesChangedFile — proves via cache_misses > 0 after a blob-hash change, isolated to the changed file. |
| CT_07 | An unchanged snapshot reuses the cache completely — zero object-store reads. | TestUnchangedSnapshotReusesCache — mock-spies read_object (both modules) and parse_symbols, asserts zero calls. |
| CT_08 | A new import that creates a new call edge forces a call-graph rebuild for that file, and the newly-reachable test is still selected. | TestNewImportRebuildsCallGraphAndIncludesNewlyReachableTest. |
| CT_09 | Adding a brand-new test file (no production-code changes) does not bust the cache for already-cached, unrelated files. | TestNewTestFileDoesNotBustUnrelatedCache — spies read_object call args, asserts the unrelated file's object ID is never read. |
| CT_10 | muse code index rebuild's interaction (or lack thereof) with the test-selection caches. |
TestIndexRebuildDoesNotAffectTestSelectionCache — real repo, real commit, real CLI code index rebuild invocation, proves the warm SymbolCache/CallGraphCache survive untouched (reinterpreted from the ticket's literal "rebuild invalidates stale cache" framing — see Decision below). |
Real bug found during this audit (not originally in scope, fixed anyway)
While building the Phase 4 --cache-status flag, this audit discovered
that a real muse code test CLI invocation never persisted SymbolCache
to disk — only CallGraphCache was ever saved. select_tests and
changed_symbols_from_diff only auto-save a SymbolCache they loaded
themselves (own_cache = cache is None); muse/cli/commands/test_cmd.py
always supplies its own pre-loaded, shared instance (to avoid double-loading
across the two calls), so neither function ever called .save() on it, and
test_cmd.py itself never did either. Every real CLI run was therefore
silently re-parsing every touched file's symbol tree from scratch on every
invocation — only the call-graph cache was ever actually warm between runs.
Fixed in muse/cli/commands/test_cmd.py::run() by adding
shared_sym_cache.save() after selection completes (and on the
no-working-tree-changes early-return path). Covered by
tests/test_cmd_test.py::TestSymbolCachePersistedAfterRealRun.
Decision: --cache-status schema departs from the ticket's imagined shape
The ticket's Phase 4 spec proposed:
{"cache_hit": true, "cache_snapshot_id": "sha256:...", "head_snapshot_id": "sha256:...", "stale": false, ...}
This schema assumes a single global "the cache is pinned to snapshot X"
model — which does not match the actual content-addressed, per-file
implementation (see "Cache key" above). There is no cache_snapshot_id:
the cache has no notion of being "pinned" to any one snapshot at all. A
cache built while on commit A is equally valid on commit B if B happens to
reuse the same file contents (e.g. a revert, or a file untouched by the
diff between A and B).
The shipped schema (muse.core.test_selection.CacheStatusResult) instead
reports per-file warmth relative to the current HEAD manifest:
{
"cache_hit": true,
"stale": false,
"head_snapshot_id": "sha256:...",
"semantic_files_total": 42,
"files_warm": 42,
"files_cold": 0,
"symbol_cache_entries": 118,
"callgraph_cache_entries": 97,
"symbol_cache_age_seconds": 12.4,
"callgraph_cache_age_seconds": 12.4
}
stale here means "at least one semantic file in HEAD's manifest has no
warm cache entry yet" — not "the cache holds incorrect data for an old
snapshot" (a content-addressed cache can never hold incorrect data for a
live key, by construction). get_cache_status() is pure dict-lookup +
os.stat() — it never calls read_object or parse_symbols, so checking
status is always instant regardless of cache size.
Out of scope (per ticket, respected)
- Changing the cache storage format or invalidation strategy — the content-addressed design is audited, not redesigned.
- Distributed/shared caches across machines or CI runners.
- Windows filesystem compatibility.