From 380fa18ba15f43bdc8a83952e07ddd37c286f8d8 Mon Sep 17 00:00:00 2001 From: himanshupatro-334 Date: Fri, 21 Aug 2026 12:54:35 +0530 Subject: [PATCH] fix(cache): retry zero-node semantic results --- graphify/cache.py | 17 ++++ graphify/cli.py | 128 ++++++++++++++++++++++++- tests/test_cache.py | 128 +++++++++++++++++++++++++ tests/test_extract_cli.py | 193 +++++++++++++++++++++++++++++++++++++- 4 files changed, 460 insertions(+), 6 deletions(-) diff --git a/graphify/cache.py b/graphify/cache.py index 833fdaa861..93d87c709a 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 hyperedge_dangles(h: dict) -> bool: ) 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 diff --git a/graphify/cli.py b/graphify/cli.py index 02e55b9447..fd4c474f03 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -133,7 +133,10 @@ def _resolve(value: str) -> Path: 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. @@ -3195,6 +3293,34 @@ def _parse_float(name: str, raw: str) -> float: 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( diff --git a/tests/test_cache.py b/tests/test_cache.py index 920526dc18..c37d6fb806 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -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 diff --git a/tests/test_extract_cli.py b/tests/test_extract_cli.py index fc98dc6e0e..754120996b 100644 --- a/tests/test_extract_cli.py +++ b/tests/test_extract_cli.py @@ -416,28 +416,35 @@ def _fresh_relative(paths, **kwargs): 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"