fix(cache): retry zero-node semantic results (#2927)

A semantic result with no nodes and no hyperedges (only edges, or nothing) was cached and
stamped into the manifest, so an empty/degenerate LLM reply for a file froze that file:
detect_incremental saw it unchanged and never re-dispatched it. Reject zero-node results
from the cache read and write, drop edges from the manifest stamp tuple, and heal an
existing manifest by re-queueing files that were already stamped with a zero-node result.
This commit is contained in:
hopstreax
2026-08-21 18:26:49 +01:00
committed by safishamsi
parent 5be36dac0d
commit 24cf52d51e
4 changed files with 460 additions and 6 deletions
+17
View File
@@ -992,6 +992,18 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
# across chunks without losing the truncated one (it stays partial).
if not allow_partial and isinstance(result, dict) and result.get("partial"):
return None
# A semantic entry with zero nodes and zero hyperedges is invalid (#2927):
# an edge-only or empty result (e.g. LLM omitted entities for the file)
# is not a valid standalone extraction. Treating it as a cache MISS
# ensures the file is re-dispatched and retried (#933/#1666).
if (
not allow_partial
and kind.startswith("semantic")
and isinstance(result, dict)
and not result.get("nodes")
and not result.get("hyperedges")
):
return None
if (
kind.startswith("semantic")
and isinstance(result, dict)
@@ -1543,6 +1555,11 @@ def save_semantic_cache(
)
if is_partial:
result = {**result, "partial": True}
# A semantic extraction with zero nodes and zero hyperedges is not a valid
# standalone extraction (#2927): edge-only or empty results must not be
# cached, so that subsequent runs can re-dispatch and retry the file (#933/#1666).
if not is_partial and not (result.get("nodes") or result.get("hyperedges")):
continue
save_cached(cache_path, result, root, kind=kind, cache_root=cache_root,
prompt=prompt, prompt_file=prompt_file)
saved += 1
+127 -1
View File
@@ -133,7 +133,10 @@ def _stamped_manifest_files(
return p
sem_extracted: set[Path] = set()
for coll in ("nodes", "edges", "hyperedges"):
# #2927: only nodes and hyperedges count as valid semantic output that stamps
# the manifest. An edge-only result has no entity representation in the graph
# and must be left unstamped so detect_incremental re-queues it (#933/#1666).
for coll in ("nodes", "hyperedges"):
for item in sem_result.get(coll, []):
sf = item.get("source_file", "")
if sf:
@@ -421,6 +424,101 @@ def _zero_node_stamped_code_sources(
return healed
def _zero_node_stamped_semantic_sources(
graph_path: Path,
scan_root: Path,
unchanged_semantic: list[str],
) -> list[str]:
"""Manifest-stamped semantic files (doc/paper/image) with ZERO nodes
and ZERO hyperedges in the existing graph.json (#2927 heal).
A manifest poisoned before #2927 (edge-only result cached and stamped)
keeps reporting the file unchanged forever, freezing it out of the graph.
Re-queue any unchanged semantic file that has neither nodes nor hyperedges
in graph.json. If it succeeds, its nodes enter graph.json; if it produces
no nodes or fails, it is now left unstamped, so this cannot wedge.
Membership mirrors the ``source_file`` spellings extracts store (#1897/
#1941: scan-root-relative, forward slash; absolute for out-of-root) and
compares NFC-normalized (#2210/#2221).
"""
if not unchanged_semantic:
return []
from graphify.paths import nfc
try:
data = json.loads(graph_path.read_text(encoding="utf-8"))
except Exception:
return []
if not isinstance(data, dict):
return []
try:
root_res = scan_root.resolve()
except (OSError, RuntimeError):
root_res = scan_root
out_base = graph_path.parent.parent
try:
out_base = out_base.resolve()
except (OSError, RuntimeError):
pass
present: set[str] = set()
for n in data.get("nodes", []):
if not isinstance(n, dict):
continue
sf = n.get("source_file")
if not sf or not isinstance(sf, str):
continue
present.add(nfc(sf))
p = Path(sf)
if p.is_absolute():
try:
present.add(nfc(str(p.resolve())))
except (OSError, RuntimeError):
pass
else:
rel = sf.replace("\\", "/")
for base in (root_res, out_base):
present.add(nfc(os.path.normpath(str(base / rel))))
hyper_items = list(data.get("hyperedges", []) or [])
if isinstance((data.get("graph") or {}).get("hyperedges"), list):
hyper_items.extend(data["graph"]["hyperedges"])
for h in hyper_items:
if not isinstance(h, dict):
continue
sf = h.get("source_file")
if not sf or not isinstance(sf, str):
continue
present.add(nfc(sf))
p = Path(sf)
if p.is_absolute():
try:
present.add(nfc(str(p.resolve())))
except (OSError, RuntimeError):
pass
else:
rel = sf.replace("\\", "/")
for base in (root_res, out_base):
present.add(nfc(os.path.normpath(str(base / rel))))
healed: list[str] = []
for f in unchanged_semantic:
p = Path(f)
spellings = {nfc(str(p))}
try:
spellings.add(nfc(str(p.resolve())))
except (OSError, RuntimeError):
pass
try:
spellings.add(nfc(p.resolve().relative_to(root_res).as_posix()))
except (ValueError, OSError, RuntimeError):
pass
if spellings & present:
continue # the graph has nodes or hyperedges for this file: stamp is honest
healed.append(f)
return healed
def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:
"""Drop nodes/edges/hyperedges owned by ``stale_sources`` from graph.json
in place. Returns the number of nodes removed.
@@ -3209,6 +3307,34 @@ def dispatch_command(cmd: str) -> None:
f"(prior failed extraction, #2543)"
)
code_files.extend(Path(p) for p in _healed_sources)
# #2927 heal: manifests poisoned BEFORE zero-node semantic cache rejection
# existed carry live hashes for semantic files (doc/paper/image) whose
# extraction produced zero nodes and zero hyperedges (e.g. edge-only).
# Re-queue any such file so it is re-dispatched and self-heals.
_unchanged_sem: list[str] = []
for _k in ("document", "paper", "image"):
_unchanged_sem.extend(detection.get("unchanged_files", {}).get(_k, []))
_healed_sem_sources = _zero_node_stamped_semantic_sources(
existing_graph_path,
target,
_unchanged_sem,
)
if _healed_sem_sources:
print(
f"[graphify extract] re-queuing {len(_healed_sem_sources)} "
f"manifest-stamped semantic file(s) with no nodes or hyperedges in graph.json "
f"(prior empty/edge-only extraction, #2927)"
)
_healed_sem_set = set(_healed_sem_sources)
for _p in detection.get("unchanged_files", {}).get("document", []):
if _p in _healed_sem_set:
doc_files.append(Path(_p))
for _p in detection.get("unchanged_files", {}).get("paper", []):
if _p in _healed_sem_set:
paper_files.append(Path(_p))
for _p in detection.get("unchanged_files", {}).get("image", []):
if _p in _healed_sem_set:
image_files.append(Path(_p))
else:
print(f"[graphify extract] scanning {target}")
detection = _detect(
+128
View File
@@ -1749,3 +1749,131 @@ def test_corrupt_semantic_entry_warns_and_is_a_miss(tmp_path):
# The corrupt entry is a miss, so the file is re-dispatched for extraction.
assert nodes == []
assert uncached == [str(f)]
# --- #2927: zero-node semantic cache rejection and healing -------------------
def test_edge_only_semantic_result_not_cached(tmp_path):
"""#2927: an edge-only semantic result (0 nodes, 0 hyperedges) represents an
omission by the model and must NOT be written to cache, so subsequent runs
can re-dispatch and retry the file (#933/#1666)."""
from graphify.cache import check_semantic_cache, load_cached, save_semantic_cache
f = tmp_path / "doc.md"
f.write_text("# Architecture\nSome prose.\n", encoding="utf-8")
edges = [{"source": "auth_a", "target": "auth_b", "source_file": "doc.md"}]
saved = save_semantic_cache([], edges, root=tmp_path, prompt="PROMPT V1")
assert saved == 0, "edge-only result must not be saved to cache"
# load_cached must return None (miss)
assert load_cached(f, root=tmp_path, kind="semantic", prompt="PROMPT V1") is None
# check_semantic_cache must treat it as uncached
nodes, edges_out, hyper_out, uncached = check_semantic_cache([str(f)], root=tmp_path, prompt="PROMPT V1")
assert nodes == [] and edges_out == [] and hyper_out == []
assert uncached == [str(f)]
def test_node_only_and_node_edge_semantic_results_cached(tmp_path):
"""Normal extractions (nodes-only and nodes+edges) continue to cache normally."""
from graphify.cache import load_cached, save_semantic_cache
f1 = tmp_path / "doc1.md"
f1.write_text("# Doc 1\n", encoding="utf-8")
f2 = tmp_path / "doc2.md"
f2.write_text("# Doc 2\n", encoding="utf-8")
# Node-only
saved1 = save_semantic_cache([{"id": "n1", "source_file": "doc1.md"}], [], root=tmp_path, prompt="P")
assert saved1 == 1
loaded1 = load_cached(f1, root=tmp_path, kind="semantic", prompt="P")
assert loaded1 is not None and len(loaded1["nodes"]) == 1
# Node + edge
saved2 = save_semantic_cache(
[{"id": "n2", "source_file": "doc2.md"}],
[{"source": "n2", "target": "n2", "source_file": "doc2.md"}],
root=tmp_path,
prompt="P",
)
assert saved2 == 1
loaded2 = load_cached(f2, root=tmp_path, kind="semantic", prompt="P")
assert loaded2 is not None and len(loaded2["nodes"]) == 1 and len(loaded2["edges"]) == 1
def test_hyperedge_only_semantic_result_cached(tmp_path):
"""#1920: hyperedge-only documents are valid semantic output and must be cached."""
from graphify.cache import check_semantic_cache, load_cached, save_semantic_cache
f = tmp_path / "hyper.md"
f.write_text("# Pipeline Concept\n", encoding="utf-8")
hyperedges = [
{"id": "h1", "label": "Pipeline", "nodes": ["a", "b", "c"], "source_file": "hyper.md"}
]
saved = save_semantic_cache([], [], hyperedges, root=tmp_path, prompt="PROMPT V1")
assert saved == 1, "hyperedge-only result must be saved to cache (#1920)"
loaded = load_cached(f, root=tmp_path, kind="semantic", prompt="PROMPT V1")
assert loaded is not None
assert len(loaded["hyperedges"]) == 1
_, _, cached_hyper, uncached = check_semantic_cache([str(f)], root=tmp_path, prompt="PROMPT V1")
assert uncached == []
assert len(cached_hyper) == 1
def test_poisoned_edge_only_cache_entry_treated_as_miss(tmp_path):
"""#2927 healing: a legacy on-disk cache entry containing edges but no nodes
or hyperedges must be rejected by load_cached as a cache MISS."""
import json
from graphify.cache import cache_dir, file_hash, load_cached, prompt_fingerprint
f = tmp_path / "poisoned.md"
f.write_text("# Poisoned\n", encoding="utf-8")
# Manually seed a legacy poisoned cache file (nodes: [], edges: [...])
prompt = "PROMPT V1"
fp = prompt_fingerprint(prompt)
cdir = cache_dir(tmp_path, "semantic", fp)
cdir.mkdir(parents=True, exist_ok=True)
h = file_hash(f, tmp_path)
(cdir / f"{h}.json").write_text(
json.dumps({
"nodes": [],
"edges": [{"source": "x", "target": "y", "source_file": "poisoned.md"}],
"hyperedges": [],
}),
encoding="utf-8",
)
# load_cached must reject the poisoned entry
assert load_cached(f, root=tmp_path, kind="semantic", prompt=prompt) is None
def test_existing_hyperedge_only_cache_entry_remains_hit(tmp_path):
"""#1920 / #2927: an existing on-disk cache entry with hyperedges but no nodes
remains a valid cache hit."""
import json
from graphify.cache import cache_dir, file_hash, load_cached, prompt_fingerprint
f = tmp_path / "valid_hyper.md"
f.write_text("# Hyper\n", encoding="utf-8")
prompt = "PROMPT V1"
fp = prompt_fingerprint(prompt)
cdir = cache_dir(tmp_path, "semantic", fp)
cdir.mkdir(parents=True, exist_ok=True)
h = file_hash(f, tmp_path)
(cdir / f"{h}.json").write_text(
json.dumps({
"nodes": [],
"edges": [],
"hyperedges": [{"id": "h1", "label": "Group", "nodes": ["a", "b", "c"], "source_file": "valid_hyper.md"}],
}),
encoding="utf-8",
)
loaded = load_cached(f, root=tmp_path, kind="semantic", prompt=prompt)
assert loaded is not None
assert len(loaded["hyperedges"]) == 1
+188 -5
View File
@@ -416,28 +416,35 @@ def test_manifest_stamps_freshly_extracted_semantic_docs(monkeypatch, tmp_path):
def test_stamped_manifest_files_normalizes_both_sides(tmp_path):
"""Unit test for the #1897 helper: relative (fresh) and absolute (cache-hit)
source_file values must both match detect()'s absolute file lists; docs with
no output are filtered; code files pass through untouched."""
no valid output (or edge-only output, #2927) are filtered; code files pass through untouched."""
from graphify.cli import _stamped_manifest_files
fresh_doc = tmp_path / "fresh.md"; fresh_doc.write_text("# fresh")
cached_doc = tmp_path / "cached.md"; cached_doc.write_text("# cached")
edge_only_doc = tmp_path / "edge_only.md"; edge_only_doc.write_text("# edge only")
omitted_doc = tmp_path / "omitted.md"; omitted_doc.write_text("# omitted")
code = tmp_path / "app.py"; code.write_text("x = 1")
files_by_type = {
"code": [str(code)],
"document": [str(fresh_doc), str(cached_doc), str(omitted_doc)],
"document": [str(fresh_doc), str(cached_doc), str(edge_only_doc), str(omitted_doc)],
}
sem_result = {
# fresh extraction: root-relative source_file
"nodes": [{"id": "n1", "source_file": "fresh.md"}],
# cache replay: absolute source_file (edge-only coverage counts too)
"edges": [{"source": "a", "target": "b", "source_file": str(cached_doc)}],
"nodes": [
{"id": "n1", "source_file": "fresh.md"},
# cache replay: absolute source_file
{"id": "n2", "source_file": str(cached_doc)},
],
# edge-only: must NOT count toward stamping (#2927)
"edges": [{"source": "a", "target": "b", "source_file": str(edge_only_doc)}],
}
out = _stamped_manifest_files(files_by_type, sem_result, tmp_path)
assert out["code"] == [str(code)]
assert out["document"] == [str(fresh_doc), str(cached_doc)]
assert str(edge_only_doc) not in out["document"]
assert str(omitted_doc) not in out["document"]
def test_stamped_manifest_files_counts_hyperedge_only_docs(tmp_path):
@@ -1369,3 +1376,179 @@ def test_cache_check_prompt_file_scopes_hits_to_that_prompt(monkeypatch, tmp_pat
os.utime(spec, ns=(0, 0))
_run_extract(monkeypatch, base + ["--prompt-file", str(spec)])
assert "Cache: 0 hit, 1 miss" in capsys.readouterr().out
# --- #2927: zero-node / edge-only semantic retry and manifest healing --------
def test_edge_only_semantic_extraction_not_stamped_and_retried(monkeypatch, tmp_path):
"""#2927 end-to-end: a semantic extraction where a dispatched doc produces
edges but zero nodes must NOT be cached or stamped into manifest.json, so
subsequent incremental runs re-dispatch and retry the file."""
import json
from graphify.cache import check_semantic_cache
corpus = _make_corpus(tmp_path) # main.go + README.md
arch = corpus / "ARCH.md"
arch.write_text("# Architecture\nDetailed arch notes.\n", encoding="utf-8")
out_dir = tmp_path / "out"
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
call_count = {"count": 0}
def _mock_llm(paths, **kwargs):
call_count["count"] += 1
on_chunk = kwargs.get("on_chunk_done")
if on_chunk:
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
path_strs = [p.name for p in paths]
nodes = []
edges = []
if "README.md" in path_strs:
nodes.append({
"id": "readme_concept",
"label": "README Concept",
"source_file": "README.md",
"file_type": "document",
})
if "ARCH.md" in path_strs:
if call_count["count"] == 1:
# Run 1: Model responds with edge only (omits nodes for ARCH.md)
edges.append({
"source": "readme_concept",
"target": "readme_concept",
"relation": "relates_to",
"source_file": "ARCH.md",
})
else:
# Run 2: Retry succeeds with actual nodes for ARCH.md
nodes.append({
"id": "arch_concept",
"label": "Arch Concept",
"source_file": "ARCH.md",
"file_type": "document",
})
return {
"nodes": nodes,
"edges": edges,
"hyperedges": [],
"input_tokens": 10,
"output_tokens": 5,
}
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _mock_llm)
argv = ["graphify", "extract", str(corpus), "--backend", "claude",
"--no-cluster", "--out", str(out_dir)]
# --- Run 1: ARCH.md produces only edges ---
_run_extract(monkeypatch, argv)
manifest_path = out_dir / "graphify-out" / "manifest.json"
assert manifest_path.exists()
m1 = json.loads(manifest_path.read_text(encoding="utf-8"))
# README.md has nodes -> stamped
assert m1.get("README.md", {}).get("semantic_hash") != ""
# ARCH.md has only edges -> NOT stamped in manifest (#2927)
assert not m1.get("ARCH.md", {}).get("semantic_hash"), (
f"ARCH.md had zero nodes and must stay unstamped: {m1}"
)
# Cache check: README.md is cached, ARCH.md is NOT cached
from graphify.cache import file_hash
cache_sem_dir = out_dir / "graphify-out" / "cache" / "semantic"
arch_h = file_hash(arch, corpus)
readme_h = file_hash(corpus / "README.md", corpus)
assert not list(cache_sem_dir.rglob(f"{arch_h}.json")), "ARCH.md must not have a cache file"
assert list(cache_sem_dir.rglob(f"{readme_h}.json")), "README.md must have a cache file"
# --- Run 2: Incremental run without modifying files on disk ---
# Because ARCH.md was left unstamped and uncached, it must be re-dispatched!
_run_extract(monkeypatch, argv)
assert call_count["count"] == 2, "ARCH.md must have triggered a second LLM call"
m2 = json.loads(manifest_path.read_text(encoding="utf-8"))
assert m2.get("ARCH.md", {}).get("semantic_hash") != "", (
"ARCH.md must now be stamped after successful node extraction"
)
graph_path = out_dir / "graphify-out" / "graph.json"
g2 = json.loads(graph_path.read_text(encoding="utf-8"))
node_ids = {n["id"] for n in g2.get("nodes", [])}
assert "arch_concept" in node_ids, "ARCH.md nodes must be present in graph.json"
def test_stale_poisoned_manifest_semantic_source_healed(monkeypatch, tmp_path, capsys):
"""#2927 healing: a manifest poisoned BEFORE #2927 (carrying a live semantic_hash
for a semantic file with zero nodes/hyperedges in graph.json) is healed by
re-queuing the file on an incremental extract."""
import json
from graphify.detect import save_manifest
project = tmp_path / "proj"
project.mkdir()
doc = project / "guide.md"
doc.write_text("# Guide\nSome instructions.\n", encoding="utf-8")
app = project / "app.py"
app.write_text("def run(): pass\n", encoding="utf-8")
out_dir = tmp_path / "out"
graphify_out = out_dir / "graphify-out"
graphify_out.mkdir(parents=True, exist_ok=True)
graph_path = graphify_out / "graph.json"
manifest_path = graphify_out / "manifest.json"
# 1) Seed a graph where guide.md has 0 nodes and 0 hyperedges
graph_path.write_text(
json.dumps({
"nodes": [{"id": "app_run", "label": "run", "source_file": "app.py", "file_type": "code"}],
"edges": [],
"hyperedges": [],
}),
encoding="utf-8",
)
# 2) Seed a poisoned manifest where guide.md has a valid semantic_hash
save_manifest(
{"code": [str(app)], "document": [str(doc)]},
manifest_path=str(manifest_path),
kind="both",
root=project,
)
m = json.loads(manifest_path.read_text(encoding="utf-8"))
assert m.get("guide.md", {}).get("semantic_hash") != ""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
dispatched_files = []
def _mock_llm(paths, **kwargs):
dispatched_files.extend(p.name for p in paths)
on_chunk = kwargs.get("on_chunk_done")
if on_chunk:
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
return {
"nodes": [{"id": "guide_doc", "label": "Guide Doc", "source_file": "guide.md", "file_type": "document"}],
"edges": [],
"hyperedges": [],
"input_tokens": 10,
"output_tokens": 5,
}
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _mock_llm)
argv = ["graphify", "extract", str(project), "--backend", "claude",
"--no-cluster", "--out", str(out_dir)]
_run_extract(monkeypatch, argv)
out_text = capsys.readouterr().out
assert "re-queuing 1 manifest-stamped semantic file" in out_text, (
f"poisoned semantic file must be healed via re-queue: {out_text}"
)
assert "guide.md" in dispatched_files, "guide.md must have been dispatched"
# Verify guide.md is now in graph.json
g = json.loads(graph_path.read_text(encoding="utf-8"))
node_ids = {n["id"] for n in g.get("nodes", [])}
assert "guide_doc" in node_ids, "guide.md node must be present in graph.json after healing"