Skip to content

Commit 390e4ea

Browse files
Juanpacolclaude
andcommitted
fix(consistency): decision resurfacing false-positives on small corpora; first real measurement
docs/BENCHMARK_PROTOCOL.md claimed hallucination/contradiction detection was blocked on the Consistency Engine existing at all -- stale. The engine (consistency/, ADR-0007) already existed and was already tested. The real gap was the same one Context Engine had before ADR-0009: no measurement against real, not hand-authored, input. Part A: five real agent trials, each shown only billing/invoice.py (no other file of a small billing package), asked to describe the full call chain anyway -- reliably produces a genuine mix of true claims and hallucinated ones about functions the agent can't see. Ground truth was written from the real source before running any checker output, to avoid biasing labels. Result: 100% recall (14/14) on invented function names, but a confirmed, structural blind spot: a claim that a function "calls into" a file (rather than another function) never parses as a relation claim at all -- it silently decomposes into two independent, both-true existence checks. Verified directly with isolated probes, not just inferred from trial transcripts. Also found: backtick-quoted local variable names (with_tax, days_overdue) get flagged as "no definition found", since the graph indexes functions/classes/files, not local variables -- a real nuisance-false-positive source in normal technical writing. Part B: a real, fixed bug. check_decision_resurfacing normalized each candidate decision's BM25 score against the maximum score *among the stored decisions*, not an absolute scale. With one or two decisions on record -- the common case for an early project, not an edge case -- whichever decision is relatively closest to the checked text always normalized to a perfect 1.0, regardless of real relevance. Confirmed by checking a proposal about caching tax rates against a fabricated rejected decision about late-fee policy: completely unrelated, still flagged at 85% confidence; adding a second, obviously unrelated rejected decision ("plaintext passwords") got both flagged. Fix: normalize against the checked text's own best-possible score (BM25 matched against itself) instead of the in-corpus max. Confidence dropped from an always-maximal 85% to 16% for the unrelated proposal and to a more proportionate 43% for a genuine paraphrase of the rejected decision -- both directionally correct. A residual small-corpus false-positive risk remains (BM25's IDF is inherently unstable with 1-2 documents), stated honestly in the ADR rather than oversold. Regression test added -- the pre-existing "unrelated text" test never caught this because its fixture had zero token overlap with the stored decision, missing the near-zero-but-nonzero score regime where the bug lived. 507 tests passing (506 + 1 new regression test). See docs/adr/0018-consistency-engine-first-measurement.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 666f49d commit 390e4ea

21 files changed

Lines changed: 440 additions & 7 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,31 @@ larger trial budget) to move — while the cost effect is now this project's
260260
most consistently reproduced Family B result, across three separate
261261
pilots and bug designs.
262262

263+
**The Consistency Engine got its own first real measurement**
264+
(`experiments/consistency_pilot_1_hallucination_detection/`, [ADR-0018](docs/adr/0018-consistency-engine-first-measurement.md)),
265+
closing a stale claim in `docs/BENCHMARK_PROTOCOL.md` that this engine was
266+
still "blocked on existing at all" — it already existed (ADR-0007) and just
267+
lacked a measurement against real, not hand-authored, claims. Real agents,
268+
shown only one file of a small codebase, were asked to describe it anyway,
269+
producing a genuine mix of true and hallucinated claims:
270+
271+
| Claim class | Result |
272+
|---|---|
273+
| Invented function names (14 across 5 trials) | **100% caught** |
274+
| Function-to-file relation hallucinations (~6) | **0% caught** — structurally invisible to the relation extractor |
275+
| Backtick-quoted local variable names (8) | False positives — real names, just not graph-indexed |
276+
277+
A real bug was also found and fixed in decision resurfacing: with only one
278+
or two rejected decisions on record, the closest-of-the-available matches
279+
always normalized to 100% confidence regardless of actual relevance — a
280+
genuinely unrelated proposal "resembled" an unrelated rejected decision just
281+
as strongly as an actual paraphrase of it. Confirmed with an isolated probe,
282+
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).
287+
263288
---
264289

265290
## Why this project changed shape

docs/BENCHMARK_PROTOCOL.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ series, 5/5 both conditions — but the cost effect reproduced a third time:
5050
limits (small N, narrow task design, single fixture) still apply — see each
5151
pilot's own README before generalizing any result.
5252

53+
Separately, the Consistency Engine (`consistency/`, ADR-0007) got its own
54+
first real measurement (`experiments/consistency_pilot_1_hallucination_
55+
detection/`, ADR-0018) — not a Family A/B comparison, but the same
56+
"real data before publishing" discipline applied to a different engine.
57+
Real agents, shown only one file of a small codebase, produced a genuine
58+
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
63+
of actual relevance — found, fixed, and regression-tested.
64+
5365
## The two families
5466

5567
Metrics are split by whether an LLM is in the loop, and the two families are
@@ -126,8 +138,13 @@ rows are Family B, and the honest position today is:
126138
- **Tokens and cost per task**: Family A for the pipeline itself. Becomes
127139
Family B the moment task success enters the denominator, because a cheaper
128140
run that fails is not cheaper.
129-
- **Hallucinations, contradictions, regressions**: Family B, and additionally
130-
blocked on the Consistency Engine existing at all (Phase 3).
141+
- **Hallucinations, contradictions, regressions**: Family B. The
142+
Consistency Engine (`consistency/`, ADR-0007) exists and has a first real
143+
measurement (ADR-0018): 100% recall on invented symbol names in real
144+
agent-generated text, a confirmed blind spot on function-to-file relation
145+
claims, and a real decision-resurfacing bug found and fixed. Not yet
146+
measured: recall/precision at scale, or on hallucinations about relations
147+
between two real symbols phrased loosely.
131148
- **Technical debt**: not yet operationally defined. No metric until it is.
132149
- **Recovery after reset**: Family B, and the most valuable row in the table.
133150
It is the one thing the harness does that an agent cannot do for itself.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# ADR-0018: First real measurement of the Consistency Engine -- and a real bug it found
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-08-09
5+
- **Context**: `docs/BENCHMARK_PROTOCOL.md` describes hallucination and
6+
contradiction detection as blocked on "the Consistency Engine existing at
7+
all (Phase 3)." Before planning that as new work, the actual state of
8+
`src/verityai/consistency/` was checked: the engine already exists,
9+
already has tests, and was accepted in ADR-0007. The real gap was a
10+
measurement against genuine (not hand-authored) input — the same gap
11+
Context Engine had before ADR-0009.
12+
13+
## Part A: inducing real hallucinations
14+
15+
Writing false claims by hand to test the checker would measure the
16+
fixture, not the checker — the synthetic-fixture trap this project has
17+
avoided since ADR-0009. Instead, five independent live-agent trials were
18+
shown only `billing/invoice.py` (from the pilot 5 fixture, with a real
19+
code graph built via `verity graph build`) and asked to describe the full
20+
call chain anyway, including functions they could not see. Ground truth was
21+
written from the real source files before running any checker output.
22+
23+
### Result
24+
25+
98 claims checked, 23 flagged:
26+
27+
| Class | Count | Outcome |
28+
|---|---|---|
29+
| Invented helper-function names | 14 | 100% caught |
30+
| Backtick-quoted local variable names (`with_tax`, `days_overdue`) | 8 | False positives — real names, just not graph-indexed |
31+
| A genuine path inaccuracy | 1 | Correctly caught |
32+
| Fabricated function-to-file relation claims | ~6 | **0% caught — structurally invisible**, not even counted among the 98 |
33+
34+
Confirmed with isolated probes, not just inferred: `` `apply_tax` calls
35+
`billing/tax_rates.py` `` produces zero contradictions (decomposes into two
36+
independent, both-true existence checks), while `` `apply_tax` calls
37+
`get_tax_rate` `` — an equally fabricated relation but with a function-shaped
38+
target — is correctly caught as a relation-level failure. The relation
39+
extractor (`consistency/claims.py`) only recognizes symbol-to-symbol
40+
`calls`/`inherits from`/`extends` phrases; a claim of a function calling
41+
into a *file* — a very natural way to describe a module dependency — never
42+
parses as a relation claim at all.
43+
44+
## Part B: a real bug in decision resurfacing
45+
46+
A `REJECTED` decision was fabricated, then two proposals were checked: a
47+
genuine paraphrase of it (should trigger resurfacing) and a completely
48+
unrelated one (should not). **Both triggered resurfacing.**
49+
50+
Root cause, in `check_decision_resurfacing`
51+
(`src/verityai/consistency/check.py`): each candidate decision's BM25 score
52+
was normalized against `max(scores.values())` — the maximum *among the
53+
stored decisions*, not an absolute scale. With one or two decisions on
54+
record (not an edge case — the common case for an early or solo project),
55+
whichever decision is relatively closest to the checked text always
56+
normalizes to a perfect 1.0, regardless of whether it shares any real
57+
content with it. Confirmed directly: adding a second, obviously unrelated
58+
rejected decision ("store passwords in plaintext") caused *both* to be
59+
flagged against a caching proposal that resembled neither.
60+
61+
### Fix
62+
63+
Normalize against the checked text's own best-possible score (BM25-matched
64+
against itself) instead of the in-corpus max:
65+
66+
```python
67+
_, self_scores = bm25_rank(text, [text])
68+
self_score = self_scores.get(0, 0.0)
69+
if self_score <= 0:
70+
return []
71+
...
72+
normalized = score / self_score
73+
```
74+
75+
This dropped the unrelated proposal's confidence from 85% (always-maximal,
76+
regardless of content) to 16%, and the genuine paraphrase's from an
77+
identically-maximal 85% to a more proportionate 43% — both directionally
78+
correct. A regression test was added
79+
(`test_a_single_rejected_decision_does_not_swallow_unrelated_text`); the
80+
pre-existing "unrelated text" test never caught this bug because its
81+
fixture text had zero token overlap with the stored decision, never
82+
exercising the near-zero-but-nonzero BM25 score regime where the bug
83+
actually lived — a reminder that a test suite's own fixtures can share the
84+
synthetic-fixture trap's blind spots.
85+
86+
## Consequences
87+
88+
- `docs/BENCHMARK_PROTOCOL.md`'s "blocked on Consistency Engine existing"
89+
language is now corrected — the engine exists, has a first real
90+
measurement, and that measurement found and fixed a genuine bug, the same
91+
pattern ADR-0009 and ADR-0016 established for other engines.
92+
- **Symbol-existence checking has a clean, confirmed 100% recall** on
93+
invented function names in this pilot — the engine's strongest result so
94+
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.
100+
- **Backtick-quoted local variable names are a genuine nuisance-false-
101+
positive source** in real usage — anyone writing a normal technical
102+
summary with `` `some_var` `` for readability will trigger a false
103+
contradiction today. Worth a narrower fix (distinguishing "this claims a
104+
codebase symbol" from "this is emphasis") in a future pass; not attempted
105+
here to keep this pilot's scope to measurement plus the one clear,
106+
high-confidence bug it found.
107+
- **The resurfacing fix is a real improvement, not a complete solution.**
108+
BM25's IDF is inherently unstable with 1-2 documents; a small residual
109+
false-positive risk remains for very small decision corpora. Stated
110+
honestly rather than oversold, per this project's standing practice (T1).
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# First real measurement of the Consistency Engine
2+
3+
`docs/BENCHMARK_PROTOCOL.md` said hallucination/contradiction detection was
4+
"additionally blocked on the Consistency Engine existing at all (Phase 3)."
5+
That sentence was stale: `src/verityai/consistency/` already exists, is
6+
tested (`tests/unit/test_consistency_check.py`), and was accepted in
7+
ADR-0007. What was actually missing was the same thing Context Engine
8+
lacked before ADR-0009: a measurement against real, not hand-authored,
9+
input. This experiment is that measurement.
10+
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`).
14+
15+
## Part A — inducing real hallucinations, not writing them by hand
16+
17+
Writing false claims by hand would measure the fixture, not the checker —
18+
the same synthetic-fixture trap `BENCHMARK_PROTOCOL.md` warns about
19+
elsewhere. The honest way to get genuine hallucinations: show a real agent
20+
only `billing/invoice.py` (from the pilot 5 fixture, reused here with a
21+
real code graph built via `verity graph build`) and ask it to describe the
22+
full call chain anyway, including functions it cannot see. This reliably
23+
produces a mix of true claims (what it could actually see) and invented
24+
ones (what it guessed about `apply_tax`/`apply_late_fee`'s internals) —
25+
five independent trials, five independently-generated transcripts.
26+
27+
Ground truth (`ground_truth.md`) was written from the real source files
28+
*before* running `verity check` on any transcript, to avoid biasing the
29+
labels: `apply_tax` and `apply_late_fee` call **nothing** internally — they
30+
only read module-level dicts directly. Any claim that either function
31+
"calls" a helper or a file is false, whatever name is used.
32+
33+
### Result
34+
35+
98 claims checked across 5 trials, 23 flagged as contradictions:
36+
37+
| Class | Count | Checker behavior |
38+
|---|---|---|
39+
| Invented helper-function names (`get_tax_rate`, `compute_overdue_penalty`, etc.) | 14 | **100% caught** — every single one flagged `FAIL` |
40+
| Backtick-quoted local variable names (`with_tax`, `days_overdue`) | 8 | Flagged `FAIL` as "no definition found" — **false positives**: these are real names in the real code, just not graph-indexed (the graph tracks functions/classes/files, not local variables) |
41+
| A genuine path inaccuracy (`invoice.py` instead of the real `billing/invoice.py`) | 1 | Correctly caught — a real, if minor, inaccuracy |
42+
| Fabricated relation claims ("`apply_tax` calls into `billing/tax_rates.py`", "`apply_late_fee` calls into `billing/policy.py`") | ~6 across trials | **0% caught — invisible to the checker entirely**, not even counted among the 98 |
43+
44+
The last row is the important finding. Confirmed with isolated probes
45+
(not just inferred from the trial transcripts):
46+
47+
```
48+
`apply_tax` calls `billing/tax_rates.py` to resolve the rate.
49+
```
50+
→ 0 contradictions. Decomposes into two independent, both-true existence
51+
checks (`apply_tax` exists, `billing/tax_rates.py` exists) — the relation
52+
extractor never fires when the "calls" target is a file rather than a
53+
function, regardless of how tightly the claim is phrased.
54+
55+
```
56+
`apply_tax` calls `get_tax_rate` to resolve the rate.
57+
```
58+
→ 1 contradiction, correctly reported as a relation-level failure
59+
(`'apply_tax' exists, but 'get_tax_rate' was not found either`). So a
60+
symbol-to-symbol relation claim, phrased tightly, *is* checked correctly —
61+
the blind spot is specific to symbol-to-file relations and to relation
62+
claims phrased with explanatory text between the verb and its targets.
63+
64+
## Part B — a real bug found in decision resurfacing
65+
66+
A `REJECTED` decision was fabricated (`Apply the late fee using
67+
DEPRECATED_POLICY directly...`), then two independent proposals were
68+
checked: one a genuine paraphrase of the rejected idea (should trigger
69+
resurfacing), one a completely unrelated caching proposal (should not).
70+
71+
**Both triggered resurfacing, at first.** The unrelated proposal "resembled"
72+
the on-file rejected decision because `check_decision_resurfacing`
73+
normalized each BM25 score against the *maximum score among the stored
74+
decisions*, not against an absolute scale. With only one or two decisions
75+
on record — a very common case, not an edge case — the single closest-of-
76+
the-available-candidates decision always normalizes to a perfect 1.0,
77+
regardless of whether it shares any real content with the checked text.
78+
Confirmed directly by adding a second, obviously unrelated rejected
79+
decision ("store passwords in plaintext") and observing *both* decisions
80+
get flagged against a caching proposal that resembles neither.
81+
82+
**Fixed** in `src/verityai/consistency/check.py`: normalize against the
83+
checked text's own best-possible score (itself matched against itself),
84+
not against the in-corpus max. This dropped the unrelated proposal's
85+
confidence from 85% to 16% and the genuine paraphrase's from 85% (an
86+
overstated, always-maximal confidence) to a more proportionate 43% — both
87+
directionally correct, though a small residual false-positive risk remains
88+
for very small decision corpora due to BM25's own IDF instability at that
89+
scale (see the ADR's limitations section). A regression test
90+
(`tests/unit/test_consistency_check.py::test_a_single_rejected_decision_
91+
does_not_swallow_unrelated_text`) locks in the fix; the pre-existing
92+
"unrelated text" test never caught this because its fixture text had zero
93+
token overlap with the stored decision, never exercising the
94+
near-zero-but-nonzero score regime where the bug actually lived.
95+
96+
## Files
97+
98+
- `fixture_repo/` — the pilot 5 `billing/` fixture, reused as-is, with a
99+
real code graph built on top (`.verity/graph.db`, gitignored).
100+
- `trial_1.txt` .. `trial_5.txt` — the five real, independently-generated
101+
transcripts from Part A.
102+
- `ground_truth.md` — written before running any checker output.
103+
- `resurfacing_trial/` — the fabricated rejected decision and the two Part
104+
B proposals.
105+
106+
## Known limitations of this pilot, stated up front
107+
108+
- **One fixture, one function, 5+2 trials.** Same caveat as every prior
109+
pilot in this project — see `docs/BENCHMARK_PROTOCOL.md`.
110+
- **The relation-extraction blind spot (function-to-file relations) was
111+
not fixed here.** It's a real, confirmed gap, but fixing the extractor's
112+
regex to recognize file targets — and to tolerate explanatory text
113+
between the relation verb and its arguments — is a larger change than
114+
this pilot's scope; it's documented in ADR-0018 as a finding, not
115+
patched.
116+
- **The resurfacing fix reduces but does not eliminate the small-corpus
117+
false-positive risk.** BM25's IDF calculation is inherently unstable with
118+
1-2 documents; a genuinely bulletproof fix would need a different
119+
similarity approach for very small decision corpora, which is out of
120+
scope here.

experiments/consistency_pilot_1_hallucination_detection/fixture_repo/billing/__init__.py

Whitespace-only changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Invoice total calculation."""
2+
3+
from billing.late_fee import apply_late_fee
4+
from billing.tax import apply_tax
5+
6+
7+
def calculate_invoice(order: dict) -> float:
8+
subtotal = order["subtotal"]
9+
with_tax = apply_tax(subtotal, order["region"])
10+
return apply_late_fee(with_tax, order["region"], order["days_overdue"])
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Late-fee calculation."""
2+
3+
from billing import policy
4+
5+
6+
def apply_late_fee(subtotal: float, region: str, days_overdue: int) -> float:
7+
region_policy = policy.DEPRECATED_POLICY.get(region)
8+
if region_policy is None:
9+
return subtotal
10+
if days_overdue > region_policy["grace_days"]:
11+
return subtotal * (1 + region_policy["fee_rate"])
12+
return subtotal
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Late-fee policy configuration."""
2+
3+
ACTIVE_POLICY = {
4+
"US": {"grace_days": 10, "fee_rate": 0.015},
5+
"EU": {"grace_days": 14, "fee_rate": 0.010},
6+
"UK": {"grace_days": 14, "fee_rate": 0.010},
7+
}
8+
9+
# Superseded by ACTIVE_POLICY after the 2024 collections policy update;
10+
# nothing in the current late-fee pipeline should read this.
11+
DEPRECATED_POLICY = {
12+
"US": {"grace_days": 30, "fee_rate": 0.020},
13+
"EU": {"grace_days": 30, "fee_rate": 0.015},
14+
"UK": {"grace_days": 30, "fee_rate": 0.015},
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Tax calculation. Healthy -- correctly reads the current rate table."""
2+
3+
from billing import tax_rates
4+
5+
6+
def apply_tax(subtotal: float, region: str) -> float:
7+
rate = tax_rates.REGION_RATES.get(region, 0.0)
8+
return subtotal * (1 + rate)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Tax configuration."""
2+
3+
REGION_RATES = {
4+
"US": 0.07,
5+
"EU": 0.20,
6+
"UK": 0.20,
7+
}
8+
9+
# Superseded by REGION_RATES after the 2024 tax reform; nothing in the
10+
# current tax pipeline should read this.
11+
LEGACY_REGION_RATES = {
12+
"US": 0.05,
13+
"EU": 0.19,
14+
"UK": 0.20,
15+
}

0 commit comments

Comments
 (0)