fix(llm): hold semantic source_file to the dispatched set, not to existence (#2217) - #2260
fix(llm): hold semantic source_file to the dispatched set, not to existence (#2217)#2260hassaanch23 wants to merge 3 commits into
Conversation
Graphify-Labs#2217) `_out_of_scope` (Graphify-Labs#1895) dropped a node only when its `source_file` resolved to an EXISTING file outside the dispatched set: return p.is_file() and p not in _dispatched_resolved so a path the model invented — one that exists nowhere — was admitted, and the comment called it a "model-invented anchor" passing through by design. The cache disagrees. `save_semantic_cache` refuses the identical attribution: its write loop skips any group whose path is not a real file, and Graphify-Labs#1757's `allowed_source_files` guard skips any real file outside the dispatched batch. So graph.json accumulated nodes the cache would never persist, and on each `--update` the source was re-extracted and minted a *different* ghost — the non-convergence reported in Graphify-Labs#2217. Same script on base and on this commit, one dispatched notes.md, a node attributed to src/auth/session.py: base ghost in graph: True cache entries for ghost: 0 after ghost in graph: False cache entries for ghost: 0 (both: cache entries for notes.md: 1 — the probe does distinguish) The existence test is KEPT and a claim test added ALONGSIDE it, never in place of it: real FILE outside the dispatched set -> misattributed (Graphify-Labs#1895, unchanged) real DIRECTORY -> kept nothing on disk, and claims a file -> fabricated (Graphify-Labs#2217, new) anything else -> kept Replacing the existence arm instead of extending it looks equivalent and is not: it re-admits every real undispatched file whose name carries no corpus extension (`Makefile`, `LICENSE`, `pyproject.toml`, `data.csv`), which the cache still refuses — reopening the very divergence this closes. Pinned by test_filter_is_a_superset_of_the_1895_existence_check. Recognising a "claim" is deliberately conservative, because a false positive DELETES a node and cascades through its edges. A separator alone is not evidence — models put "N/A", "CI/CD", "TCP/IP", "and/or", "client/server architecture" in this field. A corpus suffix alone is not either — "Node.js", "Vue.js", "D3.js". So a claim requires both: a separator, and a final segment that is a single token ending in a known corpus extension. URLs and directory-shaped values are excluded on purpose; cache.py records that subagent fragments really do carry a directory here, and the base filter kept all of them. Accepted gap, stated plainly: a fabricated BARE basename ("session.py", no directory) is not recognised, because nothing syntactic separates it from the technology names above. When it names a real file the existence arm still drops it; when it names nothing it survives. Precision-first, matching `_bind_node_evidence`, which likewise prefers a missed fabrication to a destroyed node. Both halves canonicalise a source_file the same way, via cache.py's existing `_normalize_source_file_value` (Graphify-Labs#2197, same field, same problem). Normalising in only one half is worse than normalising in neither: on POSIX a node attributed to a genuinely dispatched file with Windows separators ("pkg\B.md") missed the dispatched set and the existence check, but the claim test — which does flip backslashes — called it a fabrication, so the node was deleted while save_semantic_cache stored it under the real path. That is this filter's own invariant inverted, and cache replay would put the node straight back. Five further defects found while testing the above, all reachable once dropping stops being rare: - a node id appearing twice (chunks are concatenated) could be dropped in one copy and kept in another; the cascade then stripped every edge from the surviving, correctly-attributed copy. Mirrors cache.py Graphify-Labs#1916. - a non-scalar id or edge endpoint (Graphify-Labs#1631's malformed-result class: a list where a scalar belongs) raised TypeError and aborted the run after every chunk had been paid for. cache.py guards the same spot. - an edge or hyperedge carrying its own bad source_file was filtered only when some node happened to be dropped, so a clean-nodes/dirty-edge response went unexamined. Now filtered unconditionally, and counted. - `_endpoint_dropped` judged both endpoints under one try/except, so an unhashable `source` masked a genuinely dropped `target`. - PRE-EXISTING on 0b2bd93, verified there and guarded here only because the filter above now guards the identical class two blocks up: a non-string source_file reached `Path()` in the Graphify-Labs#1890 reconciliation and aborted the run. Verdicts are memoised per source_file value — one corpus repeats the same path across thousands of items and each miss costs two stat syscalls. Counters: `out_of_scope_dropped` keeps its meaning as the node total; `provenance_dropped_misattributed` / `_fabricated` split the node failure modes; `provenance_dropped_edges` / `_hyperedges` report the edge side (both bad- attribution and endpoint-cascade removals). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ify-Labs#2217) The skill path has told agents since Graphify-Labs#1366 to copy source_file from the FILE_LIST "character-for-character". The native/API prompt said nothing: it showed `"source_file":"relative/path"` in the schema and left provenance to the model's judgement. That is the upstream half of Graphify-Labs#2217 — a DeepSeek run reconstructed a plausible repo path out of a node id quoted inside a document, because nothing told it not to. Adds the rule to `_EXTRACTION_SYSTEM`: copy from the enclosing <untrusted_source path="..."> block, never infer from a label, node id, citation, basename or import, and omit rather than reconstruct. The test asserts against the RULE PARAGRAPH, not the whole prompt. Matching the whole prompt proves nothing here: the base prompt already contains "<untrusted_source>" (the SECURITY paragraph), "node id" ("Node ID format: ...") and "citation" ("EXTRACTED: relationship explicit in source (import, call, citation, ...)"), so a rule saying the exact opposite — "pick whatever source_file seems most plausible (basename is fine)" — passed a whole-prompt assertion green. Verified by mutation: that inverted rule now fails the test. Split out because it carries a cost the code fix does not: - prompt fingerprint f33081f95084 -> 850648cc0d13, so every existing semantic cache entry falls out of its namespace and the next run re-extracts the corpus for real money (Graphify-Labs#1939 working as designed — this IS a prompt change — but it is a one-time bill for every user on upgrade) - +490 chars (~122 tokens) on every extraction request If that trade is not worth it for a given release, drop this commit and keep the first; the graph stays correct either way, it just keeps paying to filter fabrications instead of asking for fewer. Commit 1 passes the suite standalone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…raphify-Labs#2217) `worked/` holds the only production-shaped semantic output in the tree, so it is where a provenance regression would first be visible. Each graph's nodes are judged against what that run demonstrably knew about. `worked/rsl-siege-manager` ships a manifest, so all 1,886 nodes (1,268 AST, 618 LLM `rationale`) are checked against the 293 files the run walked; 1,880 carry a file-claiming source_file and all 1,880 resolve into the manifest. The manifest-less graphs have no independent ground truth for code nodes — a fabricated one would contribute its own path to the reference set and validate itself — so only the semantic layer is audited there, against the deterministic AST layer. Three things this deliberately does NOT do: - It does not pass vacuously. `worked/karpathy-repos`'s 33 non-code nodes carry no `source_file` at all (bare id+community stubs, not LLM output), so that fixture now SKIPS rather than reporting green on zero checks. httpx and mixed-corpus are pure AST and skip outright. Only one fixture carries signal and the test says so. - It does not match a bare basename. Reducing the manifest's absolute keys to corpus-relative form and comparing exactly replaced an `endswith` fallback that accepted any trailing segment. (Both spellings of the audit catch a fabricated FULL path; only this one would reject `source_file: "env.py"` against a manifest containing `...\alembic\env.py` — and "inferred it from a basename" is precisely what the new prompt rule forbids. Note the filter itself still permits that value: see commit 1's stated bare-basename gap.) - It does not mangle dot-directories. `lstrip("./")` strips a character SET, so `.github/workflows/ci.yml` became `github/...` and read as fabricated. Mutation-tested: injecting a fabricated `rationale` node with a full path fails the audit. The claim floor (>= 1,800 for rsl-siege-manager) is asserted BEFORE the skip, so a total collapse of `_claims_a_file` fails rather than quietly skipping the only fixture that checks anything — verified by forcing it False. Scope stated honestly in the module docstring: this cannot guard `_claims_a_file` against over-tightening, because that function only decides which values get checked and every attribution here is a real manifest path. Over-tightening is covered by test_values_that_do_not_claim_a_file and by Graphify-Labs#1895's own test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review panel
Reviewed by claude, gpt.
This pull request modifies the provenance filtering logic in graphify/llm.py that decides which extracted nodes/edges are kept in the merged graph based on their source_file attribute. It adds a new _claims_a_file helper (with a lazily-loaded, memoized set of corpus file extensions) and reworks the out-of-scope filter into a _scope_verdict function that distinguishes "misattributed" real files (existing #1895 behavior) from newly-handled "fabricated" file-claiming paths that resolve to nothing (#2217), while reusing the cache module's source-file normalizer and adding resolution/verdict caching. The change touches the extraction spec prompt text (adding an explicit source_file rule) and expands the test suite across LLM backend, provenance-invariant, and worked-graph provenance test files. Reviewers should focus on the new provenance classification helpers, the normalization consistency between the filter and the cache path, and the associated new tests.
No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
graphify review
Impact — 671 function(s) in the blast radius of 307 changed node(s).
Health — regressions introduced by this change:
- worsened (Ca·Ce 115→150):
extract_corpus_parallel()(Ca=25 Ce=6) — fans out to 6 callees (efferent coupling); 25 callers depend on it (afferent coupling) - new offender:
_run()(Ca=15 Ce=1) — 15 callers depend on it (afferent coupling)
Verification — 671 function(s) need re-proving (callee-first):
tests_test_worked_graph_provenance_norm → tests_test_worked_graph_provenance_test_norm_preserves_dot_directories → tests_test_worked_graph_provenance_strip_common_prefix → tests_test_worked_graph_provenance_ground_truth → graphify_llm_corpus_suffixes → graphify_llm_claims_a_file → tests_test_worked_graph_provenance_test_no_node_claims_a_file_the_run_never_saw → tests_test_worked_graph_provenance_rationale_77 → tests_test_worked_graph_provenance_rationale_55 → tests_test_worked_graph_provenance_rationale_45 …
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not enforced this run (re-run with --prove to verify the blast radius)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 481 function(s) in blast radius — re-run with
--proveto formally verify them
· 1 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).
| from graphify.llm import _claims_a_file, extract_corpus_parallel | ||
|
|
||
|
|
||
| def _run(files, result, tmp_path): |
There was a problem hiding this comment.
_run()
15 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Closes #2217.
The defect
The merged-graph filter and the semantic cache disagree about what counts as
valid provenance, and
graph.jsontakes the lenient side.A
source_filethe model invented — a plausible repo path with nothing behindit — has
p.is_file() == False, so the graph admits it and the cache refusesit. The old comment described this as intentional ("non-file source_files
(concepts, model-invented anchors) pass through untouched").
That divergence is why #2217 reports non-convergence: the ghost is in
graph.jsonbut never in the cache, so every--updatere-extracts the sourceand mints a different ghost.
Same script against base and this branch — one dispatched
notes.md, a modelreturning a node attributed to
src/auth/session.py:The fix
The existence test is kept and a claim test added alongside it:
Replacing the existence arm rather than extending it looks equivalent and is
not: it re-admits every real undispatched file whose name carries no corpus
extension (
Makefile,LICENSE,pyproject.toml,data.csv), which the cachestill refuses — reopening the exact divergence this closes, for that class.
Pinned by
test_filter_is_a_superset_of_the_1895_existence_check.Recognising a "claim" is deliberately conservative, because a false positive
deletes a node and cascades through its edges. A separator alone is not
evidence — models put
N/A,CI/CD,TCP/IP,and/or,I/O,client/server architecturein this field. A corpus suffix alone is not either—
Node.js,Vue.js,D3.js. So a claim requires both: a separator, and afinal segment that is a single token ending in a known corpus extension.
Directory-shaped values are excluded on purpose —
cache.pyrecords thatsubagent fragments really do carry a directory here, and the base filter kept
them.
Accepted gap, stated plainly: a fabricated bare basename (
session.py,no directory) is not recognised, because nothing syntactic separates it from the
technology names above. When it names a real file the existence arm still drops
it; when it names nothing it survives. Precision-first, matching
_bind_node_evidence, which likewise prefers a missed fabrication to adestroyed node.
Both halves must canonicalise identically
_claims_a_fileflips backslashes and strips segments. If the dispatched-setlookup does not, the two disagree in the destructive direction: on POSIX a node
attributed to a genuinely dispatched file as
pkg\B.mdmisses the dispatchedset, misses
is_file()/is_dir(), and is then deleted as a fabrication by theone half that did normalise it — taking its edges with it.
And it would not even stay deleted.
save_semantic_cachealready applies_normalize_source_file_value(#2197 — same field, same problem) to every itembefore grouping, so the cache stores that node under the real dispatched path
while the graph deletes it. Since the merged-result filter is bypassed on cache
replay, the next
--updateputs it straight back. That is this PR's owninvariant inverted.
So the filter reuses the repo's existing normaliser rather than a second, subtly
different one. Pinned by
test_alternate_spellings_of_a_dispatched_path_are_tolerated, parametrised overpkg/B.md,pkg/ B.md,pkg\B.mdandpkg\B.md.Further defects found while testing the above
All become reachable once dropping stops being rare:
be dropped in one copy and kept in another; the cascade then stripped every
edge from the surviving, correctly-attributed copy.
cache.pyfixed this samehazard in its own prune (fix(cache): prune edges/hyperedges referencing never-written node groups #1916,
skipped_ids -= written_ids).where a scalar belongs — raised
TypeErrorand aborted the run after everychunk had been paid for.
cache.pyguards the same spot.source_filewas filtered only whensome node happened to be dropped, so a clean-nodes/dirty-edge response went
unexamined — while
save_semantic_cachegroups edges by their ownsource_fileand refuses them. Now filtered unconditionally, and counted._endpoint_droppedjudged both endpoints under onetry/except, so anunhashable
sourcemasked a genuinely droppedtargetand left the edgedangling.
source_filewas read as a claimed corpus path and deleted. A URL isa
source_url; base kept these.v8, not introduced here (verified on the base commit): anon-string
source_filereachedPath()in the extract: LLM-omitted documents vanish silently — no dispatch/return reconciliation, no anti-regression guard (53% of docs lost even with gpt-5) #1890 reconciliation andaborted the run. Guarded only because the filter above now guards the identical
class two blocks up, and a crash between them would be incoherent. Happy to
split this out if you'd rather it landed separately.
Verdicts are memoised per
source_filevalue — one corpus repeats the same pathacross thousands of items, and each miss costs two
statsyscalls.Commit 2: the prompt
The skill path has carried a
source_file RULEsince #1366 ("copy the FILE_LISTentry character-for-character"). The native/API prompt had no rule at all —
it showed
"source_file":"relative/path"in the schema and left the rest to themodel. That is the upstream half of this bug, and it matches the report (a
DeepSeek run reconstructing a path out of a node id quoted inside a document).
The test asserts against the rule paragraph, not the whole prompt — matching
the whole prompt proves nothing here, since the base prompt already contains
<untrusted_source>(SECURITY), "node id" (Node ID format: ...) and"citation" (
EXTRACTED: ... import, call, citation, ...). Verified by mutation:an inverted rule ("pick whatever source_file seems most plausible, basename is
fine") fails the test.
It is split out because it costs money. It changes the prompt fingerprint
(
f33081f95084→850648cc0d13), so under #1939 every existing semantic cacheentry falls out of its namespace and the next run re-extracts the corpus for
real. Plus +490 chars (~122 tokens) per extraction request. If that trade isn't right for a
release, drop commit 2 and keep commit 1 — the graph is correct either way, it
just keeps filtering fabrications instead of asking for fewer. Commit 1 passes
the suite standalone.
Measurement
worked/is the only production-shaped semantic output in the tree, so commit 3turns the check into a standing test.
rsl-siege-managerrationale)karpathy-reposhttpx,mixed-corpusOnly one fixture carries real signal, and the test now says so rather than
reporting green on the other three.
karpathy-repos's 33 non-code nodes arebare
id+communitystubs with nosource_fileat all — not LLM output — sothey are skipped, not counted as audited.
Mutation-tested: injecting a fabricated
rationalenode with a full path failsthe audit, and the claim floor (≥1,800) is asserted before the skip so a total
collapse of
_claims_a_filefails rather than quietly skipping the only fixturethat checks anything — verified by forcing it
False.An earlier revision of this audit passed the injection outright, because the
reference set was derived from the audited nodes themselves and a fabricated node
validated itself. A second revision used an
endswithfallback that accepted anytrailing segment, so it would have accepted
source_file: "env.py"against amanifest containing
...\alembic\env.py; the reference set is now reduced tocorpus-relative form and compared exactly.
Diagnostics
out_of_scope_droppedkeeps its meaning as the node total. New counters splitfailure modes that have different causes and different fixes:
provenance_dropped_misattributed— a real corpus file you can go read (deep mode: nodes with out-of-scope source_file are warned about but still written to graph.json #1895)provenance_dropped_fabricated— a file-claiming value with nothing on disk (Semantic extraction: nonexistent model-invented source_file bypasses #1895 graph filter #2217)provenance_dropped_edges/provenance_dropped_hyperedges— the edge sideTests
tests/test_provenance_invariant.pycovers the defect, the edge/hyperedgecascade, the counter split, the superset property, duplicate ids, unhashable
ids, whitespace, and a table of values that must and must not read as file
claims. Plus the property whose violation caused the non-convergence: for any
source_file that claims a file, graph admission and cache admission are the same
decision — asserted in both directions so it cannot pass vacuously.
Full suite: 3,659 passed. The 14 failures are identical to the 14 on
v8before this branch (verified by diffing the failure sets): 12 are missing
optional/dev deps in my environment —
tree-sitter-hcl(7),openai(4),build(1) — and 2 are unrelated pre-existing failures(
test_labelinglist-ordering,test_manifest_ingestKeyError: 'version').python -m tools.skillgen --checkpasses (134 artifacts).