Version: 0.9.27 · Windows · --backend ollama --model qwen2.5-coder:7b
What happened
Two consecutive graphify . --update runs on the same unchanged repo. The first was refused by the #479 shrink guard and preserved the graph. The second wrote 111 nodes over an existing 570-node graph, with no warning at all.
| run |
chunks failed |
hollow responses |
invalid JSON |
shrink guard |
result |
| 1 |
3 |
8 |
8 |
fired (×2) |
refused — graph preserved at 570 |
| 2 |
0 |
6 |
6 |
never fired |
wrote 111 nodes / 118 edges |
The graph lost ~80% of its nodes. Per-file, the largest subfolder went from 378 nodes to 39; one 138 KB markdown file went from 77 to 12. Nothing was deleted from the repo between runs — it was the same corpus, one commit old.
Why the guard didn't fire
cli.py:3458:
_force_write = cli_allow_partial or not _extraction_incomplete
_wrote = _to_json(G, communities, str(graph_json_path), force=_force_write)
The comment above it explains the intent, and it's reasonable:
force=True bypasses the #479 shrink guard entirely. A full build legitimately shrinks (fuzzy dedup collapse, deleted code) so it keeps force=True — EXCEPT when this run's extraction was incomplete (an extractor pass crashed or some semantic chunks failed).
The gap is in what counts as incomplete. _extraction_incomplete tracks hard failures — a crashed extractor pass, a chunk that errored. It does not track the two outcomes that dominated run 2:
ollama returned a hollow response (content=no nodes/edges, output_tokens=N); treating as truncation so adaptive retry can bisect the chunk
LLM returned invalid JSON, skipping chunk
Both are logged as warnings and handled gracefully — the run continues, no pass "fails", and _extraction_incomplete stays False. So the run is classified as complete, force=True, and the shrink guard is bypassed.
With an LLM backend, a hollow or unparseable response is the normal way an extraction silently produces a fraction of the nodes. That is exactly the case the guard exists to catch, and it's the one case where it's disabled.
Run 2 also emitted this, which shows the loss was visible to graphify at write time:
WARNING: 10/24 dispatched file(s) produced no nodes and are absent from the graph:
<file>.md, <file>.md, <file>.pdf, <file>.pdf, <file>.pdf (+5 more). The model returned a
response but omitted them; a re-run will retry them.
10 of 24 files contributed nothing, and the run still counted as complete.
Reproduce
Any repo with large markdown files (mine has 177 KB / 138 KB / 119 KB / 98 KB) and a small local model, so the LLM returns prose instead of JSON:
graphify <repo> --update # some chunks fail -> refused, graph preserved
graphify <repo> --update # none fail, several hollow -> overwrites with a fraction
The second run benefits from the first run's cached chunks, which is what removes the hard failures while leaving the hollow ones.
Suggested fix
Either would have prevented the loss:
- Count hollow/invalid-JSON responses toward
_extraction_incomplete. They are not clean successes — the adaptive bisect already treats a hollow response as truncation.
- Make the shrink guard a magnitude check that
force cannot fully disable. A full build legitimately shrinks by a few percent from dedup; an 80% drop is not that. A ratio threshold (say, refuse below ~50% of the existing node count unless --allow-partial) keeps the legitimate-shrink case working while catching this one.
(1) is the more precise fix; (2) is the backstop that doesn't depend on classifying every future failure mode correctly.
Also worth considering: the WARNING: N/M dispatched file(s) produced no nodes line already computes the signal. If N/M is large, that alone is strong evidence the run should not be trusted to overwrite.
Impact
The good graph was recoverable only because backup_if_protected had written 2026-08-26/graph.json before the overwrite — thank you for that. Without it the run would have been unrecoverable, and the loss is silent: exit code 0, no warning, and the next incremental run stamps the manifest against the degraded graph.
Version: 0.9.27 · Windows ·
--backend ollama --model qwen2.5-coder:7bWhat happened
Two consecutive
graphify . --updateruns on the same unchanged repo. The first was refused by the #479 shrink guard and preserved the graph. The second wrote 111 nodes over an existing 570-node graph, with no warning at all.The graph lost ~80% of its nodes. Per-file, the largest subfolder went from 378 nodes to 39; one 138 KB markdown file went from 77 to 12. Nothing was deleted from the repo between runs — it was the same corpus, one commit old.
Why the guard didn't fire
cli.py:3458:The comment above it explains the intent, and it's reasonable:
The gap is in what counts as incomplete.
_extraction_incompletetracks hard failures — a crashed extractor pass, a chunk that errored. It does not track the two outcomes that dominated run 2:ollama returned a hollow response (content=no nodes/edges, output_tokens=N); treating as truncation so adaptive retry can bisect the chunkLLM returned invalid JSON, skipping chunkBoth are logged as warnings and handled gracefully — the run continues, no pass "fails", and
_extraction_incompletestaysFalse. So the run is classified as complete,force=True, and the shrink guard is bypassed.With an LLM backend, a hollow or unparseable response is the normal way an extraction silently produces a fraction of the nodes. That is exactly the case the guard exists to catch, and it's the one case where it's disabled.
Run 2 also emitted this, which shows the loss was visible to graphify at write time:
10 of 24 files contributed nothing, and the run still counted as complete.
Reproduce
Any repo with large markdown files (mine has 177 KB / 138 KB / 119 KB / 98 KB) and a small local model, so the LLM returns prose instead of JSON:
The second run benefits from the first run's cached chunks, which is what removes the hard failures while leaving the hollow ones.
Suggested fix
Either would have prevented the loss:
_extraction_incomplete. They are not clean successes — the adaptive bisect already treats a hollow response as truncation.forcecannot fully disable. A full build legitimately shrinks by a few percent from dedup; an 80% drop is not that. A ratio threshold (say, refuse below ~50% of the existing node count unless--allow-partial) keeps the legitimate-shrink case working while catching this one.(1) is the more precise fix; (2) is the backstop that doesn't depend on classifying every future failure mode correctly.
Also worth considering: the
WARNING: N/M dispatched file(s) produced no nodesline already computes the signal. IfN/Mis large, that alone is strong evidence the run should not be trusted to overwrite.Impact
The good graph was recoverable only because
backup_if_protectedhad written2026-08-26/graph.jsonbefore the overwrite — thank you for that. Without it the run would have been unrecoverable, and the loss is silent: exit code 0, no warning, and the next incremental run stamps the manifest against the degraded graph.