Skip to content

extract silently overwrote a 570-node graph with 111 nodes: hollow LLM responses don't count as "incomplete" #3105

Description

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:

  1. Count hollow/invalid-JSON responses toward _extraction_incomplete. They are not clean successes — the adaptive bisect already treats a hollow response as truncation.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions