<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Evaluation on Nikhil Adhikari</title><link>https://kai2055.github.io/tags/evaluation/</link><description>Recent content in Evaluation on Nikhil Adhikari</description><generator>Hugo -- gohugo.io</generator><language>en-US</language><lastBuildDate>Sun, 26 Jul 2026 00:00:00 +0200</lastBuildDate><atom:link href="https://kai2055.github.io/tags/evaluation/index.xml" rel="self" type="application/rss+xml"/><item><title>A Quality Gate Must Be Reproducible Before It Can Gate Anything</title><link>https://kai2055.github.io/p/live-vs-frozen-decompose/</link><pubDate>Sun, 26 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/live-vs-frozen-decompose/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; This project has an automatic &amp;ldquo;quality gate&amp;rdquo;
that blocks any code change which makes the system worse. But the gate&amp;rsquo;s own
measurements were bouncing around between runs — because one early step uses an AI
model that phrases things slightly differently each time. So the gate couldn&amp;rsquo;t tell
a real problem from its own randomness, and on one run it &amp;ldquo;failed&amp;rdquo; for no real
reason. The fix: freeze that non-deterministic step during measurement, so every
run measures the same thing. The principle: &lt;em&gt;a gate whose own measurement isn&amp;rsquo;t
reproducible can&amp;rsquo;t gate anything.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Surfaced by:&lt;/strong&gt; first full end-to-end run of the Layer 3 runner · &lt;strong&gt;Related:&lt;/strong&gt;
ADR-016, ADR-020&lt;/p&gt;
&lt;h2 id="what-happened"&gt;What happened
&lt;/h2&gt;&lt;p&gt;The first full run of the Layer 3 runner produced Layer 2 numbers that moved against
the committed baseline. The headline: &lt;strong&gt;decline_rate dropped 1.000 → 0.500&lt;/strong&gt;, which
under ADR-020 is a hard invariant and would fail the gate. But it&amp;rsquo;s only 2 no-match
entries scored, so 0.5 is literally one entry flipping — one no-match description
stopped declining and leaked a candidate.&lt;/p&gt;
&lt;p&gt;Layer 1 reproduced almost exactly (hit rate 1.000, MRR 0.918 vs 0.9177). Layer 2 did
not.&lt;/p&gt;
&lt;h2 id="why-it-moved--not-a-regression-a-reproducibility-gap"&gt;Why it moved — not a regression, a reproducibility gap
&lt;/h2&gt;&lt;p&gt;The movement is &lt;strong&gt;not&lt;/strong&gt; a code or corpus regression. The store was freshly
re-indexed and Layer 1 reproduced perfectly, so retrieval is sound. The cause is that
&lt;strong&gt;the runner ran the full live graph, including the Decompose node.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Decompose is LLM-driven and non-deterministic. Its output — the symptom breakdown
that everything downstream retrieves against — varies run to run. So each run
measures against a &lt;em&gt;different&lt;/em&gt; set of symptoms, and the metrics wobble accordingly.&lt;/p&gt;
&lt;p&gt;The project already anticipated exactly this: a frozen symptoms file exists
specifically to hold Decompose&amp;rsquo;s output fixed so measurement is reproducible. The
Layer 2 baseline was built against frozen symptoms. The runner was not — it bypassed
the freeze and called the live graph end to end. &lt;strong&gt;So current and baseline were not
measuring the same thing.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="why-this-matters-for-the-gate"&gt;Why this matters for the gate
&lt;/h2&gt;&lt;p&gt;The entire point of the gate is telling a real regression from noise. A gate that
runs live Decompose can never do that for Layer 2: every run drifts by an unknown
amount from Decompose alone, so any metric movement is ambiguous by construction.
decline_rate dropping below its hard invariant on this run is the proof — it &lt;em&gt;looks&lt;/em&gt;
like a gate failure, but it&amp;rsquo;s Decompose variance on a 2-entry denominator, not a
broken system. A gate that fires on its own measurement noise is the false-alarm
failure mode ADR-020 warns about. It would get muted.&lt;/p&gt;
&lt;h2 id="the-fix"&gt;The fix
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;The runner must measure Layer 2 against frozen symptoms, not live Decompose&lt;/strong&gt; — the
same way the baseline was built. Run the graph from the frozen symptoms starting
point rather than letting Decompose run fresh. This makes the runner reproducible and
makes current-vs-baseline an apples-to-apples comparison.&lt;/p&gt;
&lt;p&gt;Remaining downstream LLM variance (Retrieve / Assess / Diagnose) is a deferred
question — whether it&amp;rsquo;s small enough to gate on, or whether more of the chain needs
pinning, is answered once frozen-Decompose runs are compared across several repeats.
That repeat data is also what tightens the provisional thresholds in ADR-020.&lt;/p&gt;</description></item><item><title>A Stale Vector Store Caused a Phantom Regression</title><link>https://kai2055.github.io/p/stale-vector-store-regression/</link><pubDate>Sun, 26 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/stale-vector-store-regression/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; Tests that had been passing suddenly started
failing, right after a code change — so the code change looked guilty. It wasn&amp;rsquo;t.
The real cause was a database of pre-computed data that had quietly gone stale: it
was excluded from version control, so the usual &amp;ldquo;has anything changed?&amp;rdquo; check
showed everything clean while the thing the system actually depends on was out of
date. The takeaway: &lt;em&gt;a clean version-control status tells you nothing about the
state of files it doesn&amp;rsquo;t track&lt;/em&gt; — and this is exactly the failure the project&amp;rsquo;s
automatic quality gate is built to prevent.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Surfaced by:&lt;/strong&gt; first Layer 2 runs while building the Layer 3 runner · &lt;strong&gt;Related:&lt;/strong&gt;
ADR-020 (Layer 3 gate policy)&lt;/p&gt;
&lt;h2 id="what-happened"&gt;What happened
&lt;/h2&gt;&lt;p&gt;Mid-session, Layer 2 entries that pass in the committed baseline started failing.
Two examples: &lt;strong&gt;L2-006&lt;/strong&gt; went from a correct top-1 hit to &lt;strong&gt;0 candidates, declined,
retrieved nothing&lt;/strong&gt;; &lt;strong&gt;L2-001&lt;/strong&gt; went from the correct incident in its candidate set
to 3 candidates, all wrong.&lt;/p&gt;
&lt;p&gt;On the surface this looked like a regression — the system had gotten worse — and it
appeared right after a code refactor, so the refactor was the obvious suspect.&lt;/p&gt;
&lt;h2 id="why-it-was-not-the-obvious-cause"&gt;Why it was NOT the obvious cause
&lt;/h2&gt;&lt;p&gt;The refactor was innocent. It only moved a loop (&lt;code&gt;run_suite&lt;/code&gt; extracted from &lt;code&gt;main&lt;/code&gt;);
it touched no retrieval or graph code. Isolation confirmed this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ollama list&lt;/code&gt; — the embedder was loaded and fine.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git status&lt;/code&gt; / &lt;code&gt;git log&lt;/code&gt; — corpus untouched since the baseline commit. No data
change.&lt;/li&gt;
&lt;li&gt;A &lt;em&gt;second&lt;/em&gt; entry also mis-retrieved, so it wasn&amp;rsquo;t one bad entry — it was systemic
to retrieval.&lt;/li&gt;
&lt;li&gt;Comparing to the committed baseline showed the affected entries &lt;strong&gt;used to pass&lt;/strong&gt;.
So the baseline was right and &lt;em&gt;current&lt;/em&gt; had drifted — pointing away from a bad
baseline and toward something environmental.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="root-cause"&gt;Root cause
&lt;/h2&gt;&lt;p&gt;The &lt;strong&gt;vector store on disk was stale.&lt;/strong&gt; The corpus had grown from 15 to 20 documents
in an earlier commit, but the ChromaDB index at &lt;code&gt;data/chromadb&lt;/code&gt; had never been
rebuilt after that growth. So retrieval was searching the &lt;strong&gt;old 15-doc index&lt;/strong&gt; while
the suite and baseline expected the &lt;strong&gt;current 20-doc / 107-chunk&lt;/strong&gt; corpus. Wrong
incidents came back, or none at all.&lt;/p&gt;
&lt;p&gt;The reason &lt;code&gt;git status&lt;/code&gt; gave no warning: &lt;strong&gt;&lt;code&gt;data/chromadb&lt;/code&gt; is gitignored&lt;/strong&gt; — it&amp;rsquo;s a
generated artifact, not tracked. So git reported a clean tree while the actual thing
retrieval depends on was silently out of date. &lt;strong&gt;A clean git tree says nothing about
the state of the index.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="the-fix"&gt;The fix
&lt;/h2&gt;&lt;p&gt;Re-index so the store matches the current corpus (&lt;code&gt;python -m src.embedding&lt;/code&gt;). After
re-indexing, L2-001 immediately found the expected incident again, and the full suite
reproduced documented behavior.&lt;/p&gt;
&lt;h2 id="why-this-is-load-bearing-for-the-quality-gate"&gt;Why this is load-bearing for the quality gate
&lt;/h2&gt;&lt;p&gt;This is exactly the failure the Layer 3 runner&amp;rsquo;s &lt;strong&gt;mandatory re-index step&lt;/strong&gt; prevents.
A gate that evaluated without re-indexing would compare a fresh baseline against a
possibly-stale store and report regressions that aren&amp;rsquo;t real — the false-alarm
failure mode ADR-020 warns about. The re-index isn&amp;rsquo;t hygiene; it&amp;rsquo;s a &lt;em&gt;correctness
precondition&lt;/em&gt;, now demonstrated rather than assumed.&lt;/p&gt;
&lt;h2 id="lessons"&gt;Lessons
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gitignored artifacts have no version signal.&lt;/strong&gt; A clean tree can sit on top of a
stale generated dependency. Never infer store freshness from git.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnose environmental vs. logic failures before concluding.&lt;/strong&gt; The symptom framed
the refactor as guilty; the cause was a stale artifact. Isolation, not assumption,
found it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The baseline was the tool that cracked it.&lt;/strong&gt; Run current, diff against committed
baseline, find the flipped entry, isolate the cause — the same procedure the quality
gate automates, run here by hand.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>The Layer 2 Baseline, and a Grounding Filter That Leaked</title><link>https://kai2055.github.io/p/layer2-baseline-grounding-filter/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/layer2-baseline-grounding-filter/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; Before you can detect whether a system is
&lt;em&gt;getting worse&lt;/em&gt;, you need a measured snapshot of how good it is &lt;em&gt;now&lt;/em&gt; — a baseline.
Building that baseline for the diagnostic agent immediately caught a real bug that
30 existing unit tests had missed: a safeguard meant to strip out &amp;ldquo;made-up&amp;rdquo;
citations was letting them through, as long as at least one &lt;em&gt;real&lt;/em&gt; citation rode
alongside. One genuine reference could smuggle in any number of invented ones. The
lesson: &lt;em&gt;unit tests check the cases you thought of; measuring against real output
catches the ones you didn&amp;rsquo;t.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Measured on:&lt;/strong&gt; 15 descriptions through the full agent · &lt;strong&gt;Result:&lt;/strong&gt; first measured
Layer 2 baseline; one grounding bug found and fixed.&lt;/p&gt;
&lt;h2 id="why-this-matters"&gt;Why this matters
&lt;/h2&gt;&lt;p&gt;Layer 2 had 30 unit tests proving the machinery worked, and one integration run
judged by eye. It had no numbers. You cannot build a regression gate — which detects
regressions by comparing against a baseline — without a baseline to compare to. This
is that baseline. Building it also caught a real bug the unit tests had missed.&lt;/p&gt;
&lt;h2 id="the-bug-the-baseline-caught"&gt;The bug the baseline caught
&lt;/h2&gt;&lt;p&gt;The first run reported &lt;strong&gt;2 grounding violations&lt;/strong&gt; — candidates citing incident IDs
that were never retrieved. That number is supposed to be impossible by construction,
so the tripwire fired.&lt;/p&gt;
&lt;h3 id="why-it-slipped-through"&gt;Why it slipped through
&lt;/h3&gt;&lt;p&gt;The grounding filter kept a candidate if &lt;strong&gt;any&lt;/strong&gt; cited ID was real:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;cited &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {&lt;span style="color:#f92672"&gt;...&lt;/span&gt;} &lt;span style="color:#75715e"&gt;# every id the model cited&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; cited &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; valid_ids: &lt;span style="color:#75715e"&gt;# at least one is real?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; grounded&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(d) &lt;span style="color:#75715e"&gt;# keep the whole thing, evidence unchanged&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So a candidate citing &lt;code&gt;aws-s3-2017-02-28, gitlab-2017-01-31&lt;/code&gt; passed — aws-s3 is real,
the intersection is non-empty — and carried the fabricated gitlab citation through
untouched. &lt;strong&gt;The filter checked that at least one citation was grounded. It never
checked that every citation was.&lt;/strong&gt; One real id smuggled in any number of invented
ones.&lt;/p&gt;
&lt;h3 id="the-fix"&gt;The fix
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;real &lt;span style="color:#f92672"&gt;=&lt;/span&gt; cited &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; valid_ids
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;not&lt;/span&gt; real:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;continue&lt;/span&gt; &lt;span style="color:#75715e"&gt;# nothing grounds this, drop it&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;d[&lt;span style="color:#e6db74"&gt;&amp;#34;evidence&amp;#34;&lt;/span&gt;] &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;, &amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;join(sorted(real)) &lt;span style="color:#75715e"&gt;# keep only the real citations&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;grounded&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(d)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A candidate now survives only if it has a real citation, and its evidence is
rewritten to contain &lt;em&gt;only&lt;/em&gt; real citations. Fabricated ids are stripped, not
tolerated. Covered by a new test that feeds one real and one fake id and asserts only
the real one remains.&lt;/p&gt;
&lt;h3 id="why-the-unit-tests-missed-it"&gt;Why the unit tests missed it
&lt;/h3&gt;&lt;p&gt;The existing grounding test used candidates that were &lt;em&gt;entirely&lt;/em&gt; fabricated — those
were correctly dropped. The gap was the &lt;em&gt;mixed&lt;/em&gt; case: one real citation plus one
fake. No test exercised it, so nothing failed. &lt;strong&gt;The scoring run on real model output
was the first thing to hit it. That&amp;rsquo;s the point of scoring against real output: unit
tests check the cases you thought of, evaluation catches the ones you didn&amp;rsquo;t.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="the-baseline-after-the-fix"&gt;The baseline (after the fix)
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Metric&lt;/th&gt;
 &lt;th&gt;Value&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;top-1 accuracy&lt;/td&gt;
 &lt;td&gt;0.625 (5 of 8 with a primary cause)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;any-hit rate&lt;/td&gt;
 &lt;td&gt;0.615&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;noise rate&lt;/td&gt;
 &lt;td&gt;0.444&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;decline rate&lt;/td&gt;
 &lt;td&gt;1.000 (2 of 2 no-match entries)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;mean candidates&lt;/td&gt;
 &lt;td&gt;1.80&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;grounding violations&lt;/td&gt;
 &lt;td&gt;0&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;mean iterations&lt;/td&gt;
 &lt;td&gt;2.20&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No targets were set in advance — the first clean run &lt;em&gt;is&lt;/em&gt; the baseline, so the gate
enforces &amp;ldquo;do not fall below this,&amp;rdquo; not an invented number. The one exception is
grounding violations, which isn&amp;rsquo;t a target but an invariant: it must be zero.&lt;/p&gt;
&lt;h2 id="reading-it"&gt;Reading it
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; decline rate is perfect — the reliability behaviour (declining rather
than inventing) holds. Mean candidates fell from 5 to 1.8; the list no longer pads.
Grounding is clean and now provably so.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What doesn&amp;rsquo;t:&lt;/strong&gt; noise rate 0.444 is high, but &lt;em&gt;concentrated, not spread&lt;/em&gt; — two
specific descriptions (L2-003, L2-007) produce almost all the noise in the whole
suite. Every other entry is clean or nearly so. So the problem is two descriptions
the agent handles badly, not a diffuse quality issue — a located problem to work on
rather than a general sense that quality is mediocre.&lt;/p&gt;
&lt;h2 id="what-this-unblocks"&gt;What this unblocks
&lt;/h2&gt;&lt;p&gt;Layer 2 now has a committed baseline across seven metrics and a clean grounding
invariant. The regression gate has something to regress against — and two concrete,
located problems to work on: the noise concentrated in L2-003 and L2-007, and the
XID-wraparound attractor (documented separately).&lt;/p&gt;</description></item><item><title>Why Layer 2 Needed Its Own Threshold</title><link>https://kai2055.github.io/p/layer2-threshold/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/layer2-threshold/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The diagnostic layer breaks an incident into
short symptom fragments and searches for each. But it was reusing a
&amp;ldquo;closeness&amp;rdquo; cutoff that had been tuned for full, richly-worded questions — and
short fragments always score as less close, so &lt;strong&gt;25 of 27 symptoms found the right
incident and then had it thrown away&lt;/strong&gt;. Giving Layer 2 its own, looser cutoff fixed
most of it. But the honest conclusion isn&amp;rsquo;t &amp;ldquo;0.36 is the right number&amp;rdquo; — it&amp;rsquo;s that
&lt;em&gt;a closeness score alone can&amp;rsquo;t cleanly separate signal from noise on short
fragments&lt;/em&gt;, because in the data the two genuinely overlap. Naming that limit is
the point.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Measured on:&lt;/strong&gt; 15 incident descriptions, 27 frozen symptoms, 5 no-match symptoms.&lt;/p&gt;
&lt;h2 id="the-problem-in-one-line"&gt;The problem in one line
&lt;/h2&gt;&lt;p&gt;Layer 2 was using Layer 1&amp;rsquo;s threshold. Layer 1&amp;rsquo;s threshold was tuned on complete
questions. Layer 2 sends symptom fragments. Fragments score worse, so everything was
thrown away.&lt;/p&gt;
&lt;h2 id="why-fragments-score-worse"&gt;Why fragments score worse
&lt;/h2&gt;&lt;p&gt;A post-mortem chunk describes a whole incident — trigger, failure, cascade, recovery
— many concepts in one paragraph. A complete question matches that richness. A
fragment matches one small part of it, so the distance is worse even though it
describes the same event. &lt;strong&gt;The whole description is closer to the document than any
of its parts.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Input type&lt;/th&gt;
 &lt;th&gt;Typical distance&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Layer 1 complete questions&lt;/td&gt;
 &lt;td&gt;0.20 – 0.27&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Layer 2 symptom fragments&lt;/td&gt;
 &lt;td&gt;0.32 – 0.41&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;The threshold both were using&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.30&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The cutoff sits in the gap. Layer 1 clears it every time. Layer 2 never does.&lt;/p&gt;
&lt;h2 id="what-was-actually-failing"&gt;What was actually failing
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Verdict&lt;/th&gt;
 &lt;th&gt;Count&lt;/th&gt;
 &lt;th&gt;Meaning&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;PASS&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;found, kept, visible to the agent&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;THRESHOLD&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;found the right document, then discarded&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;RANK&lt;/td&gt;
 &lt;td&gt;0&lt;/td&gt;
 &lt;td&gt;found but ranked too deep&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;MISS&lt;/td&gt;
 &lt;td&gt;2&lt;/td&gt;
 &lt;td&gt;never found at all&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;25 of 27 symptoms found the correct document and had it thrown away.&lt;/strong&gt; Retrieval
was working. Filtering was calibrated for the wrong input — the correct document was
often ranked &lt;em&gt;first&lt;/em&gt;, at distances like 0.32–0.33, just above the 0.30 cutoff.&lt;/p&gt;
&lt;h2 id="the-sweep-and-the-decision"&gt;The sweep, and the decision
&lt;/h2&gt;&lt;p&gt;Each threshold was tested against the same frozen symptoms. The usable range came out
to &lt;strong&gt;0.34–0.36&lt;/strong&gt;: below it, entries get no evidence; above 0.38 junk starts leaking
badly, and at 0.40 &lt;em&gt;every&lt;/em&gt; no-match probe leaks — decline behaviour collapses
entirely. &lt;strong&gt;0.36&lt;/strong&gt; is the best point: 9 of 13 entries get evidence, junk still only
1 of 5. Decision: &lt;code&gt;LAYER2_THRESHOLD = 0.36&lt;/code&gt;, while Layer 1 keeps 0.30.&lt;/p&gt;
&lt;h2 id="what-this-does-not-fix"&gt;What this does &lt;em&gt;not&lt;/em&gt; fix
&lt;/h2&gt;&lt;p&gt;Stated plainly, because 0.36 is not a solution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;4 of 13 descriptions still get nothing&lt;/strong&gt; — the agent still fails on those.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4 symptoms return the wrong document&lt;/strong&gt; — the agent reasons over a wrong incident,
which during an outage is worse than returning nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 junk symptom leaks&lt;/strong&gt; — a description with nothing matching gets treated as if it
matched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Layer 2 goes from never working to working roughly two-thirds of the time, with some
false leads.&lt;/p&gt;
&lt;h2 id="the-overlap-problem"&gt;The overlap problem
&lt;/h2&gt;&lt;p&gt;The reason no threshold is clean: the distance bands &lt;em&gt;interleave&lt;/em&gt;. A junk symptom
(&amp;ldquo;air conditioning failed&amp;rdquo;, 0.362) can score worse than a real one, but another junk
symptom (&amp;ldquo;machines shut themselves down&amp;rdquo;, 0.334) scores &lt;em&gt;better&lt;/em&gt; than a real hit
(&amp;ldquo;servers dropping everything&amp;rdquo;, 0.344). &lt;strong&gt;No cutoff separates them, because the
separation doesn&amp;rsquo;t exist in the data.&lt;/strong&gt; More samples would describe the overlap more
precisely; they wouldn&amp;rsquo;t create a boundary.&lt;/p&gt;
&lt;p&gt;So the honest conclusion is not &amp;ldquo;0.36 is the right number.&amp;rdquo; It&amp;rsquo;s: &lt;strong&gt;a distance score
alone cannot separate signal from noise on short symptom fragments.&lt;/strong&gt; 0.36 is a round
number picked from where the table turns — a working setting that makes the component
functional, to be revisited when the suite grows. Confidence in the exact number is
low, deliberately so.&lt;/p&gt;
&lt;h2 id="what-to-try-next"&gt;What to try next
&lt;/h2&gt;&lt;p&gt;The threshold fix treats the symptom; the cause is that fragments carry less signal
than whole descriptions. The real direction: &lt;strong&gt;search the full description alongside
each symptom&lt;/strong&gt; and merge the results — matching rich text against rich chunks, the
comparison the embedding model is actually good at. Evidence: the same content as
complete descriptions scored 0.870 at threshold 0.30, while split into fragments it
scores zero at the same threshold. That would widen the gap between signal and noise
rather than moving a line through the middle of it.&lt;/p&gt;</description></item><item><title>Chasing a Bug That Didn't Exist</title><link>https://kai2055.github.io/p/chasing-a-bug-that-didnt-exist/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/chasing-a-bug-that-didnt-exist/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; A search system was returning &amp;ldquo;nothing
found&amp;rdquo; — while the correct answer sat in its database, ranked first. Nothing had
crashed; every function did exactly what it was written to do. This is the story
of discovering that &lt;em&gt;the code was fine and the system was still broken&lt;/em&gt; — because
the test suite had been unknowingly grading the system on easy questions, and a
hidden limit was throwing away correct answers before anyone looked at them. It&amp;rsquo;s
a good example of the kind of failure that doesn&amp;rsquo;t show up as an error message —
the most dangerous kind.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Below is the full technical investigation. Skip it freely — the summary above is
the point.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Layer:&lt;/strong&gt; 1 (retrieval) · &lt;strong&gt;Outcome:&lt;/strong&gt; No defect found in application code. Two
real problems found anyway.&lt;/p&gt;
&lt;h2 id="where-it-started"&gt;Where it started
&lt;/h2&gt;&lt;p&gt;Layer 2 was blocked. A retrieval probe returned zero results against a store
holding 83 chunks. No error, no exception — just an empty list.&lt;/p&gt;
&lt;p&gt;The working theory was environmental: the embedding model wasn&amp;rsquo;t loaded in Ollama.
That theory mattered, because Layer 2&amp;rsquo;s &amp;ldquo;the agent declines honestly&amp;rdquo; framing
depended on knowing whether the empty result was a genuine no-match or a broken
pipe.&lt;/p&gt;
&lt;p&gt;First command of the day killed it. &lt;code&gt;ollama list&lt;/code&gt; showed &lt;code&gt;nomic-embed-text&lt;/code&gt;,
274 MB, installed four weeks earlier. The model was fine. So the bug was real, and
somewhere in code I&amp;rsquo;d written.&lt;/p&gt;
&lt;h2 id="the-elimination"&gt;The elimination
&lt;/h2&gt;&lt;p&gt;Rather than guess, I listed every link in the chain and tested each in order. Six
suspects.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Suspect&lt;/th&gt;
 &lt;th&gt;Result&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Model not installed&lt;/td&gt;
 &lt;td&gt;Present&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Wrong database path&lt;/td&gt;
 &lt;td&gt;App points at &lt;code&gt;data/chromadb&lt;/code&gt; — correct&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Store empty&lt;/td&gt;
 &lt;td&gt;83 chunks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Wrong distance measure&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;hnsw:space: cosine&lt;/code&gt;, set explicitly&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Embedding prefix mismatch&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;search_document:&lt;/code&gt; / &lt;code&gt;search_query:&lt;/code&gt; correctly paired&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Threshold comparison inverted&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;distance &amp;lt;= threshold&lt;/code&gt; — right direction&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every single one came back clean. Every function I checked did exactly what it was
written to do. That was the first real finding: &lt;strong&gt;the system was correct and still
useless.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="three-false-leads-all-self-inflicted"&gt;Three false leads, all self-inflicted
&lt;/h2&gt;&lt;p&gt;The diagnostic script lied to me three times, and each time for the same reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It hardcoded the database path.&lt;/strong&gt; Pointed at &lt;code&gt;data/chroma&lt;/code&gt; instead of
&lt;code&gt;data/chromadb&lt;/code&gt;. ChromaDB doesn&amp;rsquo;t error on an empty folder — it silently creates a
blank database. So the script made an empty store and correctly reported it was
empty. Cost: half an hour convinced the corpus had vanished.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It reimplemented the embedding call.&lt;/strong&gt; Called Ollama directly with plain text,
no prefix. The corpus was embedded with &lt;code&gt;search_document:&lt;/code&gt;. So it was comparing
unlabelled queries against labelled documents — a mismatched measurement that
produced a real-looking number I then reasoned from.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It used a candidate depth the system doesn&amp;rsquo;t use.&lt;/strong&gt; I set 15 to see where
documents ranked. The system uses 5. That made the diagnostic disagree with the
sweep, and reconciling the two is what exposed the actual bug.&lt;/p&gt;
&lt;p&gt;The lesson generalises: &lt;strong&gt;a diagnostic that restates what the system defines will
eventually disagree with it, and it will disagree quietly.&lt;/strong&gt; Every fix was the same
— import the real code instead of copying it.&lt;/p&gt;
&lt;h2 id="what-was-actually-wrong"&gt;What was actually wrong
&lt;/h2&gt;&lt;p&gt;Two things, neither a broken function.&lt;/p&gt;
&lt;h3 id="the-evaluation-suite-was-easier-than-reality"&gt;The evaluation suite was easier than reality
&lt;/h3&gt;&lt;p&gt;The 31-query suite was written after reading and normalising all 15 post-mortems.
So it reuses the documents&amp;rsquo; own vocabulary without meaning to. It scored the system
1.000 while plain-English questions were failing.&lt;/p&gt;
&lt;p&gt;To measure this instead of asserting it, I built a paired suite: same entries, same
&lt;code&gt;expected_doc_id&lt;/code&gt;, same difficulty, only the query text rewritten in plain English.
No-match probes and filter queries held constant as a control.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Suite&lt;/th&gt;
 &lt;th&gt;Hit rate @ 0.30&lt;/th&gt;
 &lt;th&gt;MRR&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Original wording&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Plain English&lt;/td&gt;
 &lt;td&gt;0.826&lt;/td&gt;
 &lt;td&gt;0.783&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The control held — decline rate identical across both suites at every threshold. So
the gap came from wording and nothing else.&lt;/p&gt;
&lt;p&gt;The clearest single illustration:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Query&lt;/th&gt;
 &lt;th&gt;Distance&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&amp;ldquo;BGP route leak&amp;rdquo; — the document&amp;rsquo;s own words&lt;/td&gt;
 &lt;td&gt;0.1996&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&amp;ldquo;database ran out of connections&amp;rdquo; — same kind of event, plain words&lt;/td&gt;
 &lt;td&gt;0.3035&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The distance was largely measuring word overlap. The threshold was acting as a
jargon filter.&lt;/p&gt;
&lt;h3 id="top_k-was-discarding-correct-answers-before-checking-them"&gt;&lt;code&gt;top_k&lt;/code&gt; was discarding correct answers before checking them
&lt;/h3&gt;&lt;p&gt;The plain-English curve flattened at 0.913 and never moved, even at threshold 0.50
where the filter does nothing. That meant some failures weren&amp;rsquo;t threshold failures
at all.&lt;/p&gt;
&lt;p&gt;A per-query diagnostic found the reason: one query&amp;rsquo;s correct document sat at &lt;strong&gt;rank
6, distance 0.2888&lt;/strong&gt; — comfortably inside the 0.30 threshold, never looked at,
because &lt;code&gt;top_k=5&lt;/code&gt; truncated the list first.&lt;/p&gt;
&lt;p&gt;The candidate count was overriding the relevance rule. Raising it to 10:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;Before&lt;/th&gt;
 &lt;th&gt;After&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Hit rate @ 0.30&lt;/td&gt;
 &lt;td&gt;0.826&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.870&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Ceiling&lt;/td&gt;
 &lt;td&gt;0.913&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.957&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Decline rate&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Real answers gained, no noise admitted. The same number turned out to be hardcoded
in four places — &lt;code&gt;retrieve()&lt;/code&gt;, &lt;code&gt;run_sweep&lt;/code&gt;, &lt;code&gt;score_filter_query&lt;/code&gt;, and the agent&amp;rsquo;s
retrieve node at 3. It&amp;rsquo;s now one constant, &lt;code&gt;DEFAULT_TOP_K&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Worth noting the agent was retrieving &lt;strong&gt;three&lt;/strong&gt; candidates per symptom while Layer 1
used five. The component doing the harder work had the least evidence.&lt;/p&gt;
&lt;h2 id="the-decision-i-didnt-make"&gt;The decision I didn&amp;rsquo;t make
&lt;/h2&gt;&lt;p&gt;Two queries still fail at 0.30, missing by 0.0055 and 0.0092. Moving the threshold
to 0.35 would recover both. I left it alone.&lt;/p&gt;
&lt;p&gt;The distance bands overlap — a junk probe scored 0.215, closer than four of five
real queries. There is no value that separates good from bad, so any number is a
tradeoff, and one tuned to clear 23 specific queries is fitting the sample rather
than the problem. 0.35 would also drop decline rate from 0.600 to 0.200 — triple the
noise to recover two borderline cases.&lt;/p&gt;
&lt;p&gt;And the failure modes aren&amp;rsquo;t equal. Returning nothing costs an engineer time.
Returning the wrong past incident during a live outage sends them after the wrong
root cause. Strict is the right side to fail on.&lt;/p&gt;
&lt;h2 id="what-this-is-actually-a-story-about"&gt;What this is actually a story about
&lt;/h2&gt;&lt;p&gt;Not a bug hunt. Every function was correct. It&amp;rsquo;s about a system that stays up,
throws no errors, and quietly returns nothing while holding the answer. During a
live outage it would tell an engineer that nothing similar has ever happened — with
the matching post-mortem in hand, ranked first.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s about the evaluation framework marking its own homework. The suite said
1.000. The system was at 0.826 for anyone who hadn&amp;rsquo;t read the corpus. The
measurement was wrong in the flattering direction, which is the direction you don&amp;rsquo;t
check.&lt;/p&gt;
&lt;p&gt;The fix for that isn&amp;rsquo;t a better threshold. It&amp;rsquo;s a regression gate — re-running these
measurements on every corpus change and reporting when the numbers move. You don&amp;rsquo;t
calibrate once; you build the thing that notices when calibration has gone stale.&lt;/p&gt;</description></item><item><title>BUG-001 — A Threshold Applied Where It Didn't Belong</title><link>https://kai2055.github.io/p/bug-001-distance-threshold/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/bug-001-distance-threshold/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The system had two different jobs that
happened to share one piece of code: &lt;em&gt;ranking&lt;/em&gt; results by closeness, and
&lt;em&gt;fetching an exact set&lt;/em&gt; of records that match a filter. A cutoff meant only for
the first job was silently being applied to the second — but it stayed harmless
as long as a temporary setting was loose enough to never cut anything. The moment
that setting was tightened to a realistic value, filter accuracy collapsed from
100% to 33%. The bug had been there since the first line of code; changing one
config value exposed it. The lesson: &lt;em&gt;tests passing at a permissive setting are
not proof of correctness.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full bug report below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;ID&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;BUG-001&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Component&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;retrieve()&lt;/code&gt; — &lt;code&gt;src/embedding.py&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Severity&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Major · &lt;strong&gt;Priority&lt;/strong&gt; High&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Status&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Closed — fixed and verified&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Related&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;TP-001, ADR-013&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="what-happened"&gt;What happened
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;RELEVANCE_THRESHOLD&lt;/code&gt; was lowered from 1.0 to 0.30. Semantic queries improved.
Filter queries collapsed. Filter precision, recall, and exact-match all dropped from
1.000 to 0.333 — one of three queries passing. The same value across three runs. Not
noise. A logic bug.&lt;/p&gt;
&lt;p&gt;The defect was already there. At 1.0, nothing ever got cut, so the bad path never
ran. A config value changed and the latent bug surfaced.&lt;/p&gt;
&lt;h2 id="evidence"&gt;Evidence
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Metric&lt;/th&gt;
 &lt;th&gt;t = 1.0&lt;/th&gt;
 &lt;th&gt;t = 0.30&lt;/th&gt;
 &lt;th&gt;Delta&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Hit rate@5&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;MRR&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Section accuracy&lt;/td&gt;
 &lt;td&gt;0.435&lt;/td&gt;
 &lt;td&gt;0.435&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Decline rate&lt;/td&gt;
 &lt;td&gt;0.000&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;td&gt;+0.600&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Filter precision / recall / exact&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.333&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;−0.667&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Regression is isolated. Everything else held.&lt;/p&gt;
&lt;h2 id="root-cause"&gt;Root cause
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;retrieve()&lt;/code&gt; applied the cosine-distance threshold to &lt;em&gt;every&lt;/em&gt; query — including
metadata-filtered ones:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Keep only results close enough to be relevant&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;relevant &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [r &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; r &lt;span style="color:#f92672"&gt;in&lt;/span&gt; results &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; r[&lt;span style="color:#e6db74"&gt;&amp;#34;distance&amp;#34;&lt;/span&gt;] &lt;span style="color:#f92672"&gt;&amp;lt;=&lt;/span&gt; threshold] &lt;span style="color:#75715e"&gt;# applied unconditionally&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two things are wrong. The list comprehension has no condition — every caller gets
the distance cutoff, so semantic ranking and metadata set-retrieval share one path.
And the docstring states it as a flat rule, with no sign that a metadata query might
need different treatment. The defect is in the design, not a slip in the code.&lt;/p&gt;
&lt;p&gt;A filter query like &amp;ldquo;minor-severity incidents&amp;rdquo; is a &lt;strong&gt;set question&lt;/strong&gt;. The right
answer is &lt;em&gt;every&lt;/em&gt; document matching the filter — it doesn&amp;rsquo;t matter how semantically
close the query phrase sits to the chunk text; the metadata already decided. At 1.0,
nothing was ever discarded, so the wrong logic produced correct output by accident.
At 0.30, documents that matched the filter correctly but sat far in cosine distance
got silently dropped. Filter scoring is exact set-match — lose one document, lose
the query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real defect:&lt;/strong&gt; two retrieval modes — semantic ranking and metadata
set-retrieval — forced through one code path, with a parameter meant for one mode
leaking into the other.&lt;/p&gt;
&lt;h2 id="fix"&gt;Fix
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;retrieve()&lt;/code&gt; now accepts &lt;code&gt;threshold=None&lt;/code&gt; — no distance cutoff, return all
metadata-matched results, ranked. The call site for filter scoring passes
&lt;code&gt;threshold=None&lt;/code&gt;; semantic queries keep the real threshold. The two modes are now
distinguished at the call site.&lt;/p&gt;
&lt;p&gt;Fixed at the source — &lt;code&gt;retrieve()&lt;/code&gt;, not the eval harness — so every downstream
caller inherits it, including the Layer 2 diagnostic agent.&lt;/p&gt;
&lt;h2 id="what-was-missed"&gt;What was missed
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;score_filter_query&lt;/code&gt; passes &lt;code&gt;top_k=5&lt;/code&gt;. If a metadata filter matches more than 5
documents, the vector store returns only the top 5 by distance, and set-match scoring
counts the rest as missing. Harmless now — no filter in the 15-document corpus exceeds
5 — but the same defect class: a semantic parameter constraining a metadata query.
Tracked separately, to fix before the corpus grows.&lt;/p&gt;
&lt;h2 id="lessons-learned"&gt;Lessons learned
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;1. Passing tests at a permissive setting are not evidence of correctness.&lt;/strong&gt; The bug
existed from the first line of code. &lt;code&gt;RELEVANCE_THRESHOLD = 1.0&lt;/code&gt; meant the faulty
branch never discarded anything. The placeholder was even flagged in the source
(&lt;code&gt;# loose placeholder&lt;/code&gt;). Knowing a setting is temporary is not the same as testing
what happens when it changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Partial metric reporting hides regressions.&lt;/strong&gt; The threshold sweep tracked 3 of 5
metrics — hit rate, MRR, decline rate. It &amp;ldquo;confirmed&amp;rdquo; 0.30 as optimal while silently
breaking a metric it did not watch. Caught only on the confirming run that reported
the full set. Fix: full metrics on every run, enforced by exit criteria.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Determinism separates bugs from noise.&lt;/strong&gt; The first thought was jitter. Three
identical runs at 0.333 reclassified it as logic, worth investigating.&lt;/p&gt;</description></item></channel></rss>