Skip to content

fix(llm): hold semantic source_file to the dispatched set, not to existence (#2217) - #2260

Open
hassaanch23 wants to merge 3 commits into
Graphify-Labs:v8from
hassaanch23:fix/semantic-provenance-invariant-2217
Open

fix(llm): hold semantic source_file to the dispatched set, not to existence (#2217)#2260
hassaanch23 wants to merge 3 commits into
Graphify-Labs:v8from
hassaanch23:fix/semantic-provenance-invariant-2217

Conversation

@hassaanch23

Copy link
Copy Markdown

Closes #2217.

The defect

The merged-graph filter and the semantic cache disagree about what counts as
valid provenance, and graph.json takes the lenient side.

# graphify/llm.py — _out_of_scope (#1895): drops only an EXISTING file
return p.is_file() and p not in _dispatched_resolved

# graphify/cache.py — save_semantic_cache's write loop refuses both halves:
#   a path that is not a real file, and (#1757) a real file outside the batch
if p.is_file():
    if allowed_paths is not None and p not in allowed_paths:
        ...skip...          # #1757 out-of-scope guard
else:
    skipped_not_file += 1   # ...and a non-file group is never written at all

A source_file the model invented — a plausible repo path with nothing behind
it — has p.is_file() == False, so the graph admits it and the cache refuses
it. 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.json but never in the cache, so every --update re-extracts the source
and mints a different ghost.

Same script against base and this branch — one dispatched notes.md, a model
returning a node attributed to src/auth/session.py:

base 0b2bd93            after
  ghost in graph : True   ghost in graph : False
  out_of_scope   : 0      out_of_scope   : 1
  cache(ghost)   : 0      cache(ghost)   : 0     <- cache refused it all along
  cache(notes.md): 1      cache(notes.md): 1     <- probe does distinguish accept/refuse

The fix

The existence test is kept and a claim test added alongside it:

real FILE outside the dispatched set  -> misattributed  (#1895, unchanged)
real DIRECTORY                        -> kept
nothing on disk, and claims a file    -> fabricated     (#2217, new)
anything else (prose, concept marker) -> kept

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 cache
still 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 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.
Directory-shaped values are excluded on purpose — cache.py records that
subagent 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 a
destroyed node.

Both halves must canonicalise identically

_claims_a_file flips backslashes and strips segments. If the dispatched-set
lookup does not, the two disagree in the destructive direction: on POSIX a node
attributed to a genuinely dispatched file as pkg\B.md misses the dispatched
set, misses is_file()/is_dir(), and is then deleted as a fabrication by the
one half that did normalise it — taking its edges with it.

And it would not even stay deleted. save_semantic_cache already applies
_normalize_source_file_value (#2197 — same field, same problem) to every item
before 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 --update puts it straight back. That is this PR's own
invariant 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 over
pkg/B.md, pkg/ B.md, pkg\B.md and pkg\B.md.

Further defects found while testing the above

All become reachable once dropping stops being rare:

Verdicts are memoised per source_file value — one corpus repeats the same path
across thousands of items, and each miss costs two stat syscalls.

Commit 2: the prompt

The skill path has carried a source_file RULE since #1366 ("copy the FILE_LIST
entry 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 the
model. 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
(f33081f95084850648cc0d13), so under #1939 every existing semantic cache
entry 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 3
turns the check into a standing test.

fixture nodes ground truth attributions checked fabricated
rsl-siege-manager 1,886 (1,268 AST + 618 LLM rationale) 293-file manifest 1,880 0
karpathy-repos 177 AST layer 0 → skipped
httpx, mixed-corpus 166 pure AST, no independent truth 0 → skipped

Only 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 are
bare id+community stubs with no source_file at all — not LLM output — so
they are skipped, not counted as audited.

Mutation-tested: injecting a fabricated rationale node with a full path fails
the audit, and the claim floor (≥1,800) 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.

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 endswith fallback that accepted any
trailing segment, so it would have accepted source_file: "env.py" against a
manifest containing ...\alembic\env.py; the reference set is now reduced to
corpus-relative form and compared exactly.

Diagnostics

out_of_scope_dropped keeps its meaning as the node total. New counters split
failure modes that have different causes and different fixes:

Tests

tests/test_provenance_invariant.py covers the defect, the edge/hyperedge
cascade, 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 v8
before 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_labeling list-ordering, test_manifest_ingest KeyError: 'version').
python -m tools.skillgen --check passes (134 artifacts).

hassaanch23 and others added 3 commits July 28, 2026 17:32
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>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --prove to 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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_run()

15 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Semantic extraction: nonexistent model-invented source_file bypasses #1895 graph filter

1 participant