<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Finding on Nikhil Adhikari</title><link>https://kai2055.github.io/tags/finding/</link><description>Recent content in Finding 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/finding/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>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></channel></rss>