From e519fcd271e7fabf77e128435894f40c759f709b Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Wed, 26 Aug 2026 17:08:03 +0530 Subject: [PATCH] fix(extract): hollow, unparseable and omitting chunks count as incomplete (#3105) The #479 shrink guard is bypassed (force=True) on a run classified as complete, and _extraction_incomplete tracked hard failures only: a crashed pass, a chunk that raised. A chunk that comes back hollow after every retry, or as invalid JSON, or that simply omits some of its files does not raise - it returns fewer nodes and counts as a SUCCEEDED chunk. So two consecutive --update runs on an unchanged repo: the first had 3 raised chunks and the guard refused; the second had 0 raised and 6 hollow, read as complete, and wrote 111 nodes over a 570-node graph without a word. With an LLM backend that is the normal way an extraction silently produces a fraction of the graph, so it now arms the guard exactly like a crashed chunk does: a non-empty uncovered_files list (files the model omitted, which is where invalid-JSON chunks land) or any partial/hollow file marks the run incomplete, with a stderr line saying so. A complete run keeps force=True as before; --allow-partial still overrides; a refused write still leaves the manifest unstamped so the next run retries. --- graphify/cli.py | 20 ++++ tests/test_hollow_chunks_arm_shrink_guard.py | 118 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 tests/test_hollow_chunks_arm_shrink_guard.py diff --git a/graphify/cli.py b/graphify/cli.py index bd2956815..62698fdab 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3813,6 +3813,26 @@ def _progress(idx: int, total: int, _result: dict) -> None: _strip_partial_markers as _strip_partial, ) _partial_semantic_files = set(_partial_sf(fresh)) + # A chunk that came back hollow after every retry, or as + # unparseable JSON, or that simply omitted some of its files, + # does not raise - it returns fewer nodes - so it counted as a + # SUCCEEDED chunk above and the run read as complete, force=True + # bypassed the shrink guard, and a 570-node graph was overwritten + # with 111 nodes without a word (#3105). With an LLM backend + # that is the normal way an extraction silently produces a + # fraction of the graph, so it must arm the guard exactly like a + # crashed chunk does. --allow-partial still overrides. + _omitted_files = list(fresh.get("uncovered_files") or []) + if _omitted_files or _partial_semantic_files: + _extraction_incomplete = True + print( + f"[graphify extract] semantic extraction is incomplete: " + f"{len(_omitted_files)} dispatched file(s) produced no nodes and " + f"{len(_partial_semantic_files)} came back truncated or hollow. " + f"The shrink guard stays armed for this write; pass " + f"--allow-partial to overwrite a larger existing graph anyway.", + file=sys.stderr, + ) try: _save_semantic_cache( fresh.get("nodes", []), diff --git a/tests/test_hollow_chunks_arm_shrink_guard.py b/tests/test_hollow_chunks_arm_shrink_guard.py new file mode 100644 index 000000000..01d6c2082 --- /dev/null +++ b/tests/test_hollow_chunks_arm_shrink_guard.py @@ -0,0 +1,118 @@ +"""Hollow, unparseable and omitting chunks must count as incomplete (#3105). + +The #479 shrink guard is bypassed (force=True) on a run that is classified +as complete. `_extraction_incomplete` tracked hard failures only — a crashed +pass, a chunk that raised. A chunk that came back hollow after every retry, +or as invalid JSON, or that simply omitted some of its files does not raise: +it returns fewer nodes and counts as a SUCCEEDED chunk. Two consecutive +`--update` runs on an unchanged repo: the first had 3 raised chunks and the +guard refused; the second had 0 raised, 6 hollow, and wrote 111 nodes over a +570-node graph without a word. +""" +from __future__ import annotations + +import pytest + +import graphify.__main__ as mainmod + + +def _corpus(tmp_path): + (tmp_path / "README.md").write_text("# Notes\nThe entry point overview.\n", encoding="utf-8") + (tmp_path / "GUIDE.md").write_text("# Guide\nHow to use the thing.\n", encoding="utf-8") + return tmp_path + + +def _record_force(monkeypatch): + rec = {"called": False, "force": None} + + def _stub(G, communities, output_path, *, force=False, **kwargs): + rec["called"] = True + rec["force"] = force + return True + + monkeypatch.setattr("graphify.export.to_json", _stub) + return rec + + +def _arm(monkeypatch, tmp_path, *, uncovered=(), partial=(), extra_argv=()): + corpus = _corpus(tmp_path) + out_dir = tmp_path / "out" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key") + + def _stub_corpus(paths, **kwargs): + # Every chunk "succeeds": the callback fires for each, nothing raises. + on_chunk = kwargs.get("on_chunk_done") + if on_chunk: + on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []}) + nodes = [{"id": "s1", "source_file": str(corpus / "README.md"), + "file_type": "document", "label": "Notes"}] + for sf in partial: + nodes.append({"id": f"p_{sf}", "source_file": str(corpus / sf), + "file_type": "document", "label": sf, "_partial": True}) + return {"nodes": nodes, "edges": [], "hyperedges": [], + "input_tokens": 10, "output_tokens": 5, + "uncovered_files": [str(corpus / sf) for sf in uncovered]} + + monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _stub_corpus) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, "argv", + ["graphify", "extract", str(corpus), "--backend", "claude", + "--out", str(out_dir), *extra_argv], + ) + return out_dir + + +def _run(): + try: + mainmod.main() + except SystemExit as exc: + return exc.code + return 0 + + +def test_a_chunk_that_omitted_files_arms_the_shrink_guard(monkeypatch, tmp_path, capsys): + rec = _record_force(monkeypatch) + _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",)) + _run() + assert rec["called"] and rec["force"] is False, "an omitting chunk must not bypass the guard" + assert "semantic extraction is incomplete" in capsys.readouterr().err + + +def test_a_hollow_chunk_arms_the_shrink_guard(monkeypatch, tmp_path, capsys): + """After every retry a hollow chunk is returned (not raised) with its + files marked partial; that is the reporter's run 2.""" + rec = _record_force(monkeypatch) + _arm(monkeypatch, tmp_path, partial=("GUIDE.md",)) + _run() + assert rec["called"] and rec["force"] is False + assert "1 came back truncated or hollow" in capsys.readouterr().err + + +def test_allow_partial_still_overrides(monkeypatch, tmp_path): + rec = _record_force(monkeypatch) + _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",), extra_argv=["--allow-partial"]) + _run() + assert rec["called"] and rec["force"] is True + + +def test_a_run_with_every_file_covered_keeps_force_write(monkeypatch, tmp_path, capsys): + """The ordinary complete run is unchanged: a full build legitimately + shrinks (dedup, deleted code) and keeps bypassing the guard.""" + rec = _record_force(monkeypatch) + _arm(monkeypatch, tmp_path) + _run() + assert rec["called"] and rec["force"] is True + assert "semantic extraction is incomplete" not in capsys.readouterr().err + + +def test_the_manifest_is_not_stamped_when_the_guard_refuses(monkeypatch, tmp_path): + """Refusal must leave the omitted files un-stamped so the next run retries + them — the same contract a crashed chunk already has.""" + def _refuse(G, communities, output_path, *, force=False, **kwargs): + return False + monkeypatch.setattr("graphify.export.to_json", _refuse) + out_dir = _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",)) + code = _run() + assert code not in (None, 0) + assert not (out_dir / "graphify-out" / "manifest.json").exists()