Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
128 changes: 127 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
128 changes: 128 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading