<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Nikhil Adhikari</title><link>https://kai2055.github.io/</link><description>Recent content 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/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>Confident Matches on Vocabulary, Not Mechanism</title><link>https://kai2055.github.io/p/vocabulary-not-mechanism/</link><pubDate>Sat, 25 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/vocabulary-not-mechanism/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The system finds past incidents by comparing
&lt;em&gt;meaning&lt;/em&gt;, but sometimes two very different failures use the same words —
&amp;ldquo;Cloudflare,&amp;rdquo; &amp;ldquo;edge,&amp;rdquo; &amp;ldquo;database&amp;rdquo; — and it confidently returns the wrong one. This
is the most dangerous kind of error: not a crash, not an obvious miss, but a
confident wrong answer that would send an engineer down the wrong path during a
live outage. Rather than hide it or fake a better score by deleting the test, I&amp;rsquo;ve
kept it visible and documented — because knowing exactly where a reliability
system fails &lt;em&gt;is&lt;/em&gt; the reliability work, and this particular fix is real design
work, not a quick tuning tweak.&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;Where it shows:&lt;/strong&gt; Layer 1 retrieval and Layer 2 diagnosis — the same failure,
twice. &lt;strong&gt;Status:&lt;/strong&gt; Understood, not fixed. Kept visible on purpose.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;The system sometimes matches on shared words rather than shared cause, and does it
with high confidence. A query and an incident can use the same vocabulary while
describing completely different failures. The embedding scores them as close, the
system returns the wrong incident, and nothing about the score signals that it&amp;rsquo;s
wrong. This is the failure the whole project exists to guard against: not a crash,
not an obvious miss, but a confident wrong answer that would mislead an on-call
engineer during a live outage.&lt;/p&gt;
&lt;h2 id="instance-1--layer-1-retrieval-the-ddos-probe"&gt;Instance 1 — Layer 1 retrieval: the DDoS probe
&lt;/h2&gt;&lt;p&gt;A no-match probe: &lt;em&gt;&amp;ldquo;DDoS attack overwhelmed our CDN edge nodes and caused a 12-hour
outage.&amp;rdquo;&lt;/em&gt; This is meant to retrieve nothing — the corpus has no DDoS incident.
Against the 15-document corpus it correctly declined. Against the 20-document corpus
it now matches a Cloudflare incident at distance &lt;strong&gt;0.236&lt;/strong&gt; — well inside the 0.30
threshold, a confident hit.&lt;/p&gt;
&lt;p&gt;But that Cloudflare incident is a &lt;strong&gt;configuration-error&lt;/strong&gt; incident: a database
access-control change that cascaded. It has nothing to do with a DDoS, which is a
volumetric attack. The match is on surface vocabulary — &amp;ldquo;Cloudflare,&amp;rdquo; &amp;ldquo;edge,&amp;rdquo;
&amp;ldquo;outage&amp;rdquo; — not on the failure mechanism. The probe stays classified as a no-match;
its continued matching is the finding, not something to reclassify away.&lt;/p&gt;
&lt;h2 id="instance-2--layer-2-diagnosis-the-xid-wraparound-attractor"&gt;Instance 2 — Layer 2 diagnosis: the XID-wraparound attractor
&lt;/h2&gt;&lt;p&gt;In the Layer 2 baseline, three descriptions produced a diagnosis of Postgres
transaction-ID (XID) wraparound. Two were correct (a real Sentry Postgres incident).
The third, L2-007, was &lt;strong&gt;wrong&lt;/strong&gt; — it&amp;rsquo;s Roblox&amp;rsquo;s service-registry cascade, nothing to
do with Postgres. But it shares symptom vocabulary — read-only, cascade, database —
with the Sentry incident, and the model reached for the specific,
authoritative-sounding failure it had seen before.&lt;/p&gt;
&lt;h2 id="why-these-are-the-same-failure"&gt;Why these are the same failure
&lt;/h2&gt;&lt;p&gt;Both are the system latching onto a &lt;strong&gt;specific, plausible, well-documented failure&lt;/strong&gt;
because the surface features match, while the actual mechanism does not. The danger
in both is the &lt;strong&gt;confidence&lt;/strong&gt;. A vague wrong answer is easy to distrust. &amp;ldquo;PostgreSQL
XID wraparound&amp;rdquo; and a 0.236 distance both look authoritative. During an incident,
that&amp;rsquo;s worse than silence — it&amp;rsquo;s a false lead delivered with conviction.&lt;/p&gt;
&lt;h2 id="why-it-is-being-kept-visible-rather-than-patched"&gt;Why it is being kept visible rather than patched
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;It is honest signal.&lt;/strong&gt; The Layer 1 decline rate is 0.500 — three of four no-match
probes decline correctly, and the DDoS probe is the one that doesn&amp;rsquo;t. Forcing that
number to look better by deleting the probe would hide a real property of the system.
The metric is more useful with the known failure inside it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix is not local.&lt;/strong&gt; This isn&amp;rsquo;t a threshold to nudge or a prompt line to add.
It&amp;rsquo;s a limitation of matching on embedding similarity and symptom vocabulary.
Addressing it properly means giving the system more to discriminate on — richer
context at retrieval time, or a verification step that checks whether the mechanism
actually fits before returning a confident answer. That&amp;rsquo;s real design work, recorded
here as the direction rather than attempted as a patch.&lt;/p&gt;
&lt;h2 id="what-would-actually-address-it"&gt;What would actually address it
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval:&lt;/strong&gt; search richer text so the match rests on more than a few shared
nouns. More context gives the embedding more to separate genuinely-similar
incidents from merely-similarly-worded ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnosis:&lt;/strong&gt; a mechanism-check before a candidate is returned with high
confidence — does the cited incident&amp;rsquo;s actual failure mode match the symptoms, or
only their vocabulary? This is a grounding step one level deeper than
citation-checking: not &amp;ldquo;is this incident real and retrieved,&amp;rdquo; but &amp;ldquo;does this
incident actually &lt;em&gt;explain&lt;/em&gt; what was reported.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both are deferred. Both are the honest fix. Neither is a tuning change.&lt;/p&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>The Test Suite That Was Too Slow To Run</title><link>https://kai2055.github.io/p/test-suite-too-slow/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/test-suite-too-slow/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; A project&amp;rsquo;s automated tests are its safety
net — but only if they get run. This suite took over 15 minutes, which in
practice means developers skip it, which means the safety net isn&amp;rsquo;t there. Every
test was correct; the problem was speed. This is the story of cutting it from
&lt;strong&gt;15m 46s to 8.5 seconds&lt;/strong&gt; — not by removing tests, but by noticing the same
expensive work was being redone dozens of times. The theme: &lt;em&gt;correct is not the
same as usable.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full technical walkthrough below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Fast suite 15m 46s → 8.5s · &lt;strong&gt;Also found:&lt;/strong&gt; an 82-vs-83 chunk
discrepancy open since early July.&lt;/p&gt;
&lt;h2 id="framing-this-was-not-a-bug"&gt;Framing: this was not a bug
&lt;/h2&gt;&lt;p&gt;Every test passed. Every test was correct. Nothing produced a wrong answer. The
defect was that the suite took &lt;strong&gt;15 minutes and 46 seconds&lt;/strong&gt;, which in practice
means you stop running it. A test suite you avoid running provides no safety at all,
however correct it is.&lt;/p&gt;
&lt;p&gt;So this is a performance defect in the test harness, not a bug in the system —
worth being precise about, because the fix is different in kind. Nothing was
repaired. Work that was being done repeatedly was made to happen once.&lt;/p&gt;
&lt;h2 id="the-symptom"&gt;The symptom
&lt;/h2&gt;&lt;pre tabindex="0"&gt;&lt;code&gt;85 passed, 1 deselected in 946.89s (0:15:46)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The project&amp;rsquo;s stated test architecture is pure-core / impure-shell: pure logic
tested in milliseconds, slow integration tests run deliberately. The 29 agent tests
already demonstrated this — fully mocked, 0.19 seconds. So the architecture was
right and the suite still took a quarter of an hour. Something was not matching the
design.&lt;/p&gt;
&lt;h2 id="measuring-instead-of-guessing"&gt;Measuring instead of guessing
&lt;/h2&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-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;pytest tests/ -m &lt;span style="color:#e6db74"&gt;&amp;#34;not integration&amp;#34;&lt;/span&gt; --durations&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--durations&lt;/code&gt; prints the slowest tests. The output split cleanly into two groups,
and the distinction between them turned out to be the whole story.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Group 1 — time in &lt;code&gt;setup&lt;/code&gt;:&lt;/strong&gt; four tests, ~90–110 seconds each, all in setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Group 2 — time in &lt;code&gt;call&lt;/code&gt;:&lt;/strong&gt; four tests, ~105–217 seconds each, in the test body.&lt;/p&gt;
&lt;p&gt;And once setup was done, the tests themselves ran in &lt;strong&gt;under two seconds&lt;/strong&gt;. The
tests were never slow. The setup was.&lt;/p&gt;
&lt;h2 id="cause-1-a-fixture-doing-the-same-work-eight-times"&gt;Cause 1: a fixture doing the same work eight times
&lt;/h2&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:#a6e22e"&gt;@pytest.fixture&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;indexed_chunks&lt;/span&gt;(temp_chroma, corpus_path, expected_chunks):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; docs &lt;span style="color:#f92672"&gt;=&lt;/span&gt; load_documents(corpus_path)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; chunks &lt;span style="color:#f92672"&gt;=&lt;/span&gt; chunk_documents(docs)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; index_chunks(chunks)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; chunks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No &lt;code&gt;scope&lt;/code&gt; argument, so pytest defaults to function scope: the fixture runs fresh
for every test that requests it. Embedding 82 chunks through nomic-embed-text on CPU
costs roughly 100 seconds. Four tests used the fixture. Four identical embedding
passes, ~400 seconds, for one corpus that never changed between them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix — session scope&lt;/strong&gt;, so the corpus is embedded once and shared. One
complication: &lt;code&gt;temp_chroma&lt;/code&gt; used pytest&amp;rsquo;s function-scoped &lt;code&gt;monkeypatch&lt;/code&gt;, which a
session-scoped fixture can&amp;rsquo;t consume, so the replacement instantiates
&lt;code&gt;pytest.MonkeyPatch()&lt;/code&gt; directly and undoes it manually.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The tradeoff, stated plainly:&lt;/strong&gt; function scope exists for isolation — every test
gets a clean store. Session scope trades that away for speed. Acceptable here
because these tests read from the store rather than corrupting it — and because a
suite nobody runs is worse than a small isolation risk. Result: 15m 46s → 11m 25s.&lt;/p&gt;
&lt;h2 id="cause-2-a-test-embedding-82-chunks-to-count-to-82"&gt;Cause 2: a test embedding 82 chunks to count to 82
&lt;/h2&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:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;test_vector_count_matches_chunk_count&lt;/span&gt;(corpus_path, expected_chunks):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; docs &lt;span style="color:#f92672"&gt;=&lt;/span&gt; load_documents(corpus_path)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; chunks &lt;span style="color:#f92672"&gt;=&lt;/span&gt; chunk_documents(docs)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; chunks_with_vectors &lt;span style="color:#f92672"&gt;=&lt;/span&gt; embed_chunks(chunks)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;assert&lt;/span&gt; len(chunks_with_vectors) &lt;span style="color:#f92672"&gt;==&lt;/span&gt; expected_chunks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;110 seconds. Read the assertion: it checks that &lt;code&gt;embed_chunks&lt;/code&gt; returns one vector
per chunk it was given. It never inspects a single vector. That property does not
depend on corpus size — five chunks prove it as well as 82. Slicing to &lt;code&gt;chunks[:5]&lt;/code&gt;
took it from &lt;strong&gt;110s → 4.67s&lt;/strong&gt;, and the assertion got &lt;em&gt;better&lt;/em&gt;: the real property is
&amp;ldquo;same number out as in,&amp;rdquo; not &amp;ldquo;82.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="cause-3-genuinely-slow-tests-were-not-labelled"&gt;Cause 3: genuinely slow tests were not labelled
&lt;/h2&gt;&lt;p&gt;Three tests could not be shrunk — &lt;code&gt;test_store_and_search&lt;/code&gt;, &lt;code&gt;test_search_with_filter&lt;/code&gt;,
&lt;code&gt;test_idempotency&lt;/code&gt; — because they genuinely need a fully populated store; that&amp;rsquo;s the
thing under test. They needed a &lt;em&gt;label&lt;/em&gt;, not an optimisation:&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-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[pytest]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;markers&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; slow: embeds the corpus, needs Ollama
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; integration: full end-to-end run across components&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three speeds instead of two:&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-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;pytest -m &lt;span style="color:#e6db74"&gt;&amp;#34;not slow and not integration&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# 8.5s - run constantly&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;pytest -m &lt;span style="color:#e6db74"&gt;&amp;#34;slow&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# ~5 min - before committing&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;pytest -m &lt;span style="color:#e6db74"&gt;&amp;#34;integration&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# ~100 min - deliberately&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The middle tier is the one that was missing. Previously &amp;ldquo;not integration&amp;rdquo; meant 15
minutes, so the only real choice was 15 minutes or nothing.&lt;/p&gt;
&lt;h2 id="result"&gt;Result
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Stage&lt;/th&gt;
 &lt;th&gt;Time&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Start&lt;/td&gt;
 &lt;td&gt;15m 46s&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;After session-scoped fixtures&lt;/td&gt;
 &lt;td&gt;11m 25s&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;After rewriting the count test&lt;/td&gt;
 &lt;td&gt;~9m&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;After marking slow tests&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;8.5s&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;78 tests run by default. 8 parked behind markers.&lt;/p&gt;
&lt;h2 id="the-side-finding-82-vs-83"&gt;The side finding: 82 vs 83
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;conftest.py&lt;/code&gt; expects 82 chunks and the chunking test passes — so chunking really
does produce 82. But the live store at &lt;code&gt;data/chromadb&lt;/code&gt; held &lt;strong&gt;83&lt;/strong&gt;. This
discrepancy had been open since early July. It&amp;rsquo;s now explained: the corpus is
correct, and the live store contained one orphan chunk from an earlier ingest — a
document later changed or removed without the store being rebuilt. A clean re-index
resolves it, worth doing before further calibration, since a stale chunk can surface
in retrieval and quietly distort a measurement.&lt;/p&gt;
&lt;h2 id="what-this-is-a-story-about"&gt;What this is a story about
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Correct is not the same as usable.&lt;/strong&gt; Every test passed and asserted something
true. The suite still failed at its actual job — being run often enough to catch
regressions early. &lt;strong&gt;Measure before optimising:&lt;/strong&gt; the &lt;code&gt;setup&lt;/code&gt;-vs-&lt;code&gt;call&lt;/code&gt; split
pointed straight at two different causes needing two different fixes. &lt;strong&gt;Ask what a
test is actually asserting:&lt;/strong&gt; the 110-second count test verified a property that had
nothing to do with corpus size. &lt;strong&gt;And some slowness is real&lt;/strong&gt; — three tests genuinely
need a populated store, so the fix was a marker, letting them run deliberately
rather than be skipped by accident.&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><item><title>The three layers where ML systems fail</title><link>https://kai2055.github.io/p/the-three-layers-where-ml-systems-fail/</link><pubDate>Thu, 12 Feb 2026 00:00:00 +0100</pubDate><guid>https://kai2055.github.io/p/the-three-layers-where-ml-systems-fail/</guid><description>&lt;p&gt;Most ML portfolios show a model that runs once. The interesting engineering is
in the part that comes after: keeping it running. In my experience, ML systems
fail at three predictable points.&lt;/p&gt;
&lt;h2 id="1-bad-data-gets-in"&gt;1. Bad data gets in
&lt;/h2&gt;&lt;p&gt;The model trains or scores on corrupt input and nobody notices until the numbers
are wrong three steps downstream. The fix is boring and essential: validate data
&lt;em&gt;before&lt;/em&gt; it reaches the model — missing values, duplicates, malformed headers,
schema drift.&lt;/p&gt;
&lt;h2 id="2-the-model-drifts-in-production"&gt;2. The model drifts in production
&lt;/h2&gt;&lt;p&gt;A model that passed every test on launch day quietly degrades as the world
changes underneath it. The first sign shouldn&amp;rsquo;t be an angry user — it should be
a metric. That means monitoring distribution shift (PSI, Wasserstein) and gating
deployments on it.&lt;/p&gt;
&lt;h2 id="3-the-same-failure-repeats"&gt;3. The same failure repeats
&lt;/h2&gt;&lt;p&gt;An outage happens, someone writes a post-mortem, it gets buried in a wiki, and
six months later the same thing happens again. Turning past failures into
&lt;em&gt;searchable, deployment-gating&lt;/em&gt; feedback is the third layer — and the one almost
nobody builds.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;One principle — reliability — across three layers: data, model, system. That&amp;rsquo;s
the thread running through everything on my &lt;a class="link" href="https://kai2055.github.io/projects/" &gt;projects&lt;/a&gt; page.&lt;/p&gt;</description></item><item><title>Why this site exists</title><link>https://kai2055.github.io/p/why-this-site-exists/</link><pubDate>Tue, 10 Feb 2026 00:00:00 +0100</pubDate><guid>https://kai2055.github.io/p/why-this-site-exists/</guid><description>&lt;p&gt;This is where I keep my notes on building ML systems that stay working.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m a slow, deep learner — I&amp;rsquo;d rather understand one thing from first principles
than skim ten. So instead of a static résumé, I keep a working notebook: what I&amp;rsquo;m
building, what broke, and what I learned fixing it.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re a recruiter or engineer in Berlin looking at my
&lt;a class="link" href="https://kai2055.github.io/projects/" &gt;projects&lt;/a&gt;, this blog is the &amp;ldquo;why&amp;rdquo; behind them. If you&amp;rsquo;re learning
MLOps yourself, maybe some of it saves you a wrong turn.&lt;/p&gt;
&lt;p&gt;More soon.&lt;/p&gt;</description></item><item><title>The Non-Code Half of Engineering: Lessons from a Front-End Internship</title><link>https://kai2055.github.io/p/front-end-internship-lessons/</link><pubDate>Thu, 01 Aug 2024 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/front-end-internship-lessons/</guid><description>&lt;p&gt;Before I moved toward machine learning, my first real role was a front-end
development internship at &lt;strong&gt;Mandala Infosys&lt;/strong&gt; in Kathmandu, from April to July
2024. On paper it was an HTML-and-CSS job. In practice it was a hybrid — half
building, half translating between what clients &lt;em&gt;wanted&lt;/em&gt; and what the technical
team could &lt;em&gt;build&lt;/em&gt; — and the translating half is where I learned the most.&lt;/p&gt;
&lt;h2 id="the-build-half"&gt;The build half
&lt;/h2&gt;&lt;p&gt;The straightforward part was writing the front end. I built responsive interfaces
in HTML, CSS, and JavaScript: semantic navigation components, landing-page layouts
with formatted and optimised imagery, and form pages. It was a normal agile setup —
sprint planning, code reviews, iterating on feedback — and it&amp;rsquo;s where I got my first
real taste of shipping something other people would actually use, on a deadline,
with someone reviewing my work.&lt;/p&gt;
&lt;p&gt;That was valuable. But it wasn&amp;rsquo;t the part that changed how I think.&lt;/p&gt;
&lt;h2 id="the-half-that-mattered"&gt;The half that mattered
&lt;/h2&gt;&lt;p&gt;The more unusual side of the role put me between the client and the developers. I&amp;rsquo;d
sit with customers to &lt;strong&gt;elicit what they actually wanted&lt;/strong&gt; from a website — which,
early on, I learned is almost never what they &lt;em&gt;first say&lt;/em&gt; they want. So I started
building small &lt;strong&gt;reference builds&lt;/strong&gt;: quick, concrete mock-ups I could demo, because
a vague request turns specific fast the moment someone can point at a real screen
and say &amp;ldquo;not that — this.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;From there I&amp;rsquo;d draft &lt;strong&gt;solution options&lt;/strong&gt; for the technical team to weigh, and this
is where the actual lesson lived: &lt;strong&gt;feature prioritisation&lt;/strong&gt;. Clients want
everything. Time and budget don&amp;rsquo;t allow everything. My job became walking
non-technical people through the &lt;em&gt;opportunity cost&lt;/em&gt; of each choice — &amp;ldquo;if we build
this, here&amp;rsquo;s what it costs, and here&amp;rsquo;s what it pushes out of scope&amp;rdquo; — so they could
make an informed trade-off instead of a wish list.&lt;/p&gt;
&lt;p&gt;Saying &amp;ldquo;yes, and here&amp;rsquo;s what that costs you&amp;rdquo; turned out to be far more useful than
saying &amp;ldquo;yes&amp;rdquo; to everything.&lt;/p&gt;
&lt;h2 id="why-this-connects-to-reliability"&gt;Why this connects to reliability
&lt;/h2&gt;&lt;p&gt;At the time I didn&amp;rsquo;t see the thread. I do now. The work I care about today — ML
reliability, keeping systems trustworthy in production — is &lt;em&gt;also&lt;/em&gt; mostly about
honest trade-offs. When I decide &lt;a class="link" href="https://kai2055.github.io/ml-reliability-pipeline/" &gt;not to auto-retrain a drifted model&lt;/a&gt;,
or &lt;a class="link" href="https://kai2055.github.io/p/vocabulary-not-mechanism/" &gt;keep a known failure visible instead of hiding it behind a nicer metric&lt;/a&gt;,
or &lt;a class="link" href="https://kai2055.github.io/berlin-transit/" &gt;refuse to cite a number I haven&amp;rsquo;t measured&lt;/a&gt; — that&amp;rsquo;s the same
instinct I first practised in a small office in Kathmandu, explaining to a client
why we shouldn&amp;rsquo;t build the thing they asked for.&lt;/p&gt;
&lt;p&gt;Build the thing. But stay honest about what every decision trades away. That&amp;rsquo;s the
non-code half of engineering, and it&amp;rsquo;s the half I&amp;rsquo;d bet on.&lt;/p&gt;</description></item></channel></rss>