Files
graphify/tests/test_semantic_cache_out_root.py
T
shazebandsafishamsi 08166306ba fix(cache): anchor semantic cache writes to cache_root so --out round-trips (#1990, #1991)
With `graphify extract --out <dir>`, the semantic cache write and read
sides disagreed on both location and key anchoring, breaking the cache
round-trip in two ways:

- Checkpoints (#1990): `_checkpoint_chunk` called `save_semantic_cache`
  with only `root=target`, so per-chunk recovery checkpoints were written
  under `<corpus>/graphify-out/` while the reader consulted
  `<out>/graphify-out/` — creating an unwanted graphify-out/ inside the
  analyzed source tree and making every interrupted run re-extract (and
  re-bill) completed chunks.

- Final save (#1991): cli.py passed `root=out_root`, so corpus-relative
  `source_file` paths resolved against the --out directory, failed
  `p.is_file()`, and every result group was silently skipped — the cache
  the reader would consult was never populated at all, with no warning.

Fix, following the split the AST cache already uses (#1774):

- `save_semantic_cache` and `check_semantic_cache` gain a `cache_root`
  parameter mirroring `load_cached`/`save_cached`: `root` stays the
  source-key anchor (content-hash keys, source_file resolution and
  relativization), `cache_root` selects where cache files live. Omitting
  it keeps `root` for both, so existing callers are unchanged.
- `extract_corpus_parallel` plumbs `cache_root` into `_checkpoint_chunk`.
- cli.py extract passes `root=target, cache_root=out_root` at the cache
  read, the checkpoint path, and the final save, and re-anchors the
  prune sweep's live hashes to `target` (keys anchored to out_root would
  mismatch every entry and sweep the fresh cache as orphaned).
- `save_semantic_cache` now warns loudly when every result group is
  dropped because its source_file does not resolve to a real file — the
  silent-0-writes failure mode #1991 asked to surface.

Regression tests cover: checkpoint written under cache_root (not the
corpus, no corpus graphify-out/ created), recovery read finds the
checkpoint via the same root/cache_root split, the final-save call shape
writes entries where the reader looks, the all-groups-dropped warning,
and backward compatibility when cache_root is omitted.

Fixes #1990
Fixes #1991
2026-07-18 19:09:53 +01:00

222 lines
7.6 KiB
Python

"""Regression tests for #1990 and #1991.
#1990 — `graphify extract --out` saves recovery checkpoints in the wrong directory.
save_semantic_cache must write to cache_root (the --out dir), not root
(the corpus dir), when they differ.
#1991 — Final semantic-cache save resolves source_file paths against out_root
and silently writes 0 entries when corpus files do not exist there.
Fixed by passing root=target (corpus), cache_root=out_root to
save_semantic_cache so source_file resolution and cache placement are
independently anchored.
"""
import warnings
from pathlib import Path
import pytest
from graphify.cache import (
check_semantic_cache,
file_hash,
load_cached,
save_semantic_cache,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _semantic_dir(root: Path, mode: str | None = None) -> Path:
kind = "semantic" if mode is None else f"semantic-{mode}"
return root / "graphify-out" / "cache" / kind
def _count_cache_files(base: Path) -> int:
"""Count .json files under a cache dir (recursively, excluding .tmp)."""
if not base.is_dir():
return 0
return sum(1 for _ in base.rglob("*.json"))
# ---------------------------------------------------------------------------
# #1990 — checkpoint writes to cache_root, not corpus root
# ---------------------------------------------------------------------------
def test_save_semantic_cache_writes_to_cache_root_not_corpus(tmp_path):
"""When cache_root differs from root, cache files must land under cache_root."""
corpus = tmp_path / "corpus"
out = tmp_path / "out"
corpus.mkdir()
out.mkdir()
doc = corpus / "report.md"
doc.write_text("# Report\nSome content here.")
nodes = [{"id": "n1", "label": "Report", "source_file": str(doc)}]
edges: list = []
saved = save_semantic_cache(
nodes, edges, root=corpus, cache_root=out
)
assert saved == 1, "expected 1 entry written"
# Cache file must be under the out dir, not the corpus dir
assert _count_cache_files(_semantic_dir(out)) == 1
assert _count_cache_files(_semantic_dir(corpus)) == 0
def test_save_semantic_cache_no_corpus_graphify_out_created(tmp_path):
"""With cache_root set, no graphify-out/ dir should be created inside corpus."""
corpus = tmp_path / "corpus"
out = tmp_path / "out"
corpus.mkdir()
out.mkdir()
doc = corpus / "notes.md"
doc.write_text("Notes content.")
save_semantic_cache(
[{"id": "x", "label": "X", "source_file": str(doc)}],
[],
root=corpus,
cache_root=out,
)
assert not (corpus / "graphify-out").exists(), (
"graphify-out/ must not be created inside the corpus when cache_root is set"
)
# ---------------------------------------------------------------------------
# #1990 — checkpoint written with cache_root is found on recovery read
# ---------------------------------------------------------------------------
def test_checkpoint_with_cache_root_is_found_by_check_semantic_cache(tmp_path):
"""A checkpoint saved with cache_root can be retrieved by check_semantic_cache
using the same root/cache_root split — simulating recovery after interruption."""
corpus = tmp_path / "corpus"
out = tmp_path / "out"
corpus.mkdir()
out.mkdir()
doc = corpus / "paper.md"
doc.write_text("Some academic content.")
nodes = [{"id": "p1", "label": "Paper", "source_file": str(doc)}]
# Simulate a checkpoint written mid-run (merge_existing path)
save_semantic_cache(
nodes, [],
root=corpus,
cache_root=out,
merge_existing=True,
allowed_source_files=[doc],
)
# Recovery read: check_semantic_cache with the same root/cache_root split
# cli.py now uses (root=target, cache_root=out_root).
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(
[str(doc)], root=corpus, cache_root=out
)
assert len(uncached) == 0, f"expected cache hit, got miss for: {uncached}"
assert any(n.get("id") == "p1" for n in cached_nodes)
# ---------------------------------------------------------------------------
# #1991 — final save resolves source_file against corpus root, not out_root
# ---------------------------------------------------------------------------
def test_final_save_with_out_root_populates_cache(tmp_path):
"""When root=corpus and cache_root=out, source_file resolution must use
corpus as anchor so p.is_file() succeeds and the entry is written."""
corpus = tmp_path / "corpus"
out = tmp_path / "out"
corpus.mkdir()
out.mkdir()
doc = corpus / "report.md"
doc.write_text("# Annual Report\nKey findings.")
# This is the pattern that was broken: source_file is relative to corpus,
# but was previously resolved against out_root, making p.is_file() → False.
nodes = [{"id": "r1", "label": "AnnualReport", "source_file": "report.md"}]
saved = save_semantic_cache(
nodes, [],
root=corpus,
cache_root=out,
allowed_source_files=[doc],
)
assert saved == 1, (
"final save must write 1 entry when root=corpus and source_file is "
"relative to corpus — resolving against out_root caused 0 writes (#1991)"
)
assert _count_cache_files(_semantic_dir(out)) == 1
def test_final_save_with_wrong_root_emits_warning(tmp_path):
"""Passing root=out_root (the old broken behaviour) silently writes 0 entries;
the fix adds a RuntimeWarning when ALL groups are dropped."""
corpus = tmp_path / "corpus"
out = tmp_path / "out"
corpus.mkdir()
out.mkdir()
doc = corpus / "report.md"
doc.write_text("# Report")
# Old (broken) call: root=out, no cache_root — the corpus-relative
# source_file is resolved against out where the file doesn't exist
# → 0 entries written.
nodes = [{"id": "r1", "label": "R", "source_file": "report.md"}]
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
saved = save_semantic_cache(nodes, [], root=out)
assert saved == 0
# The new warning must fire when every group is dropped
assert any(
"#1991" in str(warning.message) for warning in w
), "expected RuntimeWarning mentioning #1991 when all groups are silently skipped"
# ---------------------------------------------------------------------------
# Backward compat — omitting cache_root keeps legacy behaviour
# ---------------------------------------------------------------------------
def test_save_semantic_cache_backward_compat_no_cache_root(tmp_path):
"""When cache_root is omitted, cache files still land under root (unchanged)."""
root = tmp_path / "project"
root.mkdir()
doc = root / "main.md"
doc.write_text("Main content.")
nodes = [{"id": "m1", "label": "Main", "source_file": str(doc)}]
saved = save_semantic_cache(nodes, [], root=root)
assert saved == 1
assert _count_cache_files(_semantic_dir(root)) == 1
def test_extract_corpus_parallel_accepts_cache_root_kwarg():
"""extract_corpus_parallel must accept a cache_root kwarg without raising
(import + signature check — no actual LLM call)."""
import inspect
from graphify.llm import extract_corpus_parallel
sig = inspect.signature(extract_corpus_parallel)
assert "cache_root" in sig.parameters, (
"extract_corpus_parallel must expose cache_root so cli.py can plumb "
"out_root through to _checkpoint_chunk (#1990)"
)