# `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: ```json {"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**: ```json { "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.