<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>Noel Yohannes Ghebremeskel: writing</title><link>https://noelyohannes.com/blog/</link><description>Notes on building software you can check.</description>
<item><title>Why my AI agent isn&#39;t allowed to check its own work</title><link>https://noelyohannes.com/blog/ai-agent-cant-check-its-own-work/</link><guid>https://noelyohannes.com/blog/ai-agent-cant-check-its-own-work/</guid><pubDate>Sat, 17 Oct 2026 12:00:00 GMT</pubDate><description>In Casefile, an AI writes anti-money-laundering case notes, but plain code checks every number against the ledger before anyone sees them. Here is how it works, and the bug that got past it first.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/"><![CDATA[<p>An investigation note with one wrong amount in it is worse than no note at all. An analyst who trusts it can escalate the wrong case. An analyst who catches it stops trusting the tool.</p>
<p>A language model will happily write &quot;$12,400 moved through 14 accounts&quot; whether or not that&#39;s true. So when I built <strong>Casefile</strong>, an assistant that drafts the first pass of an anti-money-laundering (AML) investigation, I set one rule: <strong>the AI writes, but it never gets the last word on a fact.</strong></p>
<h2 id="splitting-the-job">Splitting the job</h2>
<p>Most of an AML first pass doesn&#39;t need a language model at all. I gave each step to the simplest thing that can do it well:</p>
<ul><li><strong>Finding alerts and pulling transactions:</strong> SQL, through read-only, parameterized tools. The model never writes SQL.</li><li><strong>Scoring how risky an alert is:</strong> a small tabular model.</li><li><strong>Matching behaviour to red-flag indicators:</strong> retrieval over 9 real FINTRAC indicator sentences, which I mapped to patterns, plus a rule-based support check.</li><li><strong>Writing the narrative:</strong> the language model. This is the only AI step.</li><li><strong>Checking every number in that narrative:</strong> a deterministic verifier, which is plain Python.</li><li><strong>Deciding:</strong> a human analyst. Casefile never files anything.</li></ul>
<p>The writer produces the note one sentence at a time. Each sentence carries the transaction IDs it relies on, like a citation.</p>
<h2 id="what-the-verifier-checks">What the verifier checks</h2>
<p>For every sentence, the verifier looks up the cited transactions in the ledger and checks:</p>
<ol><li>Every cited transaction exists, and every transaction ID in the text is cited.</li><li>Every dollar amount equals a cited transfer, or the sum of the cited inflows or outflows.</li><li>Every date is the date of a cited transfer, and &quot;between D1 and D2&quot; spans the cited transfers exactly.</li><li>Every account is a party to a cited transfer.</li><li>Every count (&quot;12 transfers&quot;, &quot;10 distinct senders&quot;) is right.</li><li><strong>No other number is allowed.</strong> A number the verifier doesn&#39;t recognise is rejected, not ignored.</li><li>Every cited red-flag indicator really holds on those transfers. For example, &quot;fan-in&quot; needs at least 10 distinct senders (my threshold, not FINTRAC&#39;s).</li></ol>
<p>Rule 6 matters most. A checker that ignores what it doesn&#39;t understand lets the scariest mistakes through.</p>
<h2 id="when-a-sentence-fails">When a sentence fails</h2>
<p>A failed check isn&#39;t a dead end. The verifier sends back specific, machine-readable errors that say which check failed on which claim, such as an amount that matches no cited transfer. The agent then repairs the claim. It&#39;s a loop, built with LangGraph:</p>
<pre data-lang="text"><code>investigate -&gt; match indicators -&gt; write -&gt; verify
     ^                                         |
     +--------------- errors (max 3) ----------+
                                               |
               still failing after 3 -&gt; manual review (bad claims withheld)</code></pre>
<p>After three repair attempts, the case goes to <strong>manual review</strong>, and the sentences that never passed are withheld. A person always sees which parts were proven.</p>
<h2 id="testing-it-by-breaking-it-on-purpose">Testing it by breaking it on purpose</h2>
<p>To check that the verifier really catches mistakes, I made the writer lie. A fault-injection mode corrupts claims on purpose: wrong amounts, wrong dates, wrong accounts, wrong counts, fake transaction IDs, invented numbers, and indicators the data doesn&#39;t support.</p>
<p>Across two eval runs (30% and 60% fault rates):</p>
<ul><li><strong>417 of 417</strong> deliberately corrupted claims were flagged.</li><li><strong>0 of 994</strong> clean claims were falsely flagged.</li></ul>
<p>To see what the verifier is worth, I compared against the same pipeline with no verifier. At a 30% fault rate, the no-verifier version had 70% citation accuracy, and only 28% of its notes were fully correct. With the verifier, everything Casefile emitted checked out: 94% of cases passed, and 6% went to manual review.</p>
<h2 id="the-bug-that-got-through-first">The bug that got through first</h2>
<p>The first version didn&#39;t score 100%. It caught only about 95% of the corrupted claims.</p>
<p>The gap was in date ranges. Each date in a sentence only had to match <em>one</em> of the cited transfers. So in &quot;between March 3 and March 19&quot;, a wrong endpoint passed as long as it was the date of some cited transfer, even if it wasn&#39;t the first or the last one.</p>
<p>The fix was a stricter rule: a &quot;between&quot; range must span the cited transfers exactly, plus a test so it stays fixed.</p>
<p>The same run caught a bug in the fault injector too. One indicator it labelled &quot;unsupported&quot; was actually supported by the data. The verifier was right and my test label was wrong, so now the injector only picks indicators that really fail.</p>
<h2 id="honest-limits">Honest limits</h2>
<p>I&#39;d rather say these myself than have someone find them:</p>
<ul><li><strong>The data is synthetic.</strong> I generated the ledger with planted laundering patterns, so the patterns are cleaner than real data.</li><li><strong>The &quot;AI writer&quot; in these tests is a template stand-in, not a real language model.</strong> The numbers above measure how well the verifier catches and repairs injected mistakes. They say nothing about how often a real model gets things wrong. Testing against a real model is next.</li><li><strong>&quot;100% of what Casefile emits is correct&quot; is partly true by construction.</strong> Casefile only emits claims that passed the verifier. The meaningful numbers are the catch rate, the false-flag rate and the gap against the baseline.</li><li><strong>The verifier proves that numbers match the ledger.</strong> It doesn&#39;t prove a note is insightful, or that an indicator is legally the right one. That&#39;s the analyst&#39;s job.</li></ul>
<h2 id="what-i-took-from-it">What I took from it</h2>
<p>I stopped trying to make the model careful and made the pipeline strict instead. A claim reaches a person only after code has checked it against the ledger. And when the checker has a hole, it gets a rule and a test, so it stays closed.</p>
<p><em>Casefile is built with Python, LangGraph, FastAPI, SQLite, React and TypeScript, and has 36 automated tests in CI. The repo is private while I finish it.</em></p>]]></content:encoded></item>
<item><title>Proving a root cause before reporting it</title><link>https://noelyohannes.com/blog/prove-the-root-cause-first/</link><guid>https://noelyohannes.com/blog/prove-the-root-cause-first/</guid><pubDate>Sat, 17 Oct 2026 12:00:00 GMT</pubDate><description>RootCause is an on-call agent that finds why a microservice broke, then re-queries the telemetry to prove it before it says anything. Why the loudest service is usually the wrong answer, and why &quot;I don&#39;t know&quot; is a feature.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/"><![CDATA[<p>When something breaks in a system of microservices, the first stretch of an incident is spent jumping between dashboards, trying to work out which service actually failed. And the loudest symptom is usually not the cause. When the payment service breaks, checkout and the front end light up too, because they&#39;re waiting on it.</p>
<p>A confident wrong diagnosis is expensive. It sends people to fix the wrong thing while the outage keeps going. So when I built <strong>RootCause</strong>, an on-call assistant, I wanted its answers to be checkable, not just plausible.</p>
<h2 id="an-answer-key">An answer key</h2>
<p>You can&#39;t score a diagnosis without knowing the right answer. The OpenTelemetry Demo, a sample online store made of many microservices, ships with failure switches like <code>paymentFailure</code> and <code>emailMemoryLeak</code>. Flip one, and you know exactly what broke.</p>
<p>I built a simulator of a 15-service store that follows the demo&#39;s shape and uses 10 of its failure switches. That gave me incidents with a known culprit, so I could measure <strong>top-1 accuracy</strong>: did the agent name the right service and the right kind of failure?</p>
<h2 id="how-the-agent-works">How the agent works</h2>
<p>RootCause is a LangGraph agent, and it is strictly <strong>read-only</strong>. It never changes anything; a person acts on what it finds.</p>
<ol><li><strong>Detect:</strong> a forecast band on each metric flags when something drifts outside normal. No AI here.</li><li><strong>Triage:</strong> rank the suspect services.</li><li><strong>Gather evidence:</strong> query metrics, traces and logs for the top suspects.</li><li><strong>Check the runbook:</strong> retrieve the matching runbook notes.</li><li><strong>Hypothesise:</strong> propose a root cause.</li><li><strong>Verify:</strong> prove it, or loop back.</li></ol>
<h2 id="the-verifier-re-queries">The verifier re-queries</h2>
<p>The verifier doesn&#39;t just reread the evidence the agent already used. It asks <strong>fresh, independent questions</strong>: a later time window, with stricter thresholds. For a crash, for example, the suspect needs at least 10% errors on its own spans (at least 3 spans), in a window that adds 4 fresh minutes the evidence step never saw.</p>
<p>If the hypothesis survives, RootCause reports it. If it doesn&#39;t, the agent widens its search and tries again, up to 3 cycles. After that, it reports <strong>&quot;inconclusive&quot;</strong> instead of guessing.</p>
<h2 id="the-red-herring">The red herring</h2>
<p>My simulator includes a deliberately noisy <code>currency</code> service that logs ERROR lines all the time, whether anything is wrong or not. I added it on purpose, because a noisy-but-healthy service is exactly what fools a quick look at the logs.</p>
<p>I compared RootCause against a naive baseline: blame the service with the most ERROR lines. The baseline blamed <code>currency</code> in 28 of 30 incidents and got <strong>6.7%</strong> right. RootCause got <strong>96.7%</strong> (29 of 30, on simulated data with a rule-based stand-in for the LLM), because it looks at signals that separate a culprit from its victims: errors on a service&#39;s own spans, its own processing time, and its CPU and memory use.</p>
<p>These numbers are on simulated data, with a rule-based stand-in where the language model would go (more under Honest limits). Over 5 random seeds (150 incidents and 50 healthy runs), RootCause scored <strong>98.0%</strong>. When it confirmed an answer, it was right every time (147 of 147), and it raised <strong>0 false alarms</strong> on the healthy runs.</p>
<h2 id="inconclusive-is-a-feature">&quot;Inconclusive&quot; is a feature</h2>
<p>All of the misses were the same case: a weak payment failure (<code>paymentFailure</code> at low intensity). The verifier couldn&#39;t find enough errors in the fresh window, so the agent said &quot;inconclusive&quot;. It never gave a wrong answer with confidence.</p>
<p>I built it that way on purpose. It costs some recall, but on call, &quot;I can&#39;t prove this yet&quot; is far more useful than a confident wrong answer.</p>
<h2 id="the-fancy-model-didn-t-win">The fancy model didn&#39;t win</h2>
<p>I also tried a real pretrained forecasting model (Chronos-Bolt, the tiny version) in place of my simple robust linear forecaster. On this data it made <strong>no measurable difference</strong> (98.0% either way) and was about <strong>7 times slower</strong>. So the simple model stayed as the default.</p>
<p>That says more about my simulated data, which is close to linear plus noise, than about Chronos. On real, seasonal production metrics a learned forecaster may well win. But I only know which one to use because I measured both.</p>
<h2 id="honest-limits">Honest limits</h2>
<ul><li><strong>The data is simulated.</strong> The service names and failure switches follow the OpenTelemetry Demo, but the effect sizes and noise are my own choices. I also tuned the thresholds on this same simulator. Real telemetry will be harder.</li><li><strong>The reasoning step in these tests is a rule-based stand-in, not a real language model.</strong> So 96.7% is a result for the whole pipeline, not a measure of how good an LLM is. Testing against a real model is next.</li><li><strong>It names one culprit per incident.</strong> When two things break at once, it reliably names one real culprit and never blames a victim, but it can&#39;t report both yet.</li></ul>
<h2 id="what-i-took-from-it">What I took from it</h2>
<p>Make the agent prove its answer with data it hasn&#39;t already seen. Give it a way to say &quot;I don&#39;t know&quot;. And measure the fancy option before you adopt it.</p>
<p><em>RootCause is built with Python, LangGraph, FastAPI, React and TypeScript, with parsers for Prometheus, Jaeger and JSON-lines logs, and it has 52 automated tests in CI. The repo is private while I finish it.</em></p>]]></content:encoded></item>
</channel></rss>
