Skip to content

Commit c31d52d

Browse files
Juanpacolclaude
andcommitted
fix(graph): resolve from package import submodule to the real submodule file
While verifying ADR-0018's function-to-file relation fix against the real billing fixture, found that `billing/tax.py` (which does `from billing import tax_rates` and genuinely reads `tax_rates.REGION_RATES`) was recorded in the graph as importing only `billing/__init__.py`, never `billing/tax_rates.py`. Root cause in `graph/ingest.py`'s `visit_ImportFrom`: only the bare package name reached `_add_import`, never the imported names themselves. Python's grammar cannot distinguish "importing a submodule via its package" from "importing a plain symbol defined in the package's __init__.py" -- both are `ImportFrom(module="billing", names=["tax_rates"])`. Only a post-walk check of whether `billing.tax_rates` corresponds to a real first-party file can tell them apart, and `_resolve` already does exactly this kind of lookup for other edge kinds -- it just was never given the candidate to try. Fix: `visit_ImportFrom` now records one IMPORTS edge per imported name (matching plain `import a, b`'s existing per-alias granularity), each carrying the qualified candidate (`module.name`) in its metadata. `_resolve` tries that candidate first -- if it's a real first-party file, that IS what Python binds at runtime, not a guess -- falling back to the bare package (the `__init__.py`-symbol case) exactly as before when it isn't. This changed the correct answer to ADR-0018's own demonstration probe: `apply_tax` calls `billing/tax_rates.py` now reports SUPPORTED, because the import genuinely exists -- the CONTRADICTED verdict reported moments earlier (in the immediately preceding commit) was itself an artifact of this ingester bug, not the true answer. A second probe against a file `apply_tax`'s module never imports still correctly reports CONTRADICTED, confirming the mechanism works once the underlying import graph is accurate. README.md, BENCHMARK_PROTOCOL.md and ADR-0018 updated to reflect the corrected sequence rather than leaving a now-stale claim standing. 2 new regression tests in test_graph_ingest.py (submodule-import case; plain __init__.py-symbol case confirming no regression). No test in the suite asserted an exact import-edge count, so the per-name edge granularity change is safe. 520 tests passing (518 + 2 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fcd142f commit c31d52d

6 files changed

Lines changed: 127 additions & 37 deletions

File tree

README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,22 @@ 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
283283
instead of the in-corpus max, and locked in with a regression test.
284284

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).
285+
**The function-to-file relation blind spot, and a real ingester bug behind
286+
it, were both closed the same day.** The relation target pattern now
287+
accepts file paths, and a new check (`check_symbol_calls_file`) verifies
288+
the claim against the file-level `IMPORTS` graph. That surfaced a second,
289+
independent bug: the ingester recorded `from billing import tax_rates` as
290+
importing only the bare package, never the real submodule file —
291+
`billing/tax.py` genuinely does import `billing/tax_rates.py`, but the
292+
graph didn't know it. Fixed in `graph/ingest.py` by trying the more
293+
specific candidate first. The exact probe this measurement used to
294+
demonstrate the original gap — `` `apply_tax` calls `billing/tax_rates.py` ``
295+
— now correctly reports **`SUPPORTED`** (the import genuinely exists);
296+
a second probe against a file `apply_tax`'s module never imports still
297+
correctly reports `CONTRADICTED`, confirming the mechanism works once the
298+
underlying import graph is accurate. See ADR-0018 for the full sequence,
299+
including the one relation-extraction gap that remains open (loose
300+
phrasing like "likely calls a helper in").
295301

296302
---
297303

docs/BENCHMARK_PROTOCOL.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,13 @@ invented symbol names (14/14 caught); a blind spot on function-to-file
6060
relation claims (0% caught at the time, structurally invisible to the
6161
extractor), closed the same day by extending the relation target pattern
6262
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
66-
of actual relevance — found, fixed, and regression-tested.
63+
IMPORTS graph instead of a symbol-level CALLS edge -- which in turn
64+
surfaced a second, independent ingester bug (`from package import
65+
submodule` resolved only to the package, never the real submodule file),
66+
also fixed the same day; and a real bug in decision resurfacing — a corpus
67+
of one or two decisions always normalized its closest match to 100%
68+
confidence regardless of actual relevance — found, fixed, and
69+
regression-tested.
6770

6871
## The two families
6972

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,38 @@ synthetic-fixture trap's blind spots.
9797
(`[\w./-]+` instead of `[\w.]+`), and `check.py` gained
9898
`check_symbol_calls_file`, which checks a file-targeted relation claim
9999
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.
100+
edge. First verified against the exact probe this ADR used to demonstrate
101+
the gap — `` `apply_tax` calls `billing/tax_rates.py` `` went from
102+
vanishing entirely to reporting `CONTRADICTED`, because at that point the
103+
ingester itself had a separate bug (below) that failed to record the real
104+
import. Regression tests added in both `test_claims.py` and
105+
`test_consistency_check.py` (`TestSymbolCallsFileRelation`).
106+
- **The ingester's own import-resolution gap — also fixed, same day.**
107+
`billing/tax.py` does `from billing import tax_rates` and genuinely reads
108+
`tax_rates.REGION_RATES` — a real import of `billing/tax_rates.py`. But
109+
`graph/ingest.py`'s `visit_ImportFrom` recorded only the bare package name
110+
(`billing`) as the import target, never trying `billing.tax_rates` as a
111+
candidate — Python's grammar cannot tell "importing a submodule via its
112+
package" from "importing a symbol defined in the package's `__init__.py`"
113+
apart syntactically; only a post-walk check of whether `billing.tax_rates`
114+
is a real first-party file can. Fixed by recording that more specific
115+
candidate per imported name (one `IMPORTS` edge per name, matching plain
116+
`import a, b`'s existing granularity) and having `_resolve` try it first.
117+
**This changed the probe's correct answer**: with the ingester fixed,
118+
`billing/tax.py` is now correctly recorded as importing
119+
`billing/tax_rates.py`, so `` `apply_tax` calls `billing/tax_rates.py` ``
120+
now reports `SUPPORTED` — the CONTRADICTED verdict reported moments
121+
earlier was itself an artifact of the ingester bug, not the correct
122+
answer. A second probe, `` `apply_tax` calls `billing/policy.py` `` (a
123+
file `tax.py` genuinely never imports), still correctly reports
124+
`CONTRADICTED`, confirming the mechanism distinguishes real from false
125+
file relations once the underlying import graph is accurate. Two
126+
regression tests added in `test_graph_ingest.py`, one confirming the
127+
submodule case resolves to the submodule file, one confirming a plain
128+
`__init__.py`-symbol import is unaffected. The extractor's remaining named
129+
gap (tolerating explanatory text between the relation verb and its
130+
arguments, e.g. "likely calls a helper in") remains open, deliberately
131+
out of scope for this pass.
115132
- **Backtick-quoted local variable names — addressed, deliberately without
116133
changing any verdict** (same-day follow-up). Investigating a fix
117134
surfaced a harder fact: a real local variable name (`with_tax`) and a

experiments/consistency_pilot_1_hallucination_detection/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ ADR-0007. What was actually missing was the same thing Context Engine
88
lacked before ADR-0009: a measurement against real, not hand-authored,
99
input. This experiment is that measurement.
1010

11-
**Status: complete, and it found a real bug**, fixed in
12-
`src/verityai/consistency/check.py` (see `docs/adr/0018-consistency-engine-
13-
first-measurement.md`).
11+
**Status: complete, and it found three real bugs**, all fixed the same day
12+
(see `docs/adr/0018-consistency-engine-first-measurement.md` for the full
13+
sequence): the decision-resurfacing normalization bug below, the
14+
function-to-file relation blind spot, and — found while fixing that blind
15+
spot — a separate ingester bug that had been silently making the blind-spot
16+
fix's own verdicts *less* accurate. Note: this README describes what Part A
17+
found at measurement time; the specific "0% caught" relation-blind-spot
18+
result and its example probe were both since fixed, and the exact probe's
19+
correct answer changed as a result (see the ADR).
1420

1521
## Part A — inducing real hallucinations, not writing them by hand
1622

src/verityai/graph/ingest.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,23 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
166166
if node.level:
167167
module = "." * node.level + module
168168

169+
# One edge per name (matching `visit_Import`'s granularity), because
170+
# `from pkg import a, b` cannot be resolved as a single unit: `a`
171+
# might be a submodule file and `b` a plain symbol in the same
172+
# statement. Python's grammar cannot tell "importing a submodule via
173+
# its package" from "importing a symbol defined in the package's
174+
# __init__.py" -- both are `ImportFrom(module="pkg", names=["x"])`.
175+
# `submodule_candidate` carries the more specific guess (`pkg.x`) for
176+
# `_resolve` to try first, once every file has been walked and it can
177+
# check whether that candidate is a real first-party file.
169178
for alias in node.names:
170179
local = alias.asname or alias.name
171-
self.imported_names[local] = f"{module}.{alias.name}" if module else alias.name
172-
self._add_import(module or ".", node.lineno)
180+
qualified = f"{module}.{alias.name}" if module else alias.name
181+
self.imported_names[local] = qualified
182+
self._add_import(module or ".", node.lineno, submodule_candidate=qualified)
173183
self.generic_visit(node)
174184

175-
def _add_import(self, module: str, line: int) -> None:
185+
def _add_import(self, module: str, line: int, submodule_candidate: str | None = None) -> None:
176186
"""Record an imported module as an EXTERNAL node.
177187
178188
Every import becomes a node even when the target is a first-party
@@ -205,7 +215,10 @@ def _add_import(self, module: str, line: int) -> None:
205215
# resolver needs the name to look the module up among
206216
# first-party files; without this it can only ever see
207217
# "external:b" and no first-party import would ever resolve.
208-
metadata={"module": module},
218+
# `submodule_candidate` (from `from X import Y`) is `X.Y`,
219+
# a strictly more specific guess than `module` alone -- see
220+
# `_resolve`.
221+
metadata={"module": module, "submodule_candidate": submodule_candidate},
209222
line=line,
210223
)
211224
)
@@ -524,7 +537,16 @@ def _resolve(
524537

525538
if edge.kind is EdgeKind.IMPORTS:
526539
module = edge.metadata.get("module", "")
527-
target_id = module_ids.get(module)
540+
# `from pkg import name` is ambiguous by grammar alone: `name` might
541+
# be a submodule file (`pkg/name.py`) or a plain symbol defined in
542+
# `pkg/__init__.py`. Try the more specific candidate first -- if
543+
# `pkg.name` really is a first-party file, that IS what Python binds
544+
# at runtime, not a guess. Falls back to the package itself when the
545+
# candidate isn't a real file (the __init__.py-symbol case).
546+
candidate = edge.metadata.get("submodule_candidate")
547+
target_id = module_ids.get(candidate) if candidate else None
548+
if target_id is None:
549+
target_id = module_ids.get(module)
528550
if target_id is None and module:
529551
# `from pkg.core import X` names a module that is a file; `import
530552
# pkg` names a package whose file is `pkg/__init__.py`. Try the

tests/unit/test_graph_ingest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,42 @@ def test_tests_edges_are_derived_from_calls(self, repo, store):
297297
testers = store.neighbours(factory.id, [EdgeKind.TESTS], direction="in")
298298
assert [n.name for n in testers] == ["test_service_runs"]
299299

300+
def test_from_package_import_submodule_resolves_to_the_submodule_file(self, tmp_path, store):
301+
"""Regression (ADR-0018): `from billing import tax_rates` is a real
302+
import of `billing/tax_rates.py`, not of `billing/__init__.py` --
303+
but grammatically it looks identical to importing a plain symbol
304+
defined inside `__init__.py`. Only a post-walk file-existence check
305+
can tell them apart, and it must prefer the submodule file when one
306+
exists."""
307+
(tmp_path / "billing").mkdir()
308+
(tmp_path / "billing" / "__init__.py").write_text("")
309+
(tmp_path / "billing" / "tax_rates.py").write_text("REGION_RATES = {}\n")
310+
(tmp_path / "billing" / "tax.py").write_text(
311+
"from billing import tax_rates\n\n\ndef apply_tax():\n return tax_rates.REGION_RATES\n"
312+
)
313+
314+
ingest_repo(tmp_path, store)
315+
316+
tax_file = next(n for n in store.all_nodes(NodeKind.FILE) if n.path == "billing/tax.py")
317+
imports = store.neighbours(tax_file.id, [EdgeKind.IMPORTS], direction="out")
318+
assert "billing/tax_rates.py" in {n.path for n in imports}
319+
assert "billing/__init__.py" not in {n.path for n in imports}
320+
321+
def test_from_package_import_symbol_still_resolves_to_the_package(self, tmp_path, store):
322+
"""The other half of the same ambiguity: `from billing import
323+
HELPER` where `HELPER` is a plain symbol defined in
324+
`billing/__init__.py`, not a submodule file -- must still resolve
325+
to the package, unchanged from before this fix."""
326+
(tmp_path / "billing").mkdir()
327+
(tmp_path / "billing" / "__init__.py").write_text("HELPER = 1\n")
328+
(tmp_path / "billing" / "tax.py").write_text("from billing import HELPER\n")
329+
330+
ingest_repo(tmp_path, store)
331+
332+
tax_file = next(n for n in store.all_nodes(NodeKind.FILE) if n.path == "billing/tax.py")
333+
imports = store.neighbours(tax_file.id, [EdgeKind.IMPORTS], direction="out")
334+
assert "billing/__init__.py" in {n.path for n in imports}
335+
300336

301337
class TestIncrementality:
302338
def test_unchanged_files_are_skipped_on_reingest(self, repo, store):

0 commit comments

Comments
 (0)