Skip to content

Semantic extraction cannot tell a file with no entities from an omitted file, so empty files arm the shrink guard #4547

Open

Description

@tobimax

Summary

Semantic extraction cannot tell "the model found no entities in this file" from "the model omitted this file". Both end up in uncovered_files, both make the extraction count as incomplete, and both keep the shrink guard armed, so a file that legitimately has nothing to extract (a robots.txt, an HTML shell, a short compose file) fails a headless graphify extract exactly as a dropped file does, on every run.

Version

graphifyy 0.9.92, graphify extract . --backend claude --model claude-haiku-5-5 --force in CI.

What happens

In llm.py, after the chunks are merged, a dispatched file is "uncovered" when no returned node has it as source_file:

uncovered = sorted(p for p in dispatched if p.resolve() not in {c.resolve() for c in covered})
...
WARNING: N/M dispatched file(s) produced no nodes and are absent from the graph ... The model returned a response but omitted them; a re-run will retry them.

The extraction schema in _EXTRACTION_SYSTEM has no way for the model to say "I read this file and there is nothing to extract": the response is only nodes, edges and hyperedges, and nothing asks for one acknowledgement per file. So absence of nodes is the only signal, and it means two different things.

On our run, 18 of 330 files were uncovered with no truncation and no hollow responses ("the model returned complete, well-formed responses that simply did not address every file"). The ones named include README.md, docker-compose.yml and two index.html files; HTML shells and compose files may well have nothing worth a node. The guard then refused the write, which is correct for omitted files and wrong for empty ones, and a re-run with the same inputs reproduces it.

Two smaller points in the same place:

  • The warning prints five names and "+N more"; the full list is not written anywhere a CI job can read after the run (it lives in memory as uncovered_files).
  • A node the model mis-attributes to another file's source_file also leaves the real file uncovered, which is a third case behind the same signal.

Expected

One of:

  1. A per-file acknowledgement in the schema (for example "files": [{"path": "...", "entities": 0}], or a required file-level node per dispatched file), so "read, nothing to extract" is distinguishable from "not addressed", and only the latter counts as incomplete; or
  2. A file-level node always minted for each dispatched document (as the AST pass does for code), so coverage is never zero for a file the model actually read.

And the full uncovered list written to a file in graphify-out/ (or printed in full under a flag), so CI can report it.

Workaround we are using

Keeping non-markdown files out of the semantic pass with .graphifyignore patterns, and a smaller --token-budget so fewer files share a chunk.

Related

#3412 (the unverified-semantic-shrink guard has no verification path), #4546 (thinking on the claude backend).

Activity

  1. github-actions commented on Oct 18, 2026

    @github-actions

    Thanks for opening this issue, @tobimax. A maintainer will take a look soon.

    If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.

    To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.

  2. Deepjyoti-Sarmah commented on Oct 19, 2026

    @Deepjyoti-Sarmah
    Contributor

    I reproduced this on the latest v8 (0.9.92). It happens the way the report says: a file with zero nodes always lands in uncovered_files, and that sets _extraction_incomplete. With that set, --force is off, so any run where the graph shrinks exits non-zero. The extraction format gives the model no way to say "I read this file and there's nothing in it."

    I have a fix ready on a branch. It follows option 1 from the issue:

    • The extraction format gets an optional top-level files list, one entry per file in the chunk: {"path": "...", "entities": 0}.
    • A file with no nodes that's listed with entities: 0 goes into a new empty_files list. It isn't counted as uncovered and doesn't set off the shrink guard.
    • These cases behave exactly as they do today: a file the model doesn't list, a file listed with entities > 0 but no node behind it, and a response with no files key. Paths that weren't in the chunk are ignored. A file from a truncated or hollow chunk stays uncovered whatever the model says about it.
    • A response that only acknowledges files is no longer treated as hollow, but only when every file in the chunk is listed as empty. Otherwise a lone robots.txt would be retried twice and then marked partial. If the response leaves out any file in the chunk, it still gets the normal hollow retry.
    • The full uncovered list is written to graphify-out/uncovered_files.txt, so CI can show it. A stale copy is removed on the next run.
    • The warning no longer says a re-run will fix it. Re-sending the same chunk usually skips the same files.

    Limits I know about:

    1. The guard now trusts the model's 0. If the model writes nodes for some files and falsely lists the rest as 0, those files won't trigger the guard. The omission in this report (files never mentioned) is still caught.
    2. If a large document is split into slices and one slice reports 0 while another slice is never mentioned, the file counts as empty.
    3. references/extraction-spec.md has no files rule yet, so skill-driven runs behave as they do today.
    4. Empty files are still sent on every run. Semantic results with edges but no nodes are cached, permanently freezing the file out of the graph #2927 keeps zero-node results out of the semantic cache on purpose, and I didn't want to change that rule in this PR. I'm happy to do it as a follow-up if you want empty acknowledgements cached.

    The full suite passes, and there's a new test file covering each case above. I'll open the PR unless you'd rather take a different direction, such as option 2 (always creating a file-level node).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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