gabriel / muse public
code-test-internals.md markdown
164 lines 8.5 KB
Raw
sha256:ce542c4d0f6dacc9520c02d0e3378bf96bc377d118a7402f2bbc8af1749b08e0 feat(code-test): audit cache internals, add cache_hit/--cac… Sonnet 5 minor ⚠ breaking 5 hours ago

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:

  1. 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).
  2. 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_tests accepts callgraph_cache= without raising.
  • The callgraph cache file is created and populated after a run.
  • A warm callgraph_cache skips parse_symbols entirely (mock-spy proof).
  • Cold vs. warm runs produce identical selection results.
  • A shared SymbolCache avoids double-loading from disk across changed_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.
File History 1 commit
sha256:ce542c4d0f6dacc9520c02d0e3378bf96bc377d118a7402f2bbc8af1749b08e0 feat(code-test): audit cache internals, add cache_hit/--cac… Sonnet 5 minor ⚠ 5 hours ago