Skip to content

Commit 1aae097

Browse files
Juanpacolclaude
andcommitted
fix(consistency): close the function-to-file relation blind spot from ADR-0018
ADR-0018 confirmed, with isolated probes, that a claim like `apply_tax` calls `billing/tax_rates.py` never parsed as a relation at all -- the target pattern (`[\w.]+`) excluded `/`, so the claim silently decomposed into two independent, both-true existence checks and the false relation went unchecked. Fix: extended the relation target pattern in claims.py to accept file paths (`[\w./-]+`), and added check_symbol_calls_file() in check.py, dispatched from check_symbol_relation() whenever the target looks like a path. A file-targeted relation claim asserts a module-level dependency, not a function call, so it's checked against the file-level IMPORTS graph (the same edges GraphQuery.file_dependencies already uses) instead of a symbol-level CALLS edge between function nodes -- reusing existing graph infrastructure, no ingester changes. Verified against the exact probe ADR-0018 used to demonstrate the gap: `apply_tax` calls `billing/tax_rates.py` on the real billing fixture now reports CONTRADICTED ("the file defining 'apply_tax' does not import 'billing/tax_rates.py'") instead of silently vanishing. Along the way, found (but did not fix, as it's a separate pre-existing gap) that the ingester only registers an IMPORTS edge to a package's __init__.py for `from package import submodule`-style imports, not to the submodule file itself. 5 new regression tests (test_claims.py, test_consistency_check.py's new TestSymbolCallsFileRelation). ADR-0018 updated to reflect the fix and its residual, honestly-stated limitations (loose relation phrasing still unrecognized; accuracy bounded by ingester import resolution). 512 tests passing (507 + 5 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 390e4ea commit 1aae097

7 files changed

Lines changed: 180 additions & 17 deletions

File tree

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ producing a genuine mix of true and hallucinated claims:
271271
| Claim class | Result |
272272
|---|---|
273273
| Invented function names (14 across 5 trials) | **100% caught** |
274-
| Function-to-file relation hallucinations (~6) | **0% caught** — structurally invisible to the relation extractor |
274+
| Function-to-file relation hallucinations (~6) | **0% caught at the time** — structurally invisible to the relation extractor |
275275
| Backtick-quoted local variable names (8) | False positives — real names, just not graph-indexed |
276276

277277
A real bug was also found and fixed in decision resurfacing: with only one
@@ -280,10 +280,18 @@ always normalized to 100% confidence regardless of actual relevance — a
280280
genuinely unrelated proposal "resembled" an unrelated rejected decision just
281281
as strongly as an actual paraphrase of it. Confirmed with an isolated probe,
282282
fixed by normalizing against the checked text's own best-possible score
283-
instead of the in-corpus max, and locked in with a regression test. See
284-
ADR-0018 for the full write-up, including what's still an open, unfixed
285-
blind spot (the relation-extraction gap) versus what got fixed this pass
286-
(the resurfacing bug).
283+
instead of the in-corpus max, and locked in with a regression test.
284+
285+
**The function-to-file relation blind spot was closed the same day**: the
286+
relation target pattern now accepts file paths, and a new check
287+
(`check_symbol_calls_file`) verifies the claim against the file-level
288+
`IMPORTS` graph instead of a symbol-level `CALLS` edge. The exact probe
289+
this measurement used to demonstrate the gap —
290+
`` `apply_tax` calls `billing/tax_rates.py` `` — now correctly reports
291+
`CONTRADICTED` instead of vanishing. See ADR-0018 for the full write-up,
292+
including the residual limitations that remain open (loose relation
293+
phrasing, and accuracy bounded by how completely the ingester resolves
294+
`from package import submodule`-style imports).
287295

288296
---
289297

docs/BENCHMARK_PROTOCOL.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@ detection/`, ADR-0018) — not a Family A/B comparison, but the same
5656
"real data before publishing" discipline applied to a different engine.
5757
Real agents, shown only one file of a small codebase, produced a genuine
5858
mix of true and hallucinated claims about it. Result: 100% recall on
59-
invented symbol names (14/14 caught), a confirmed blind spot on
60-
function-to-file relation claims (0% caught, structurally invisible to the
61-
extractor), and a real bug in decision resurfacing — a corpus of one or two
62-
decisions always normalized its closest match to 100% confidence regardless
59+
invented symbol names (14/14 caught); a blind spot on function-to-file
60+
relation claims (0% caught at the time, structurally invisible to the
61+
extractor), closed the same day by extending the relation target pattern
62+
to accept file paths and checking such claims against the file-level
63+
IMPORTS graph instead of a symbol-level CALLS edge; and a real bug in
64+
decision resurfacing — a corpus of one or two decisions always normalized
65+
its closest match to 100% confidence regardless
6366
of actual relevance — found, fixed, and regression-tested.
6467

6568
## The two families

docs/adr/0018-consistency-engine-first-measurement.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,26 @@ synthetic-fixture trap's blind spots.
9292
- **Symbol-existence checking has a clean, confirmed 100% recall** on
9393
invented function names in this pilot — the engine's strongest result so
9494
far.
95-
- **The function-to-file relation blind spot is real and unfixed.** Closing
96-
it would mean extending `claims.py`'s relation extraction to recognize
97-
file targets and to tolerate explanatory text between the relation verb
98-
and its arguments — a real change to the extractor's scope, deliberately
99-
left for a future pilot/ADR rather than rushed into this one.
95+
- **The function-to-file relation blind spot is fixed** (same-day
96+
follow-up): `claims.py`'s relation target pattern now accepts file paths
97+
(`[\w./-]+` instead of `[\w.]+`), and `check.py` gained
98+
`check_symbol_calls_file`, which checks a file-targeted relation claim
99+
against the file-level `IMPORTS` graph instead of a symbol-level `CALLS`
100+
edge. Verified against the exact probe this ADR used to demonstrate the
101+
gap: `` `apply_tax` calls `billing/tax_rates.py` `` now reports
102+
`CONTRADICTED` ("the file defining 'apply_tax' does not import
103+
'billing/tax_rates.py'") instead of silently vanishing into two
104+
independent existence checks. Regression tests added in both
105+
`test_claims.py` and `test_consistency_check.py`
106+
(`TestSymbolCallsFileRelation`). One residual, honestly-stated limitation:
107+
the fix's accuracy is bounded by how completely the ingester resolves
108+
imports — `from package import submodule`-style imports were observed, in
109+
the real `billing/tax.py` fixture, to register only an edge to the
110+
package's `__init__.py`, not to the submodule file. That is a pre-existing
111+
ingester limitation, not introduced by this fix, and is not addressed
112+
here. The extractor's other named gap (tolerating explanatory text
113+
between the relation verb and its arguments, e.g. "likely calls a helper
114+
in") remains open, deliberately out of scope for this pass.
100115
- **Backtick-quoted local variable names are a genuine nuisance-false-
101116
positive source** in real usage — anyone writing a normal technical
102117
summary with `` `some_var` `` for readability will trigger a false

src/verityai/consistency/check.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from pathlib import Path
2626

27-
from verityai.consistency.claims import extract_claims
27+
from verityai.consistency.claims import extract_claims, looks_like_path
2828
from verityai.context.rank import bm25_rank
2929
from verityai.core.models import (
3030
CheckStatus,
@@ -34,6 +34,8 @@
3434
ConsistencyReport,
3535
DecisionStatus,
3636
Evidence,
37+
GraphNode,
38+
NodeKind,
3739
)
3840
from verityai.graph.query import GraphQuery
3941
from verityai.graph.store import EdgeKind
@@ -73,8 +75,62 @@ def check_symbol_exists(claim: Claim, query: GraphQuery) -> ClaimCheck:
7375
)
7476

7577

78+
def check_symbol_calls_file(claim: Claim, query: GraphQuery) -> ClaimCheck:
79+
"""Does the file defining `claim.subject` actually import `claim.target`?
80+
81+
A relation claim whose target is a file, not a function, asserts a
82+
module-level dependency, not a call edge -- there is no CALLS edge to a
83+
file node. Checked instead against the file-level IMPORTS graph
84+
(`GraphQuery.file_dependencies` uses the same edges). This closes the
85+
blind spot found in ADR-0018: "`apply_tax` calls `billing/tax_rates.py`"
86+
used to decompose into two independent, both-true existence checks and
87+
vanish -- neither symbol/file existence check has any way to see that
88+
the claimed relationship between them is false.
89+
"""
90+
subject_nodes = query.define(claim.subject)
91+
if not subject_nodes:
92+
return ClaimCheck(
93+
claim=claim,
94+
status=CheckStatus.CONTRADICTED,
95+
confidence=1.0,
96+
explanation=f"no definition of {claim.subject!r} found",
97+
)
98+
99+
target_path = claim.target or ""
100+
target_file = query.store.get_node(GraphNode.make_id(NodeKind.FILE, target_path))
101+
if target_file is None:
102+
return ClaimCheck(
103+
claim=claim,
104+
status=CheckStatus.CONTRADICTED,
105+
confidence=1.0,
106+
explanation=f"no file at {target_path!r} found in the graph",
107+
)
108+
109+
for subject_node in subject_nodes:
110+
subject_file_id = GraphNode.make_id(NodeKind.FILE, subject_node.path)
111+
imports = query.store.neighbours(subject_file_id, kinds=[EdgeKind.IMPORTS], direction="out")
112+
if any(node.id == target_file.id for node in imports):
113+
return ClaimCheck(
114+
claim=claim,
115+
status=CheckStatus.SUPPORTED,
116+
confidence=1.0,
117+
explanation=f"{subject_node.path!r} imports {target_path!r}",
118+
evidence=[Evidence(kind="file", locator=subject_node.path)],
119+
)
120+
121+
return ClaimCheck(
122+
claim=claim,
123+
status=CheckStatus.CONTRADICTED,
124+
confidence=0.9,
125+
explanation=(f"the file defining {claim.subject!r} does not import {target_path!r}"),
126+
)
127+
128+
76129
def check_symbol_relation(claim: Claim, query: GraphQuery) -> ClaimCheck:
77130
"""Does the graph actually contain the claimed relationship?"""
131+
if looks_like_path(claim.target or ""):
132+
return check_symbol_calls_file(claim, query)
133+
78134
edge_kind = _RELATION_EDGE_KINDS.get(claim.relation or "")
79135
if edge_kind is None:
80136
return ClaimCheck(

src/verityai/consistency/claims.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,26 @@
7070
_RELATION_PATTERN = re.compile(
7171
r"`(?P<subject>[\w.]+)`\s*(?:class|function|method|object)?\s+(?P<relation>"
7272
+ "|".join(re.escape(phrase) for phrase in _RELATIONS)
73-
+ r")\s+`(?P<target>[\w.]+)`",
73+
# The target may be a symbol (`[\w.]+`) or a file path -- `/` and `-`
74+
# are needed for the latter (e.g. `billing/tax_rates.py`). Without them
75+
# a claim like "`apply_tax` calls `billing/tax_rates.py`" never matches
76+
# at all and silently decomposes into two independent, both-true
77+
# existence checks -- see ADR-0018.
78+
+ r")\s+`(?P<target>[\w./-]+)`",
7479
re.IGNORECASE,
7580
)
7681

7782

78-
def _looks_like_path(token: str) -> bool:
83+
def looks_like_path(token: str) -> bool:
7984
return "/" in token or token.lower().endswith(_FILE_EXTENSIONS)
8085

8186

87+
# Kept as an alias: this was private until check.py needed the same test to
88+
# route a relation claim to the file-import checker instead of the
89+
# symbol-relation checker (ADR-0018's fix).
90+
_looks_like_path = looks_like_path
91+
92+
8293
def _looks_like_symbol(token: str) -> bool:
8394
return bool(_SYMBOL.match(token)) and (
8495
"." in token or "_" in token or token.endswith("()") or any(c.isupper() for c in token[1:])

tests/unit/test_claims.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ def test_extends_is_recognised(self):
105105
relation = next(c for c in claims if c.kind is ClaimKind.SYMBOL_RELATION)
106106
assert relation.relation == "inherits"
107107

108+
def test_a_file_path_target_is_recognised_as_a_relation(self):
109+
"""Regression (ADR-0018): a claim that a function calls INTO a file,
110+
not another function, used to fall outside the target pattern
111+
entirely and silently decompose into two independent, both-true
112+
existence claims -- the false relation itself went unchecked."""
113+
claims = extract_claims("`apply_tax` calls `billing/tax_rates.py` to resolve the rate.")
114+
115+
relation = next(c for c in claims if c.kind is ClaimKind.SYMBOL_RELATION)
116+
assert relation.subject == "apply_tax"
117+
assert relation.target == "billing/tax_rates.py"
118+
# And it must not ALSO appear as a bare FILE_EXISTS claim -- same
119+
# subsumption rule as a symbol-to-symbol relation.
120+
assert not any(c.kind is ClaimKind.FILE_EXISTS for c in claims)
121+
108122
def test_an_unmapped_verb_is_not_extracted_as_a_relation(self):
109123
"""'depends on' and 'uses' are ambiguous about which edge kind they
110124
mean, so they are left unrecognised rather than mapped to a guess."""

tests/unit/test_consistency_check.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,62 @@ def test_an_unresolved_ambiguous_call_is_unverifiable(self, tmp_path):
146146
store.close()
147147

148148

149+
class TestSymbolCallsFileRelation:
150+
"""ADR-0018 found that a relation claim targeting a FILE, not a function,
151+
silently decomposed into two independent, both-true existence checks and
152+
vanished. These reproduce the exact real-world case: a function whose
153+
file does not import the file it's claimed to "call into"."""
154+
155+
@pytest.fixture
156+
def two_file_project(self, tmp_path):
157+
(tmp_path / "rates.py").write_text("REGION_RATES = {}\n")
158+
(tmp_path / "policy.py").write_text("ACTIVE_POLICY = {}\n")
159+
(tmp_path / "tax.py").write_text(
160+
"from rates import REGION_RATES\n\n\ndef apply_tax(subtotal, region):\n return subtotal\n"
161+
)
162+
return tmp_path
163+
164+
@pytest.fixture
165+
def two_file_query(self, two_file_project):
166+
store = GraphStore()
167+
ingest_repo(two_file_project, store)
168+
yield GraphQuery(store)
169+
store.close()
170+
171+
def test_an_actual_import_is_supported(self, two_file_query):
172+
result = check_symbol_relation(
173+
relation_claim("apply_tax", "calls", "rates.py"), two_file_query
174+
)
175+
176+
assert result.status is CheckStatus.SUPPORTED
177+
178+
def test_a_file_never_imported_is_contradicted(self, two_file_query):
179+
"""apply_tax's file (tax.py) never imports policy.py -- the exact
180+
shape of hallucination ADR-0018 found real agents producing."""
181+
result = check_symbol_relation(
182+
relation_claim("apply_tax", "calls", "policy.py"), two_file_query
183+
)
184+
185+
assert result.status is CheckStatus.CONTRADICTED
186+
assert "does not import" in result.explanation
187+
188+
def test_a_nonexistent_target_file_is_contradicted(self, two_file_query):
189+
result = check_symbol_relation(
190+
relation_claim("apply_tax", "calls", "nonexistent.py"), two_file_query
191+
)
192+
193+
assert result.status is CheckStatus.CONTRADICTED
194+
assert "no file at" in result.explanation.lower()
195+
196+
def test_missing_subject_is_still_contradicted(self, two_file_query):
197+
result = check_symbol_relation(
198+
relation_claim("nonexistent_fn", "calls", "rates.py"), two_file_query
199+
)
200+
201+
assert result.status is CheckStatus.CONTRADICTED
202+
assert "nonexistent_fn" in result.explanation
203+
204+
149205
class TestFileExistence:
150206
def test_an_existing_file_is_supported(self, project):
151207
result = check_file_exists(file_claim("src/limits.py"), project)

0 commit comments

Comments
 (0)