<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Giulio D'Erme</title>
    <description>The latest articles on DEV Community by Giulio D'Erme (@gde03).</description>
    <link>https://dev.to/gde03</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3940212%2F5c67ea11-57fb-4c0d-af21-86b03bfcda07.jpg</url>
      <title>DEV Community: Giulio D'Erme</title>
      <link>https://dev.to/gde03</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gde03"/>
    <language>en</language>
    <item>
      <title>Retrieval-Augmented Self-Recall — What the Comments Taught Me (RE-call v0.3)</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:07:45 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1</guid>
      <description>&lt;p&gt;&lt;em&gt;A follow-up to &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-the-rag-problem-nobody-talks-about-2n0n"&gt;Part 1: the self-recall thesis&lt;/a&gt; — the series runs through &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-part-6-the-fine-tune-that-did-nothing-and-shipping-it-as-an-mcp-240m"&gt;Part 6&lt;/a&gt;. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt; — everything below is measured and reproducible (&lt;code&gt;make eval&lt;/code&gt;), full study in &lt;a href="https://github.com/GiulioDER/RE-call/blob/master/docs/ENTAILMENT_SUPERSESSION_STUDY.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/ENTAILMENT_SUPERSESSION_STUDY.md&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I published a thesis post about agent memory and got five comments that were better than the post.&lt;/p&gt;

&lt;p&gt;Two of them didn't just critique the design — they described, precisely, why it would fail and what would fix it. So I did the only reasonable thing: I turned both into experiments, ran them on the same eval harness the series is built on, and shipped what survived. That's &lt;strong&gt;RE-call v0.3&lt;/strong&gt;, and this post is the receipt.&lt;/p&gt;

&lt;p&gt;I want to be explicit about why I'm writing it this way. The point of publishing this series was never broadcast — it was &lt;strong&gt;error-correction&lt;/strong&gt;. A design you keep in a drawer accumulates conviction; a design you publish accumulates &lt;em&gt;objections&lt;/em&gt;, and objections are the cheapest high-quality signal you will ever get. The comment section of Part 1 did more for this codebase than any week of solo iteration. This post exists to pay that back with the thing commenters almost never receive: evidence that someone listened, measured, and changed the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comment 1: "A similarity score is not a confidence score"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/vinimabreu"&gt;Vinicius Pereira&lt;/a&gt; put it in one line I've been quoting since:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Proximity is a candidate; entailment is the evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;His argument: the near-misses that hurt most are &lt;strong&gt;high-similarity and wrong&lt;/strong&gt; — memos semantically adjacent to the query that don't answer it. A threshold-based &lt;code&gt;gap_warning&lt;/code&gt; (Part 3, Part 5) waves them straight through &lt;em&gt;by construction&lt;/em&gt;, because their similarity clears any threshold you could calibrate. The abstention signal cannot be the retriever's own score. You need a separate check that the retrieved memo actually &lt;strong&gt;entails&lt;/strong&gt; an answer.&lt;/p&gt;

&lt;p&gt;He was right, and measurably so. I built a held-out challenge set of 10 near-miss queries — each names a strongly on-topic memo that does &lt;em&gt;not&lt;/em&gt; contain the asked-for fact ("how much did the cache reduce &lt;strong&gt;memory usage&lt;/strong&gt;" against a memo that measures &lt;em&gt;latency&lt;/em&gt;). Baseline, with the calibrated threshold from Part 5 doing its best:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Embedder&lt;/th&gt;
&lt;th&gt;Near-miss FCR @ calibrated threshold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hashing-64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bge-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The threshold that scores a perfect 0.00 on far-gap queries passes 40–100% of near-misses. There is no threshold to fix. The distractor's cosine is &lt;em&gt;genuinely high&lt;/em&gt; — that's what makes it a near-miss.&lt;/p&gt;

&lt;p&gt;So v0.3 adds an opt-in &lt;strong&gt;entailment stage&lt;/strong&gt;: a small QNLI cross-encoder ("does this sentence answer this question?") judges the trusted hits, and a hit that doesn't entail the query is demoted to a new verdict, &lt;code&gt;not_entailed&lt;/code&gt;. The key property is exactly the one Vinicius predicted: it emits a &lt;strong&gt;decision at the judge's own trained boundary&lt;/strong&gt;, not another score — so there is no per-embedder constant left to recalibrate. And that transfer claim held: the &lt;em&gt;identical&lt;/em&gt; judge, zero tuning, on every embedder:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Embedder&lt;/th&gt;
&lt;th&gt;Near-miss FCR: threshold → +entailment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hashing-64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.00 → &lt;strong&gt;0.60&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bge-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.80 → &lt;strong&gt;0.50&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.40 → 0.40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Where the comment needed a refinement — which is the point of measuring
&lt;/h3&gt;

&lt;p&gt;The ablation was the honest surprise. Running the judge &lt;em&gt;alone&lt;/em&gt;, without the threshold, &lt;strong&gt;degrades far-gap detection&lt;/strong&gt; (gap FCR 0.00 → 0.40 on both semantic embedders): fed nearest-noise from a topic the corpus doesn't cover, the QNLI model sometimes calls it an answer. So entailment does not &lt;em&gt;replace&lt;/em&gt; the calibrated threshold — the two guard &lt;strong&gt;different failure classes&lt;/strong&gt; and must be stacked. Threshold catches far gaps; judge catches near-misses.&lt;/p&gt;

&lt;p&gt;And the costs are real, and published: ~0.1–1.0 s of judge time per query on CPU, one legitimately answerable query wrongly rejected on both semantic embedders (its gold memo answers by &lt;em&gt;negation&lt;/em&gt; — "do we retry on 4xx?" → "we do &lt;strong&gt;not&lt;/strong&gt; retry" — and the judge reads that as not-answering), MRR on answerable queries dips 1.000 → 0.929. The residual near-miss FCR (0.40–0.60) is the judge's own quality bound — Part 5's law, one layer up: gap detection is bounded by the embedder, and &lt;strong&gt;abstention-by-entailment is bounded by the judge&lt;/strong&gt;. Ships OFF by default for exactly these reasons; you opt in with your eyes open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comment 2: "Supersession is a relation, not a property"
&lt;/h2&gt;

&lt;p&gt;The same comment carried a second thesis, on the guard I'd already confessed was weakest (freshness):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are trying to infer a relation between two memos at read time, when both look valid in isolation. That inference is a losing game. Bind the truth when it is created.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And &lt;a href="https://dev.to/mateo_ruiz_6992b1fce47843"&gt;Mateo Ruiz&lt;/a&gt; had independently named the target shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Retrieval should return confidence + provenance + validity, not just relevance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is now, almost verbatim, how RE-call's trust layer describes itself. Every hit returns a verdict (&lt;code&gt;ok / superseded / expired / …&lt;/code&gt;), a calibrated confidence, and provenance; a memo declares &lt;code&gt;supersedes: old-memo.md&lt;/code&gt; in its frontmatter &lt;em&gt;at write time&lt;/em&gt;, and retrieval returns the current head of the chain instead of a resolved-but-still-embedded old decision.&lt;/p&gt;

&lt;p&gt;For v0.3 I added the experiment that closes the "why not just timestamps?" question — against the &lt;strong&gt;steelman&lt;/strong&gt;, not a strawman: "among the confidently-relevant hits, trust the newest", with the stale docs re-touched after their successors, the way any living corpus re-syncs constantly. Superseded-trust rate (how often the stale memo is handed back as the answer — lower is better):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Embedder&lt;/th&gt;
&lt;th&gt;Plain search&lt;/th&gt;
&lt;th&gt;Recency (steelman)&lt;/th&gt;
&lt;th&gt;Declared &lt;code&gt;supersedes:&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hashing-64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bge-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the &lt;code&gt;bge-small&lt;/code&gt; row: the timestamp heuristic is &lt;em&gt;worse than plain relevance ranking&lt;/em&gt; — the tie-break actively promotes the freshly-re-synced stale memo in the one case where ranking had preferred the successor. A per-document timestamp cannot see a two-document relation, and making the timestamp "smarter" makes it more confidently wrong. The declared relation holds at 0.00 in the same runs.&lt;/p&gt;

&lt;p&gt;Vinicius also called the residual failure mode in advance: write-time binding is only as good as the author's discipline — a forgotten link is an orphan memo that looks valid forever. But, as I replied then: &lt;em&gt;impossible to infer&lt;/em&gt; becomes &lt;em&gt;possible to enforce&lt;/em&gt;. So v0.3 ships &lt;code&gt;recall lint&lt;/code&gt; — dangling &lt;code&gt;supersedes:&lt;/code&gt; references, cycles, ambiguous successors, versioned siblings with no declared edge, closures declared only in prose. No DB, exit 1 on errors, drops into CI in one line. (It paid for itself before it shipped: writing its tests uncovered a real parser bug where a scalar &lt;code&gt;[[wikilink]]&lt;/code&gt; was read as a YAML list, producing an edge that silently never resolved.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment I still owe
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/nazar-boyko"&gt;Nazar Boyko&lt;/a&gt; asked, before Part 5 was even published, whether the gap threshold should be &lt;em&gt;relative&lt;/em&gt; — top hit versus the rest of the batch — rather than an absolute cutoff re-tuned per embedder. It's a good idea with a suspected hole (a spread-based check is blind to the single confident distractor — which is precisely the near-miss class above), but suspicion is not measurement. It's on the list, and the harness is now shaped to answer it.&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://dev.to/hannune"&gt;Tae Kim&lt;/a&gt;'s point — a typed &lt;code&gt;coverage_check&lt;/code&gt; slot so the "no real match" signal can't be silently dropped — sharpened a design rule that now runs through the whole stack: &lt;strong&gt;the retriever computes the signal, the schema carries it.&lt;/strong&gt; Computed, it's a measurement; self-reported by the model, it's a declaration. Those fail very differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm actually arguing for
&lt;/h2&gt;

&lt;p&gt;Five people I've never met read a post about a niche RAG problem and, between them, produced: a falsifiable critique of my abstention mechanism, the correct architecture for supersession, a proposed alternative worth benchmarking, and a schema-design principle. Total cost to me: publishing something concrete enough to be wrong about, and taking the replies seriously enough to run them.&lt;/p&gt;

&lt;p&gt;That's the whole model. Not "content", not reach — &lt;strong&gt;working in public as a form of peer review&lt;/strong&gt;. The asymmetry is absurdly favorable: you contribute one design and get back the failure modes it would have taken you months to hit alone. The only price is that you have to be willing to write "I was wrong, here's the measurement" — which, in a series whose thesis is &lt;em&gt;calibrated honesty&lt;/em&gt;, is not a price at all. It's the product.&lt;/p&gt;

&lt;p&gt;So: thank you Vinicius, Mateo, Nazar, Tae — and &lt;a href="https://dev.to/amin_parva_ab01ff398fd341"&gt;Amin&lt;/a&gt;, whose memory-compaction angle (keep the gist graph, not every turn) is a different axis of the same problem and deserves its own experiment. v0.3 has your fingerprints on it.&lt;/p&gt;

&lt;p&gt;If you're reading this and see the next hole — the negation-blind judge, the owed relative-threshold benchmark, a stronger entailment model, something I haven't imagined — the comment section is open and the harness is public. Evidently, it works.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt; (MIT). The full v0.3 study with every table: &lt;a href="https://github.com/GiulioDER/RE-call/blob/master/docs/ENTAILMENT_SUPERSESSION_STUDY.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/ENTAILMENT_SUPERSESSION_STUDY.md&lt;/code&gt;&lt;/a&gt;. Series index: &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-the-rag-problem-nobody-talks-about-2n0n"&gt;Part 1&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall — Part 6: The Fine-Tune That Did Nothing, and Shipping It as an MCP Server</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:07:17 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-part-6-the-fine-tune-that-did-nothing-and-shipping-it-as-an-mcp-240m</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-part-6-the-fine-tune-that-did-nothing-and-shipping-it-as-an-mcp-240m</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 6 (finale) of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-part-5-the-gap-threshold-that-didnt-transfer-86a"&gt;Part 5: the gap threshold that didn't transfer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I fine-tuned the embedder on my own domain expecting a win. I measured it properly, on held-out queries.&lt;/p&gt;

&lt;p&gt;The improvement was exactly zero. &lt;strong&gt;Δ+0.00 MRR. Δ+0.00 nDCG@10.&lt;/strong&gt; Not "small". Not "within noise". Zero.&lt;/p&gt;

&lt;p&gt;It's also the result I wanted, which takes some explaining. That's the first half of this post. The second half is how the whole engine ships, so an agent can actually use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fine-tune that did nothing
&lt;/h2&gt;

&lt;p&gt;After Part 5, the natural next question: if calibrating the &lt;em&gt;threshold&lt;/em&gt; helps, would a better &lt;em&gt;embedding&lt;/em&gt; help more? So I fine-tuned one on my domain.&lt;/p&gt;

&lt;p&gt;The setup: &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt;, &lt;code&gt;OnlineContrastiveLoss&lt;/code&gt; on query/gold-chunk pairs, trained on the 14-document corpus. The result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Test MRR&lt;/th&gt;
&lt;th&gt;Test nDCG@10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Fine-tuned&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Δ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero lift. And that is the &lt;strong&gt;correct&lt;/strong&gt; outcome, not a failed experiment.&lt;/p&gt;

&lt;p&gt;Here's the reasoning, because it's the whole point. The base model &lt;em&gt;already&lt;/em&gt; scores a perfect MRR and nDCG@10 on this corpus. There is no headroom left to recover. The only ways to manufacture a "gain" from here would be dishonest ones: evaluate on the training set (and measure memorization, not retrieval), or artificially cripple the baseline so fine-tuning has something to fix. Reporting &lt;strong&gt;+0.00&lt;/strong&gt; is the honest read, and the honest read is that off-the-shelf embeddings already saturate this corpus.&lt;/p&gt;

&lt;p&gt;But the full result is more nuanced, and more useful. On a &lt;em&gt;harder&lt;/em&gt;, &lt;strong&gt;opaque-jargon&lt;/strong&gt; corpus — one where the base model genuinely struggles to map queries to the right chunks — the same fine-tuning gave &lt;strong&gt;+0.24 MRR&lt;/strong&gt;. So the real conclusion isn't "fine-tuning doesn't work." It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fine-tuning helps when the base model doesn't already cover your vocabulary. When it does, you get nothing. Know which regime you're in &lt;em&gt;before&lt;/em&gt; you spend the GPU hours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the value of a null result. "+0.00" told me my corpus was already well-covered by a general-purpose embedder — which saved me from a fine-tuning pipeline I didn't need, and told me exactly when I &lt;em&gt;would&lt;/em&gt; need one. Teams that reflexively bury negative results throw away findings like that and re-learn them the expensive way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it: the MCP server
&lt;/h2&gt;

&lt;p&gt;An engine nobody can plug in is a paper. RE-call ships as &lt;strong&gt;&lt;code&gt;recall_mcp&lt;/code&gt;&lt;/strong&gt;, an MCP (Model Context Protocol) server over stdio, so Claude — Desktop, Code, or any MCP client — can query its own memory directly as a tool.&lt;/p&gt;

&lt;p&gt;That closes the loop with the applied series. There are three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The human-editable memory&lt;/strong&gt; — the plain markdown files you curate by hand (the two-file memory system from the Claude Code series).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retrieval engine&lt;/strong&gt; — RE-call: hybrid search on Postgres, plus the honesty guards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The MCP server&lt;/strong&gt; — how the agent reaches layer 2 at runtime, as a first-class tool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the design principle carries straight over from the applied series: &lt;strong&gt;the honesty signals ride inside the tool's structured output.&lt;/strong&gt; When the agent queries memory, the response isn't just a ranked list — every hit carries a trust &lt;em&gt;verdict&lt;/em&gt; (&lt;code&gt;ok / superseded / expired / …&lt;/code&gt;), a calibrated confidence, provenance, and validity, and the result carries &lt;code&gt;gap_warning&lt;/code&gt;, freshness, and an explicit &lt;code&gt;abstained&lt;/code&gt; + reason. The agent physically cannot get the answer without also getting "here's how much to trust it." Honesty isn't an advisory the model may ignore; it's baked into the shape of the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the series lands
&lt;/h2&gt;

&lt;p&gt;Six parts ago I claimed self-recall is a &lt;em&gt;different&lt;/em&gt; RAG problem — one about &lt;strong&gt;calibrated abstention&lt;/strong&gt;, not ranking. Everything since served that one idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture (Part 2)&lt;/strong&gt; — hybrid dense + sparse retrieval on nothing but Postgres, because agent memory doesn't need a dedicated vector DB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guards (Part 3)&lt;/strong&gt; — &lt;code&gt;gap_warning&lt;/code&gt;, freshness, and anti-re-litigation, the three things a &lt;em&gt;memory&lt;/em&gt; does that a &lt;em&gt;search index&lt;/em&gt; doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluation (Part 4)&lt;/strong&gt; — a false-confident rate measured alongside MRR, because the failure that matters is the one ranking metrics can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Findings (Parts 4–6)&lt;/strong&gt; — hybrid + rerank earns its cost only on weak embedders; a hard-coded abstention threshold is a silent landmine; and fine-tuning is regime-dependent, worth exactly nothing on a corpus your base model already covers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of those findings are negative results. That's deliberate. In a domain about knowing your own limits, the honest nulls are the most valuable thing on the table — and everything here is public, reproducible, and covered by a 150-test suite whose 49 DB-touching tests run against real Postgres, so you don't have to take my word for any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read it, run it, break it
&lt;/h2&gt;

&lt;p&gt;The whole engine is open source: &lt;strong&gt;&lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;&lt;/strong&gt; (MIT). Clone it, point it at your own corpus, and check the calibration on &lt;em&gt;your&lt;/em&gt; embedder before you trust any threshold — including mine.&lt;/p&gt;

&lt;p&gt;And if you came here from the applied track, &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt; is where all of this gets used in anger: the memory an agent reads at the start of every session, backed by the engine you just read the internals of.&lt;/p&gt;

&lt;p&gt;Thanks for reading the whole way down. If you build on it — or find where I'm wrong — I want to hear about it.&lt;/p&gt;

&lt;p&gt;And that's not a rhetorical close: the comments on Part 1 already found where I was wrong, twice, and the fixes shipped as v0.3 — an entailment stage for the near-miss a threshold can't see, and write-time supersession that beats any timestamp. The receipts, with the commenters' names on them, are in &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;the series follow-up&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The finale of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. Building agent memory, or hiring people who do? This series is the long-form version of my answer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>mcp</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall — Part 5: The Gap Threshold That Didn't Transfer</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:06:53 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-part-5-the-gap-threshold-that-didnt-transfer-86a</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-part-5-the-gap-threshold-that-didnt-transfer-86a</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-part-4-benchmarking-retrieval-and-honesty-aj3"&gt;Part 4: the eval harness&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I shipped the &lt;code&gt;gap_warning&lt;/code&gt; guard from Part 3 with a sensible-looking default: if the best cosine similarity is &lt;strong&gt;below 0.50&lt;/strong&gt;, call it a probable gap and abstain. I tested it. It worked.&lt;/p&gt;

&lt;p&gt;Then I swapped the embedding model. &lt;strong&gt;Every question that should have been refused came back as a confident answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No error. No crash. No failed test. The guard was still there, still running, still reporting that everything was fine. It had just quietly stopped being a guard.&lt;/p&gt;

&lt;p&gt;Here's the mechanism, because it generalises well past my project, and there's a decent chance it's live in your RAG system right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cosine similarity is not calibrated across models
&lt;/h2&gt;

&lt;p&gt;The gap guard fires when &lt;code&gt;best_cosine &amp;lt; threshold&lt;/code&gt;. I used 0.50. The problem is that &lt;strong&gt;0.50 means completely different things to different embedders&lt;/strong&gt;, because each model lays out its vector space with its own geometry. Same number, different meaning.&lt;/p&gt;

&lt;p&gt;The harness measured the cosine distributions for answerable vs. unanswerable queries, per embedder. Look at what "similarity" actually ranges over:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Embedder&lt;/th&gt;
&lt;th&gt;Answerable cosine&lt;/th&gt;
&lt;th&gt;Unanswerable cosine&lt;/th&gt;
&lt;th&gt;Separable?&lt;/th&gt;
&lt;th&gt;FCR @ 0.50&lt;/th&gt;
&lt;th&gt;FCR @ calibrated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hashing-64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.30 – 0.68&lt;/td&gt;
&lt;td&gt;0.35 – 0.53&lt;/td&gt;
&lt;td&gt;no — overlap&lt;/td&gt;
&lt;td&gt;0.20*&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bge-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.70 – 0.90&lt;/td&gt;
&lt;td&gt;0.51 – 0.64&lt;/td&gt;
&lt;td&gt;yes, at ~0.70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.53 – 0.70&lt;/td&gt;
&lt;td&gt;0.09 – 0.32&lt;/td&gt;
&lt;td&gt;yes, at ~0.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* &lt;em&gt;misleadingly low: with overlapping distributions the 0.50 cut also wrongly flags answerable queries, and the error-minimizing threshold simply stops firing at all. No threshold works here.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read across the rows and the whole story is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;voyage-3&lt;/code&gt;:&lt;/strong&gt; unanswerable queries score 0.09–0.32, answerable score 0.53–0.70. A threshold of 0.50 lands cleanly in the gap between them. FCR is 0.00. My default worked — &lt;strong&gt;by accident.&lt;/strong&gt; Voyage's geometry just happens to put the boundary near 0.50.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;bge-small&lt;/code&gt;:&lt;/strong&gt; unanswerable queries score &lt;strong&gt;0.51–0.64&lt;/strong&gt; — &lt;em&gt;entirely above&lt;/em&gt; my 0.50 threshold. So the guard, which only fires &lt;em&gt;below&lt;/em&gt; 0.50, never fires on them at all. Result: FCR &lt;strong&gt;1.00&lt;/strong&gt;. Every unanswerable query was confidently answered. The guard was switched off, and nothing told me. Recalibrate the threshold to ~0.70 and FCR drops to &lt;strong&gt;0.00&lt;/strong&gt; — the distributions &lt;em&gt;are&lt;/em&gt; separable, I was just cutting in the wrong place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hashing-64&lt;/code&gt;:&lt;/strong&gt; answerable (0.30–0.68) and unanswerable (0.35–0.53) &lt;strong&gt;overlap&lt;/strong&gt;. No threshold separates them, because the embedder is too weak to distinguish "relevant" from "vaguely near." The right lesson here isn't "pick a better threshold" — it's "this embedder can't support abstention at all."&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: never ship a hard-coded abstention threshold
&lt;/h2&gt;

&lt;p&gt;A magic constant that "works" is the most dangerous kind of code, because it works right up until the context shifts — and then it fails &lt;strong&gt;silently&lt;/strong&gt;, which is the worst possible failure mode for a safety guard. My 0.50 wasn't a good threshold that I'd validated. It was a coincidence that held for one embedder and collapsed the moment I changed one.&lt;/p&gt;

&lt;p&gt;The fix is cheap and non-negotiable: &lt;strong&gt;calibrate the abstention threshold per embedding model, against a small labeled set.&lt;/strong&gt; Twenty-odd queries — a handful answerable, a handful not — is enough to see where the two distributions actually sit and cut between them. Do NOT ship a constant. The writeup says it in one line: &lt;em&gt;calibrate per embedding model against a small labeled set; do not ship a hard-coded constant.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this generalizes past RAG
&lt;/h2&gt;

&lt;p&gt;Any decision that thresholds a similarity score inherits this exact trap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;semantic caching ("is this query close enough to a cached one?")&lt;/li&gt;
&lt;li&gt;near-duplicate / dedup detection&lt;/li&gt;
&lt;li&gt;"is this document relevant enough to include?"&lt;/li&gt;
&lt;li&gt;clustering cutoffs, entity-matching thresholds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every one of these, the threshold is a property of the &lt;strong&gt;(embedder, corpus) pair&lt;/strong&gt;, not a universal constant. Swap the model and your carefully-chosen number is now cutting in the wrong place — and unless you're measuring the failure explicitly, you won't know.&lt;/p&gt;

&lt;p&gt;Which is the meta-point, and the reason Part 4 mattered: &lt;strong&gt;this failure is invisible without the eval harness.&lt;/strong&gt; The system ranks well, returns plausible results, and lies on gaps. You only catch it by measuring a false-confident rate &lt;em&gt;per embedder&lt;/em&gt;. Ranking metrics would have shown me green the entire time.&lt;/p&gt;

&lt;p&gt;Two footnotes from after this was drafted. First: when I trailed this finding in Part 1, a commenter guessed the mechanism before the post existed — and proposed a &lt;em&gt;relative&lt;/em&gt; threshold (top hit versus the rest of the batch) instead of an absolute one. That experiment is still owed; my worry is that a spread-based check is blind to the single confident distractor. Second, and worse: there is a whole failure class &lt;strong&gt;no threshold can catch, by construction&lt;/strong&gt; — the near-miss that scores &lt;em&gt;high&lt;/em&gt;. That one needed a different kind of fix, and it's the subject of &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;the follow-up post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;If a better &lt;em&gt;threshold&lt;/em&gt; helps, would a better &lt;em&gt;embedding&lt;/em&gt; help more? The intuitive next move is to fine-tune the embedder on my domain. I did. The result was &lt;strong&gt;zero&lt;/strong&gt; — and Part 6 is about why that was exactly the right outcome, and the one case where fine-tuning actually did move the needle.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 5 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. The "measure the failure that matters, not the one that flatters" discipline runs through &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt; too.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall — Part 4: Benchmarking Retrieval *and* Honesty</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:06:26 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-part-4-benchmarking-retrieval-and-honesty-aj3</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-part-4-benchmarking-retrieval-and-honesty-aj3</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-part-3-teaching-rag-to-say-i-dont-know-28no"&gt;Part 3: the honesty guards&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A standard RAG benchmark would have handed my retriever a perfect score on a day it was confidently answering questions it had no data for.&lt;/p&gt;

&lt;p&gt;That's not a bug in my system. It's a gap in what those benchmarks ask. Every one of them asks the same thing: &lt;em&gt;when there was an answer, did you rank it first?&lt;/em&gt; None of them ask the question that decides whether agent memory is safe: &lt;em&gt;when there was no answer, did you say so?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So RE-call ships its own harness. Here's how it works, and the first finding it produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test set: the unanswerable queries are the point
&lt;/h2&gt;

&lt;p&gt;The evaluation runs on &lt;strong&gt;14 answerable queries + 5 unanswerable queries&lt;/strong&gt; over a synthetic corpus.&lt;/p&gt;

&lt;p&gt;Those 5 unanswerable queries are the whole reason the harness exists. They're questions the corpus genuinely cannot answer, where the &lt;em&gt;correct&lt;/em&gt; behavior is to abstain — to fire &lt;code&gt;gap_warning&lt;/code&gt;, not to confidently return the nearest memo. Standard retrieval benchmarks are built entirely from answerable queries; they have no way to score "did it correctly say nothing?" This harness is built around that case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two families of metrics
&lt;/h2&gt;

&lt;p&gt;Because there are two jobs — rank well when there's an answer, abstain when there isn't — there are two families of metrics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking quality&lt;/strong&gt; (for the answerable queries):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;precision@k, recall@k&lt;/li&gt;
&lt;li&gt;MRR (mean reciprocal rank)&lt;/li&gt;
&lt;li&gt;nDCG@10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Guard quality&lt;/strong&gt; (for the unanswerable queries):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False-confident rate (FCR)&lt;/strong&gt; — the fraction of unanswerable queries that the guard &lt;em&gt;failed to flag&lt;/em&gt;. High FCR means the system confidently answered questions it should have abstained on. This is the honesty metric, and it's the one almost nobody reports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why you need both is the crux: &lt;strong&gt;a system can post excellent MRR and terrible FCR.&lt;/strong&gt; It ranks beautifully whenever an answer exists, and lies confidently whenever one doesn't. If you only look at ranking metrics — as most RAG evals do — that failure is completely invisible. FCR is what drags it into the light.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ablation: every embedder × every fusion stage
&lt;/h2&gt;

&lt;p&gt;The harness runs the full matrix: each &lt;strong&gt;embedder&lt;/strong&gt; (&lt;code&gt;HashingEmbedder&lt;/code&gt;, &lt;code&gt;bge-small&lt;/code&gt;, &lt;code&gt;voyage-3&lt;/code&gt;) crossed with each &lt;strong&gt;fusion configuration&lt;/strong&gt; (dense only → hybrid → hybrid + rerank). That's what lets you answer "which component actually earns its cost?" instead of cargo-culting a reranker into every pipeline.&lt;/p&gt;

&lt;p&gt;And it runs against the real thing: &lt;strong&gt;49 integration tests on a live &lt;code&gt;pgvector&lt;/code&gt; container&lt;/strong&gt; (of a 150-test suite), in CI, no mock database. The benchmark exercises the actual retrieval path, not a stand-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: hybrid + rerank helps most exactly where you'd expect — and nowhere else
&lt;/h2&gt;

&lt;p&gt;Here's the ablation on the &lt;strong&gt;weak&lt;/strong&gt; (&lt;code&gt;hashing&lt;/code&gt;) embedder — quality climbs monotonically as you add stages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;MRR&lt;/th&gt;
&lt;th&gt;nDCG@10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dense only&lt;/td&gt;
&lt;td&gt;0.63&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ sparse (hybrid)&lt;/td&gt;
&lt;td&gt;0.74&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ cross-encoder rerank&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the same pipeline on the &lt;strong&gt;strong&lt;/strong&gt; &lt;code&gt;bge-small&lt;/code&gt; embedder: dense retrieval &lt;em&gt;already&lt;/em&gt; achieves a perfect &lt;a href="mailto:nDCG@10"&gt;nDCG@10&lt;/a&gt;. Hybrid fusion and reranking add &lt;strong&gt;nothing&lt;/strong&gt; — there's no headroom left to recover.&lt;/p&gt;

&lt;p&gt;The conclusion, stated plainly in the writeup: &lt;strong&gt;hybrid + rerank buys the most on weaker embedders or harder corpora; on an easy corpus with a strong embedder it's redundant.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a genuinely useful engineering result, because the reflex in RAG is to stack a reranker onto everything. This says: don't pay for stages your embedder has already made unnecessary. Measure first. A cross-encoder rerank on every query is real latency and real cost — and on a strong embedder over a well-covered corpus, you're buying zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rigor is the point
&lt;/h2&gt;

&lt;p&gt;None of these numbers come from an in-memory toy. They come from the same harness that runs in CI against real Postgres — with a dependency audit — every commit. The reason to trust the &lt;em&gt;findings&lt;/em&gt; is that the &lt;em&gt;measurement&lt;/em&gt; is reproducible. That's the whole pitch of this track: measure honestly, including the parts that make your work look less impressive.&lt;/p&gt;

&lt;p&gt;Which is a good segue, because the same ablation surfaced something that made a chunk of my &lt;code&gt;gap_warning&lt;/code&gt; design look &lt;strong&gt;worthless&lt;/strong&gt; on certain embedders. I did not see it coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 5 is the finding I keep leading with: I shipped a sensible-looking abstention threshold, switched embedders, and watched &lt;strong&gt;every query that should have been refused&lt;/strong&gt; sail through as a confident answer. Why a hard-coded similarity threshold is a landmine — and what to do instead.&lt;/p&gt;

&lt;p&gt;(The harness itself has kept growing since this was drafted — it now also scores declared-supersession versus timestamps, and a held-out "near-miss" challenge set that no threshold can catch by construction. Both came out of reader comments, and both are covered in &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;the follow-up post&lt;/a&gt;.)&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 4 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. The eval-first discipline here is the same one behind &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>testing</category>
      <category>programming</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall — Part 3: Teaching RAG to Say \"I Don't Know\</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:06:01 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-part-3-teaching-rag-to-say-i-dont-know-28no</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-part-3-teaching-rag-to-say-i-dont-know-28no</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-part-2-hybrid-rag-on-nothing-but-postgres-2pge"&gt;Part 2: hybrid retrieval on Postgres&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Ask your agent &lt;em&gt;"have we tried this filter on this market before?"&lt;/em&gt; when the honest answer is &lt;em&gt;never&lt;/em&gt;. A ranking retriever hands back the three closest memos anyway — something about a different filter, on a different market — and the agent, looking at three confident results, concludes: yes, we've looked at this.&lt;/p&gt;

&lt;p&gt;It just made a decision on a hallucination. Nothing in the stack noticed. No error was raised, because from the retriever's point of view nothing went wrong: you asked for the nearest neighbours and it gave you the nearest neighbours.&lt;/p&gt;

&lt;p&gt;Everything in Part 2 made retrieval &lt;em&gt;good&lt;/em&gt;. Good ranking makes this failure &lt;strong&gt;worse&lt;/strong&gt;, not better — it returns confident noise faster. This post is about making retrieval &lt;em&gt;honest&lt;/em&gt;, which for agent memory is the part that actually decides whether you can trust it.&lt;/p&gt;

&lt;p&gt;So RE-call wraps retrieval in &lt;strong&gt;honesty guards&lt;/strong&gt; — this post covers the original three, each answering a question ranking metrics never ask. (The current repo has grown that table to six, and the growth story is &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;its own post&lt;/a&gt;: two of the new guards exist because readers of Part 1 pointed at exactly the weaknesses you're about to see me describe. I'll flag those spots as we go.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 1: &lt;code&gt;gap_warning&lt;/code&gt; — "is the best match good enough to trust?"
&lt;/h2&gt;

&lt;p&gt;After retrieval, look at the best dense cosine similarity. If it falls &lt;strong&gt;below a calibrated threshold&lt;/strong&gt;, the top result isn't the answer — it's the least-bad noise. The system sets &lt;code&gt;gap_warning = true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important design idea: this is a &lt;strong&gt;second-order signal&lt;/strong&gt;. Retrieval still returns its ranked list; the guard &lt;em&gt;annotates&lt;/em&gt; how much to trust it. That annotation is what lets the calling agent do something other than blindly act — it can abstain, ask a human to confirm, widen the search, or explicitly note "no prior memory on this" before proceeding.&lt;/p&gt;

&lt;p&gt;That single flag is the difference between an agent that says &lt;em&gt;"we've looked at this before"&lt;/em&gt; and one that says &lt;em&gt;"I have nothing relevant on this — treat it as new."&lt;/em&gt; In a system that makes decisions, that distinction is worth more than any ranking improvement.&lt;/p&gt;

&lt;p&gt;There's one buried landmine here: &lt;strong&gt;what threshold?&lt;/strong&gt; The obvious move is to pick something like 0.50 and move on. That obvious move is quietly, dangerously wrong — and it's the biggest finding in this series, so I'm giving it its own post (Part 5). For now, the load-bearing word is &lt;em&gt;calibrated&lt;/em&gt;: the threshold is fit to data, never hard-coded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 2: freshness — "is this memory still current?"
&lt;/h2&gt;

&lt;p&gt;Every memo has a timestamp. The freshness guard reports the &lt;strong&gt;age&lt;/strong&gt; of retrieved content and warns when it's stale relative to the re-index cadence (my corpus re-indexes daily, so "stale" has a concrete meaning).&lt;/p&gt;

&lt;p&gt;This one is specific to &lt;em&gt;memory&lt;/em&gt; in a way document QA rarely deals with. A documentation corpus is mostly static — last year's page is still roughly true. Agent memory is a moving target: a decision recorded in April may have been &lt;em&gt;reversed&lt;/em&gt; in June. Without a freshness signal, April-truth and June-truth are indistinguishable at retrieval time, and the agent will happily act on a superseded conclusion. Freshness lets it weight recency, or at least flag the risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest update, because this section aged:&lt;/strong&gt; freshness turned out to be the weakest guard of the three, and a commenter on Part 1 put a finger on why — supersession is a &lt;em&gt;relation between two memos&lt;/em&gt;, and no per-document timestamp can see a relation. We later measured it: even a steelmanned "trust the newest relevant hit" heuristic still hands back the stale memory &lt;strong&gt;83–100% of the time&lt;/strong&gt;, while an explicitly declared &lt;code&gt;supersedes:&lt;/code&gt; link holds at &lt;strong&gt;0.00&lt;/strong&gt;. The fix (a trust layer that binds the relation at write time) and the measurement are in &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;the follow-up post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 3: anti-re-litigation — "did we already settle this?"
&lt;/h2&gt;

&lt;p&gt;The most agent-specific guard of the three. Before the agent proposes an idea, it queries memory for &lt;strong&gt;closed decisions&lt;/strong&gt; on that topic — the "we tried X, it failed, here's why" memos — and the guard surfaces them.&lt;/p&gt;

&lt;p&gt;The failure it prevents is subtle and expensive: an agent re-proposing a dead idea because the memo that killed it three months ago didn't happen to rank in the top results for today's phrasing. Ranking-optimized retrieval is bad at this specifically, because a settled-decision memo is often &lt;em&gt;lexically&lt;/em&gt; distant from the fresh proposal even though it's the most decision-relevant document in the store.&lt;/p&gt;

&lt;p&gt;The implementation leans on structure: decision-type memos (closed hypotheses, postmortems) are typed, and a targeted retrieval path prioritizes them when the agent is in "propose" mode. Memory that can't defend its own past decisions is condemned to relive them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unifying idea
&lt;/h2&gt;

&lt;p&gt;Retrieval answers one question: &lt;em&gt;what's closest?&lt;/em&gt; The guards answer the three that actually govern whether the agent should act:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Should you trust it?&lt;/strong&gt; (&lt;code&gt;gap_warning&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it still current?&lt;/strong&gt; (freshness)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did we already decide this?&lt;/strong&gt; (anti-re-litigation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole difference between a &lt;strong&gt;search index&lt;/strong&gt; and a &lt;strong&gt;memory&lt;/strong&gt;. A search index ranks. A memory knows its own limits.&lt;/p&gt;

&lt;p&gt;(Since this was drafted, the guard table grew: trust verdicts with declared supersession, an opt-in entailment judge for the high-similarity-but-wrong case a threshold can never catch, and a write-time lint for the supersession graph. All three exist because readers argued with this post's ancestors — that story, with measurements, is &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;the follow-up&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule, or the guards are theater
&lt;/h2&gt;

&lt;p&gt;A guard only helps if its signal &lt;strong&gt;reaches the decision layer&lt;/strong&gt;. A &lt;code&gt;gap_warning&lt;/code&gt; that gets computed and then dropped before the agent sees it is worse than useless — it's false assurance that the system is careful when it isn't. So in RE-call the honesty signals ride &lt;em&gt;inside&lt;/em&gt; the retrieval result: you cannot get the answer without also getting "here's how much to trust it." (If you read the applied series, this is the same principle as making the &lt;em&gt;tool&lt;/em&gt; enforce the rule instead of trusting the prompt to.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The guards make claims: &lt;em&gt;this is a gap, this is stale.&lt;/em&gt; Claims demand measurement — and "how well does it know when it doesn't know?" is a metric that standard RAG benchmarks don't even have. Part 4 builds the eval harness that measures it, and delivers the first finding about which pipeline components actually earn their cost.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 3 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. This is the layer that makes &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;'s memory trustworthy, not just searchable.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall — Part 2: Hybrid RAG on Nothing but Postgres</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:05:37 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-part-2-hybrid-rag-on-nothing-but-postgres-2pge</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-part-2-hybrid-rag-on-nothing-but-postgres-2pge</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. &lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-the-rag-problem-nobody-talks-about-2n0n"&gt;Part 1: the self-recall problem&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Say "vector search" and the reflex is a dedicated vector database: Pinecone, Weaviate, Qdrant. I didn't install one. &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt; keeps the dense vectors, the full-text index, and the metadata you filter them by in a single Postgres.&lt;/p&gt;

&lt;p&gt;Not as a shortcut, and not out of allergy to new infrastructure. Because for agent memory a separate vector store is the wrong &lt;em&gt;shape&lt;/em&gt;, and it quietly costs you the one property this whole system depends on.&lt;/p&gt;

&lt;p&gt;Here's the argument, and the retrieval pipeline it buys you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a dedicated vector DB
&lt;/h2&gt;

&lt;p&gt;Agent memory has three properties that make Postgres the natural fit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It's already relational.&lt;/strong&gt; Memos have timestamps, source types, tags, decision status. That's structured metadata you want to filter and join on — exactly what a relational database is for. A separate vector store means keeping two systems in sync and losing transactional consistency between the vectors and the metadata.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;pgvector&lt;/code&gt; gives you real vector search &lt;em&gt;inside&lt;/em&gt; Postgres.&lt;/strong&gt; Approximate-nearest-neighbor cosine search, in the same database as your rows. And Postgres already ships full-text search. So you get &lt;strong&gt;dense and sparse retrieval in one transactional store&lt;/strong&gt; — no sync layer, no second system to operate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The scale doesn't justify the complexity.&lt;/strong&gt; My corpus is ~700 memos, about 5 MB, re-indexed daily. Even orders of magnitude larger, a read-mostly, latency-tolerant memory is nowhere near the regime where a distributed vector DB earns its operational cost. Reaching for one here is over-engineering.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One store, one source of truth, ops you already know. Now the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retrieval pipeline
&lt;/h2&gt;

&lt;p&gt;RE-call retrieves in up to four stages. The first two run in parallel; the last two refine.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Dense retrieval
&lt;/h3&gt;

&lt;p&gt;Embed the query, run a &lt;code&gt;pgvector&lt;/code&gt; cosine-similarity search, take the top-k. This is semantic matching — it finds memos that &lt;em&gt;mean&lt;/em&gt; the same thing as the query even with no shared words. Strong on concepts, weak on exact tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Sparse retrieval
&lt;/h3&gt;

&lt;p&gt;Run a Postgres full-text search (&lt;code&gt;tsvector&lt;/code&gt;/&lt;code&gt;tsquery&lt;/code&gt;) over the same corpus. This is lexical matching — it nails exact terms: a specific error code, a ticker, a piece of domain jargon, a proper noun. Strong on precision, blind to paraphrase.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fusion with RRF
&lt;/h3&gt;

&lt;p&gt;Now you have two ranked lists that disagree. You fuse them with &lt;strong&gt;Reciprocal Rank Fusion&lt;/strong&gt; (k=60):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(doc) = Σ  1 / (k + rank_in_list_i(doc))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each document's score is the sum, across both lists, of one over its rank (plus a constant &lt;code&gt;k&lt;/code&gt;). Documents that rank high in &lt;em&gt;either&lt;/em&gt; list bubble up; documents high in &lt;em&gt;both&lt;/em&gt; dominate.&lt;/p&gt;

&lt;p&gt;The reason RRF specifically: dense cosine scores and full-text scores &lt;strong&gt;aren't on the same scale&lt;/strong&gt; — you can't just add or average them without arbitrary normalization. RRF sidesteps that entirely by fusing on &lt;em&gt;rank&lt;/em&gt; instead of raw score. &lt;code&gt;k=60&lt;/code&gt; is the well-established default and it's robust; you rarely need to tune it.&lt;/p&gt;

&lt;p&gt;Why fuse at all? Because dense and sparse fail in &lt;strong&gt;opposite directions&lt;/strong&gt; — one misses exact tokens, the other misses meaning. Combining them gives you the concept-matching of embeddings &lt;em&gt;and&lt;/em&gt; the precision of keyword search. (If you read the applied series, this is the "hybrid beats either alone" lesson — here's the actual mechanism under it.)&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cross-encoder rerank (optional)
&lt;/h3&gt;

&lt;p&gt;The fused shortlist can be reordered by a &lt;strong&gt;cross-encoder&lt;/strong&gt; (&lt;code&gt;ms-marco-MiniLM&lt;/code&gt;). Unlike the bi-encoder embeddings in stage 1 — which encode query and document &lt;em&gt;separately&lt;/em&gt; and compare vectors — a cross-encoder encodes the query and a candidate &lt;em&gt;together&lt;/em&gt; and scores their relevance jointly. It's meaningfully more accurate and meaningfully slower, so you only run it on the top handful of candidates, never the whole corpus.&lt;/p&gt;

&lt;p&gt;Whether stage 4 is worth its cost turns out to depend heavily on your embedder — which is the subject of Part 4's benchmark.&lt;/p&gt;

&lt;p&gt;One property of this stage matters more than I realized when I first drafted this: the cross-encoder &lt;em&gt;reorders&lt;/em&gt;, but what it emits is still a &lt;strong&gt;score&lt;/strong&gt; you end up thresholding somewhere downstream. That distinction — a score you tune versus a decision you can trust — comes back with force later in the series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pluggable embedders
&lt;/h2&gt;

&lt;p&gt;RE-call treats the embedder as a swappable component, with three shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HashingEmbedder&lt;/code&gt;&lt;/strong&gt; — deterministic, offline, no model download. It exists so the test suite and CI can run vector retrieval with zero external dependencies. Weak, but reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastEmbed (&lt;code&gt;bge-small&lt;/code&gt;)&lt;/strong&gt; — a strong local model, no API calls. Good default for privacy or air-gapped runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voyage (&lt;code&gt;voyage-3&lt;/code&gt;)&lt;/strong&gt; — a cloud model, the strongest of the three.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pluggability isn't just tidiness. It lets you test deterministically offline, run locally for privacy, or call the cloud for maximum quality — same pipeline, different tradeoff. And it sets up the single most important finding in this series: &lt;strong&gt;the embedder you choose changes how you have to calibrate everything downstream&lt;/strong&gt; (Part 5). Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is "just Postgres" actually enough?
&lt;/h2&gt;

&lt;p&gt;Fair challenge, and I don't want to hand-wave it. The claim is backed by the harness, not vibes: of RE-call's &lt;strong&gt;150-test suite, 49 integration tests run against a real &lt;code&gt;pgvector&lt;/code&gt; container&lt;/strong&gt; — no mock database — in CI. The retrieval you just read about is exercised against the real engine on every commit.&lt;/p&gt;

&lt;p&gt;And to be honest about the boundary: when &lt;em&gt;would&lt;/em&gt; you want a dedicated vector DB? Billions of vectors, sub-10 ms p99 under heavy concurrent QPS, distributed sharding across nodes. Agent memory is none of those. It's small, read-mostly, and perfectly happy with tens-of-milliseconds retrieval. Match the tool to the regime — and this regime is Postgres-shaped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Retrieval now returns the closest memos. But "closest" is not "relevant" — the closest match to a question with no answer is still just noise wearing a high similarity score. Part 3 is about the guards that let the system tell the difference: how RE-call learns to say &lt;em&gt;"I don't know."&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 2 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. If you came from &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;, this is the retrieval layer under Part 5's semantic search.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>Retrieval-Augmented Self-Recall: The RAG Problem Nobody Talks About</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:13:07 +0000</pubDate>
      <link>https://dev.to/gde03/retrieval-augmented-self-recall-the-rag-problem-nobody-talks-about-2n0n</link>
      <guid>https://dev.to/gde03/retrieval-augmented-self-recall-the-rag-problem-nobody-talks-about-2n0n</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of Retrieval-Augmented Self-Recall — the research track behind &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;. All code is open source: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Almost every RAG tutorial you've read solves the same problem: you have a pile of documents, a user asks a question, and you retrieve the chunks that answer it. Rank the right passage to the top, stuff it in the prompt, done.&lt;/p&gt;

&lt;p&gt;There's a second kind of RAG that behaves &lt;em&gt;completely differently&lt;/em&gt;, and almost nobody writes about it: an agent retrieving from &lt;strong&gt;its own memory&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I ran into it building the operational brain behind an automated trading system. That agent accumulates memory as it works — over 700 typed markdown memos, about 5 MB, re-indexed daily. Decisions, dead ends, calibration notes, "we tried this and it failed" postmortems. When the agent starts a task, it queries that memory: &lt;em&gt;have we tested this before? what did we decide about X? is this still true?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The moment your knowledge base is the agent's own growing memory, the RAG problem &lt;strong&gt;inverts&lt;/strong&gt; — and standard retrieval quietly does the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-recall is not document QA
&lt;/h2&gt;

&lt;p&gt;In document QA, there's a load-bearing assumption you probably never think about: &lt;strong&gt;the answer is in the corpus.&lt;/strong&gt; Someone asked a question because the docs can answer it. Your whole job is ranking — surface the right chunk.&lt;/p&gt;

&lt;p&gt;In self-recall, the most important queries are exactly the ones where &lt;strong&gt;the answer isn't there.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent asks "have we tried a mean-reversion filter on this market?" If the honest answer is &lt;em&gt;no, never&lt;/em&gt;, a ranking-optimized retriever will still cheerfully return the three most cosine-similar memos — probably something about a different filter on a different market — and the agent, seeing confident results, concludes "yes, we looked at this." It just made a decision on a hallucination.&lt;/p&gt;

&lt;p&gt;The failure isn't bad ranking. The top results might be the genuinely closest memos. The failure is that &lt;strong&gt;the system had no way to say "there's nothing relevant here."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failure modes unique to agent memory
&lt;/h2&gt;

&lt;p&gt;Once you look at memory this way, three distinct failure modes show up that document-QA RAG never has to handle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hallucinating over gaps.&lt;/strong&gt; The query has no real answer in memory, but retrieval returns the nearest neighbors anyway, and their presence reads as a "yes." The system needs to &lt;em&gt;abstain&lt;/em&gt; — to flag "this is probably a gap" instead of pretending.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-litigating settled decisions.&lt;/strong&gt; The agent proposes an idea it already tried and killed three months ago, because the "we decided against this, here's why" memo didn't surface at the moment of proposing. Memory that can't defend its own past decisions is doomed to relive them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Acting on stale memory.&lt;/strong&gt; A memo that was true in April is retrieved and treated as current in July. Without a freshness signal, old truth and current truth are indistinguishable — and in a system that touches money, that's expensive.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are ranking problems. You cannot fix them by getting a better embedding model or a fancier reranker, because a better retriever just returns &lt;em&gt;more confidently wrong&lt;/em&gt; results faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe: abstention, not ranking
&lt;/h2&gt;

&lt;p&gt;Here's the thesis of this whole series:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document-QA RAG optimizes ranking. Agent-memory RAG has to optimize &lt;em&gt;calibrated abstention&lt;/em&gt; — knowing when it doesn't know.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This matters because the metrics you've been trained to care about — MRR, nDCG, precision@k — &lt;strong&gt;don't measure abstention at all.&lt;/strong&gt; They score how well you ordered the results &lt;em&gt;assuming an answer exists&lt;/em&gt;. They are silent on the case that matters most in self-recall: the query with no answer, where the correct behavior is to return nothing and say so.&lt;/p&gt;

&lt;p&gt;That's why reaching for an off-the-shelf RAG stack and pointing it at your agent's memory feels fine in a demo and rots in production. The stack is tuned for the wrong objective. It was never asked to abstain, so it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  RE-call: a reference implementation
&lt;/h2&gt;

&lt;p&gt;To work through this properly I built and open-sourced &lt;strong&gt;&lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;&lt;/strong&gt; — a retrieval engine designed around abstention from the start rather than bolted on after. The one-paragraph version, which the rest of this series unpacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage &amp;amp; retrieval:&lt;/strong&gt; PostgreSQL + pgvector as a single transactional store — dense vector search &lt;em&gt;and&lt;/em&gt; sparse full-text search in the same database, fused with Reciprocal Rank Fusion, with an optional cross-encoder reranker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three honesty guards:&lt;/strong&gt; a &lt;code&gt;gap_warning&lt;/code&gt; that fires when the best match is too weak to trust, a freshness signal that flags stale memory, and an anti-re-litigation check that surfaces closed decisions before the agent re-proposes them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest evaluation:&lt;/strong&gt; a harness that measures not just ranking quality but a &lt;strong&gt;false-confident rate&lt;/strong&gt; — how often the system fails to abstain when it should.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It ships as an MCP server, so an agent (Claude or otherwise) can query its own memory directly. That's the loop that closes back to my other series — this is the engine underneath "the memory file" and "semantic search."&lt;/p&gt;

&lt;h2&gt;
  
  
  What this series covers
&lt;/h2&gt;

&lt;p&gt;Six parts, each a standalone piece of the problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This one&lt;/strong&gt; — why self-recall is a different problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid RAG on nothing but Postgres&lt;/strong&gt; — the architecture, and why I didn't reach for a dedicated vector database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teaching RAG to say "I don't know"&lt;/strong&gt; — the three honesty guards, in detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarking retrieval &lt;em&gt;and&lt;/em&gt; honesty&lt;/strong&gt; — the eval harness, and why I measure a false-confident rate alongside MRR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gap threshold that didn't transfer&lt;/strong&gt; — the finding that a single hard-coded abstention threshold is worthless across embedding models. This one surprised me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fine-tune that did nothing, and shipping it as an MCP server&lt;/strong&gt; — an honest null result, then how the whole thing deploys.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A note on tone, because it's the point: &lt;strong&gt;this track reports what didn't work.&lt;/strong&gt; A fine-tuning experiment that produced zero lift. An abstention threshold that fell apart the moment I changed embedders. In most domains those get buried. In &lt;em&gt;this&lt;/em&gt; domain — calibration, honesty, knowing your limits — the negative results are the most useful thing I can hand you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 2 builds the retrieval core: dense plus sparse plus fusion plus reranking, all inside a single Postgres database, with pluggable embedders — and the argument for why, in 2026, you probably don't need a separate vector store to do this well.&lt;/p&gt;







&lt;p&gt;&lt;strong&gt;Update (July 2026):&lt;/strong&gt; the comments below did exactly what publishing is for. Two of them — the entailment-over-similarity argument and "supersession is a relation, not a property" — became measured experiments and shipped as &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call v0.3&lt;/a&gt;: an opt-in entailment stage for the near-miss no threshold can catch, write-time supersession that beats even a steelmanned timestamp heuristic (83–100% stale-trust → 0.00), and a supersession lint. The full follow-up, with the commenters' names on it: &lt;strong&gt;&lt;a href="https://dev.to/gde03/retrieval-augmented-self-recall-what-the-comments-taught-me-re-call-v03-42c1"&gt;What the Comments Taught Me (RE-call v0.3)&lt;/a&gt;&lt;/strong&gt;. The "three honesty guards" described above were v0.1's set — the current table has six.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 1 of Retrieval-Augmented Self-Recall. Code: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt; (Postgres + pgvector, MIT). If you came from &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-i-run-a-live-automated-trading-system-on-it-heres-the-whole-setup-478l"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;, this is the engine under Part 1's memory and Part 5's search.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>One MCP Server, Two Models: An Always-On Ops Agent That Costs $0</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:15:23 +0000</pubDate>
      <link>https://dev.to/gde03/one-mcp-server-two-models-an-always-on-ops-agent-that-costs-0-57b6</link>
      <guid>https://dev.to/gde03/one-mcp-server-two-models-an-always-on-ops-agent-that-costs-0-57b6</guid>
      <description>&lt;p&gt;&lt;em&gt;A companion to &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-4-your-first-mcp-server-give-claude-safe-hands-on-your-own-b8p"&gt;Part 4: your first MCP server&lt;/a&gt; and the &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive-299m"&gt;hardening deep dive&lt;/a&gt;. Part of Claude Code, Beyond the Prompt.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Part 4 gave Claude hands: an MCP server exposing narrow, audited tools onto my systems. It works. But it has a shape problem that took me a while to name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude is interactive and metered.&lt;/strong&gt; It acts when I'm at the keyboard, and every action costs tokens. My systems, meanwhile, run twenty-four hours a day. Something fails at 3 a.m. and there is nobody home.&lt;/p&gt;

&lt;p&gt;What I wanted was an always-on agent: watching the journal, triaging recurring errors, and able to answer "why is that service failing?" from my phone, for free.&lt;/p&gt;

&lt;p&gt;The answer turned out not to be a second Claude. It was a &lt;strong&gt;second client on the same tool server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And that's the idea worth stealing, so let me put it up front:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Once the enforcement lives in the tools, the model becomes a swappable client.&lt;/strong&gt; The fence isn't in the model, it's in the server — so you can plug a cheap, dumb local model into the &lt;em&gt;same&lt;/em&gt; fence and let it be wrong, safely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is true for &lt;strong&gt;safety&lt;/strong&gt;. It turns out not to be true for &lt;strong&gt;cost&lt;/strong&gt;, and I only found that out by measuring — after I'd already built the thing. There's a section on that below, with the numbers that killed a feature I was rather proud of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: one server, many clients
&lt;/h2&gt;

&lt;p&gt;Four pieces run on the box:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The MCP tool server.&lt;/strong&gt; The hardened boundary from Part 4: around thirty narrow tools, sandboxed, audit-logged, bearer-auth, bound to a private network only. Never exposed to the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude&lt;/strong&gt;, connected as an MCP client, for interactive work with me in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local model&lt;/strong&gt;, served by &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; on the same machine, connected as &lt;em&gt;another&lt;/em&gt; client. It never talks to the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three autonomous entry points&lt;/strong&gt; that drive the local model: a &lt;strong&gt;Telegram bot&lt;/strong&gt;, a &lt;strong&gt;watchdog&lt;/strong&gt;, and a &lt;strong&gt;nightly audit&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important part is what it is &lt;em&gt;not&lt;/em&gt;: the local model is not Claude's assistant, and Claude is not its supervisor. They're peers. Two clients, one fence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool catalogue
&lt;/h2&gt;

&lt;p&gt;Here's the actual surface, grouped by what it does. Note that every tool carries its guardrail &lt;em&gt;in the tool&lt;/em&gt;, not in a prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observation (read-only)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;The guardrail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db_query_ro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run a SQL query&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SELECT&lt;/code&gt; only via a &lt;strong&gt;read-only DB role&lt;/strong&gt;, 500-row cap, 5s statement timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read a repo file&lt;/td&gt;
&lt;td&gt;Path traversal blocked; secrets (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;*.key&lt;/code&gt;, &lt;code&gt;.ssh/&lt;/code&gt;) refused server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;list_dir_live&lt;/code&gt; / &lt;code&gt;rg_search_live&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;List / grep live files&lt;/td&gt;
&lt;td&gt;Confined to allowlisted roots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;journalctl_tail&lt;/code&gt; / &lt;code&gt;journalctl_grep&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Read a unit's journal&lt;/td&gt;
&lt;td&gt;Read-only, line-capped, output truncated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;systemctl_is_active&lt;/code&gt; / &lt;code&gt;systemctl_status&lt;/code&gt; / &lt;code&gt;systemctl_cat&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Unit liveness, status, unit file&lt;/td&gt;
&lt;td&gt;Read-only, no restart capability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;port_check&lt;/code&gt; / &lt;code&gt;health_probe_http&lt;/code&gt; / &lt;code&gt;env_presence_check&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Is it listening, is it healthy, is the env var set&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;env_presence_check&lt;/code&gt; returns &lt;strong&gt;presence only, never values&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sha256_file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checksum a deployed file&lt;/td&gt;
&lt;td&gt;Read-only; used to verify a deploy matches git&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Retrieval (this is the one people skip)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;code_search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Hybrid semantic + BM25&lt;/strong&gt; search over the indexed repo. Find code by &lt;em&gt;intent&lt;/em&gt; ("where do we filter signals"), not by guessing a grep string.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docs_search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same hybrid retrieval over the knowledge base (memory / research / plans). Returns a &lt;strong&gt;&lt;code&gt;gap_warning&lt;/code&gt;&lt;/strong&gt; when the top-3 cosine similarity is below threshold, i.e. "I probably have nothing on this, don't trust these hits."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code&gt;gap_warning&lt;/code&gt; is the single most valuable field in the whole server. It's the difference between an agent that says "we looked at this before" and one that says "I have nothing relevant, treat it as new."&lt;/p&gt;

&lt;h3&gt;
  
  
  Git and GitHub (read)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;git_log&lt;/code&gt; · &lt;code&gt;gh_issue_list&lt;/code&gt; · &lt;code&gt;gh_issue_view&lt;/code&gt; · &lt;code&gt;gh_pr_list&lt;/code&gt; · &lt;code&gt;gh_pr_view&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Gated write
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;The gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_branch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New branch&lt;/td&gt;
&lt;td&gt;Name &lt;strong&gt;must&lt;/strong&gt; match &lt;code&gt;auto/&amp;lt;agent&amp;gt;-fix-*&lt;/code&gt;. Nothing else is accepted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apply_patch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apply a unified diff and commit&lt;/td&gt;
&lt;td&gt;The gauntlet below.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;open_pr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Push the branch, open a PR&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Never auto-merges.&lt;/strong&gt; Dedups against open PRs with the same error signature. 24h rate cap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh_issue_create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File an issue for a human&lt;/td&gt;
&lt;td&gt;Title prefix enforced; body &lt;strong&gt;must&lt;/strong&gt; contain &lt;code&gt;## Evidence&lt;/code&gt; and &lt;code&gt;## Source&lt;/code&gt; sections; 24h cap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;systemctl_restart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Restart a unit&lt;/td&gt;
&lt;td&gt;Allowlist only.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy_files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ship a file&lt;/td&gt;
&lt;td&gt;The tool itself enforces backup → checksum-verify against git → restart → health-check.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;telegram_alert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page the operator&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;info&lt;/code&gt; / &lt;code&gt;warn&lt;/code&gt; / &lt;code&gt;critical&lt;/code&gt; levels only.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The design that makes it safe: different callers, different tool surfaces
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most like you to take away, because it's cheap and almost nobody does it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server exposes ~30 tools. No caller sees all of them.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Telegram bot → read-only tools.&lt;/strong&gt; When I'm asking questions from my phone, there is no path to a write. Not "the model has been told not to." There is no write tool in its list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The watchdog → read + gated write.&lt;/strong&gt; It's the only caller that can propose a patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The nightly audit → read + &lt;code&gt;gh_issue_create&lt;/code&gt;.&lt;/strong&gt; It's the only caller that can open an issue. (A watchdog running every fifteen minutes with issue-creation would be a spam machine.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude → the full set&lt;/strong&gt;, because a human is in the loop for every call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same server, four different surfaces. It's per-caller least privilege, and it's the "scope the reads, per tool" lesson from the hardening piece, applied one level up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auto-fix gauntlet
&lt;/h2&gt;

&lt;p&gt;The watchdog can open a pull request. Here's every condition it must satisfy — &lt;strong&gt;all of them, or the server rejects the call&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;≤ 20 changed lines, ≤ 1 file.&lt;/strong&gt; Anything bigger is not an auto-fix, it's a change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category must be whitelisted&lt;/strong&gt;: JSONB serialization, timezone-naive datetime, missing &lt;code&gt;None&lt;/code&gt; check, import error, connection retry/backoff. That's it. Five known, boring, mechanical bug shapes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error signature must have occurred at least twice in the last 30 days.&lt;/strong&gt; This is my favourite rule: &lt;em&gt;the agent may only fix bugs it has already seen recur.&lt;/em&gt; No novel fixes. Novelty goes to a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The target file must not be frozen.&lt;/strong&gt; The frozen list is everything that could actually hurt: the live money-path modules, central config, CI, systemd units, deploy scripts — and the agent's own source code, so it can't rewrite its own guardrails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The branch must match &lt;code&gt;auto/*-fix-*&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No renames, binaries, new files, or deletions.&lt;/strong&gt; Modifications to one existing file, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It never pushes to the main branch.&lt;/strong&gt; Branch, commit, open a PR, alert me. &lt;strong&gt;A human merges.&lt;/strong&gt; Always.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss any one and the call comes back &lt;code&gt;blocked&lt;/code&gt;, with an instruction not to retry but to escalate to a human via &lt;code&gt;telegram_alert&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The philosophy underneath: &lt;strong&gt;the local model is allowed to be wrong.&lt;/strong&gt; It can't do damage, so it doesn't need to be smart. Every proposal lands as a PR I read on my phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a dumber model is safe here: defense in depth
&lt;/h2&gt;

&lt;p&gt;A local model is worse at tool-calling than a frontier model. It &lt;em&gt;will&lt;/em&gt; hallucinate a tool name. So the enforcement is layered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-side guardrails&lt;/strong&gt; are the real wall. Every gate above is enforced in the server, not in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A client-side tool whitelist&lt;/strong&gt; sits in front of it. The tool name from the model is Unicode-normalized (NFKC, so a lookalike character can't sneak through), any namespace separator (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;) is rejected, and anything not in &lt;em&gt;that caller's&lt;/em&gt; advertised list is blocked before it ever reaches the server. This exists precisely because a weaker model hallucinating &lt;code&gt;apply_patch&lt;/code&gt; from the read-only Telegram bot is not hypothetical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database role is the wall for data.&lt;/strong&gt; A dedicated &lt;code&gt;SELECT&lt;/code&gt;-only role with a connection limit, not a string check on the SQL. (The &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive-299m"&gt;hardening deep dive&lt;/a&gt; is entirely about why.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets are blocked server-side&lt;/strong&gt;, so no tool can read them even if asked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every call is audit-logged&lt;/strong&gt; with a hash of its arguments, latency, and result kind. Two models, one ledger.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice: none of this depends on the model being good. That's the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does, day to day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;3 a.m., from bed.&lt;/strong&gt; Something looks off. I open Telegram and type &lt;em&gt;"why is the X collector failing?"&lt;/em&gt; The local model calls &lt;code&gt;journalctl_tail&lt;/code&gt;, then &lt;code&gt;systemctl_is_active&lt;/code&gt;, then &lt;code&gt;code_search&lt;/code&gt; to find the relevant function, loops through up to six tool iterations, and comes back with a three-sentence diagnosis and the log excerpt it based it on. Cost: zero. Laptop: closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The watchdog.&lt;/strong&gt; A periodic vigilance pass. It finds an exception that has now fired three times in a month, recognizes the shape (JSONB serialization), confirms the file isn't frozen, builds an eight-line diff, opens &lt;code&gt;auto/…-fix-jsonb-…&lt;/code&gt;, files a PR labelled for autofix, and sends me a &lt;code&gt;warn&lt;/code&gt;. I read the diff on my phone and merge it, or close it. It has never once been able to merge itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nightly audit.&lt;/strong&gt; Anything novel and recurring worth a human's attention becomes a GitHub issue with a mandatory &lt;code&gt;## Evidence&lt;/code&gt; section (log excerpt, unit, occurrence count) and &lt;code&gt;## Source&lt;/code&gt; section (file path, unit, lineage). The strict format is what makes them triage-able instead of noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude, during the day.&lt;/strong&gt; Same tools, different mode: interactive, with me in the loop. Semantic search before grepping, DB queries instead of pasted psql output, deploys through the deploy tool that enforces its own checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token dividend: the server reads, the model doesn't
&lt;/h2&gt;

&lt;p&gt;Here's the thing I underestimated when I built this. I reach for these tools constantly, and &lt;em&gt;safety is not the reason&lt;/em&gt;. &lt;strong&gt;Economy is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every tool is a &lt;strong&gt;compression function&lt;/strong&gt;. It does the expensive reading on the box and hands back only the answer. Compare what a model would otherwise have to do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Without the server&lt;/th&gt;
&lt;th&gt;With the tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH in, &lt;code&gt;cat&lt;/code&gt; a file, paste 800 lines into the chat&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;read_file&lt;/code&gt; returns it, path-checked and byte-capped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grep the repo, then read ten files to find one function&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;code_search&lt;/code&gt; returns the handful of relevant chunks: roughly 300 tokens instead of 6,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paste a 5,000-line journal dump&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;journalctl_tail&lt;/code&gt; returns a capped, filtered tail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run &lt;code&gt;psql&lt;/code&gt;, paste the whole result table&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;db_query_ro&lt;/code&gt; returns at most 500 structured rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-read the knowledge base to check a past decision&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docs_search&lt;/code&gt; returns the matching memo, plus a &lt;code&gt;gap_warning&lt;/code&gt; if there &lt;em&gt;is&lt;/em&gt; no matching memo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cat&lt;/code&gt; the env file to check a variable is set&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;env_presence_check&lt;/code&gt; returns &lt;code&gt;present: true&lt;/code&gt;. Not the value. Not the file.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a deployed file to check it matches git&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sha256_file&lt;/code&gt; returns a hash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look hard at those last two. The question was &lt;em&gt;"is it set?"&lt;/em&gt; and &lt;em&gt;"does it match?"&lt;/em&gt;, so the tool returns a boolean and a hash. &lt;strong&gt;Zero tokens spent on content the model never needed.&lt;/strong&gt; The answer, not the material.&lt;/p&gt;

&lt;p&gt;This is the same lever as &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-7-how-i-cut-claude-codes-token-bill-and-made-it-faster-3hoj"&gt;"use a subagent for heavy reading"&lt;/a&gt; from the token piece: spend the 50,000 tokens of scanning somewhere that isn't your main context, and bring back the 500-token conclusion. An MCP tool is exactly that, made permanent — the fan-out happens server-side, every single call, without you having to think about it.&lt;/p&gt;

&lt;p&gt;And here's the part I find genuinely satisfying: &lt;strong&gt;the guardrail and the token budget turn out to be the same line of code.&lt;/strong&gt; The row cap that stops a runaway query is the row cap that stops a 40,000-token result. The path confinement that blocks &lt;code&gt;.ssh/&lt;/code&gt; is what stops the model wandering into directories it never needed. The truncation on a log tail is both a safety valve and a cost control.&lt;/p&gt;

&lt;p&gt;That isn't a coincidence. &lt;em&gt;"Return only what was asked for"&lt;/em&gt; is simultaneously the security principle and the efficiency principle. Harden the tool properly and you get the cheaper bill for free — or, put the other way round, if your tool returns a wall of text, it's both expensive &lt;strong&gt;and&lt;/strong&gt; insecure, and you should fix it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I tried to go one step further, and measured why it failed
&lt;/h2&gt;

&lt;p&gt;If every tool is a compression function, the obvious next move is to compress the &lt;em&gt;whole investigation&lt;/em&gt;. Give the frontier model one more tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;ask_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grounded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude hands over a bulk, mechanical question — &lt;em&gt;"scan six hours of journal for unit X, give me the distinct error signatures and their counts"&lt;/em&gt; — the local model does the five tool calls and the reading &lt;strong&gt;in its own free context&lt;/strong&gt;, and Claude gets back three sentences instead of twenty thousand tokens of logs. Model-to-model delegation. The ultimate version of the token dividend.&lt;/p&gt;

&lt;p&gt;I built it. It works. &lt;strong&gt;It ships disabled.&lt;/strong&gt; Here's why, because the &lt;em&gt;why&lt;/em&gt; is worth more than the feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  The design decision I'd defend anywhere
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ask_local&lt;/code&gt; returns &lt;strong&gt;evidence, not just an answer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is non-negotiable, and it's the same lesson as the dead collector. An unverifiable lossy compressor is a &lt;strong&gt;hallucination-laundering machine&lt;/strong&gt;: to the caller, a wrong summary and a right one look &lt;em&gt;identical&lt;/em&gt;, so it acts on either — silently. That's the worst failure mode there is.&lt;/p&gt;

&lt;p&gt;So every answer ships with the tool calls it was built from, and &lt;code&gt;grounded&lt;/code&gt; flips to &lt;strong&gt;false&lt;/strong&gt;, with a loud warning, when no tool returned usable data — i.e. the model answered from its weights rather than from my systems. Tool errors and blocked calls never count as grounding. The caller checks &lt;code&gt;grounded&lt;/code&gt; before acting, or it has learned nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The numbers that killed it
&lt;/h3&gt;

&lt;p&gt;I ran it against the real local model on the real box: twelve cores, already sitting at load 12–17 because the live systems are using them. &lt;code&gt;llama3.2:3b&lt;/code&gt;, warm, &lt;strong&gt;the identical call&lt;/strong&gt;, varying only how many tools I advertised:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tools advertised&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;180.3 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.6 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;252.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at that column. It isn't monotonic. One tool is &lt;em&gt;slower&lt;/em&gt; than three. That makes no sense if latency tracks the work — and that's the finding:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Latency here is not a function of the work. It's a function of how much CPU happens to be free at that instant.&lt;/strong&gt; The same call takes 29 seconds or 250 seconds. It isn't &lt;em&gt;slow&lt;/em&gt;, it's &lt;strong&gt;unpredictable&lt;/strong&gt; — and you cannot budget a synchronous tool against a 10× swing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While it runs, inference also takes &lt;strong&gt;about seven of the twelve cores&lt;/strong&gt; away from the live systems. Which detonates the line I'd have written without measuring: the local model is not the cheap tenant on a shared production box. &lt;strong&gt;It's the most expensive one.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The model trap, free of charge
&lt;/h3&gt;

&lt;p&gt;Two other models, same box, same call:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&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;
&lt;code&gt;qwen3:8b&lt;/code&gt; (as shipped)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;timeout, &amp;gt;600 s&lt;/strong&gt; — never returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;qwen3:8b&lt;/code&gt;, thinking disabled&lt;/td&gt;
&lt;td&gt;233 s — still far too slow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llama3.2:3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;29–250 s — the only viable family, still erratic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That first row is worth your time. &lt;strong&gt;Qwen3 ships with thinking mode on.&lt;/strong&gt; Combine thinking with tool-calling on a CPU and it doesn't get slow, it &lt;em&gt;never comes back&lt;/em&gt; — I gave it ten minutes and it was still going. Turning thinking off took it from "&amp;gt;600 s" to 233 s, which means the thinking alone was costing &lt;strong&gt;more than six minutes per turn&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're putting a Qwen3-family model behind tools on CPU, disable thinking or you will sit there wondering why nothing ever returns.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I actually learned
&lt;/h3&gt;

&lt;p&gt;The mechanism was fine. The 3B called the right tools. The in-process dispatch worked. And the guardrails did exactly their job: &lt;strong&gt;every single failed run came back &lt;code&gt;grounded: false&lt;/code&gt;, &lt;code&gt;truncated: true&lt;/code&gt;, with a loud warning.&lt;/strong&gt; Not once did it hand back a confident invention. It failed &lt;em&gt;noisily&lt;/em&gt;, which is the only acceptable way to fail.&lt;/p&gt;

&lt;p&gt;The hardware was the problem, and it produces a sharper rule than the one I started with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A local model works beautifully as an &lt;strong&gt;asynchronous background worker&lt;/strong&gt; — a watchdog that runs every fifteen minutes, a bot you're willing to wait for. Unpredictable latency simply doesn't matter when nobody is blocked.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; work as a &lt;strong&gt;synchronous delegate&lt;/strong&gt; for your interactive model. There is no budget you can set when the same call takes 29 s or 250 s.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And notice: my Telegram bot and my watchdog are &lt;em&gt;already&lt;/em&gt; asynchronous. I didn't design that from insight — the hardware had decided it for me long before anyone measured. The measurement just told me &lt;em&gt;why&lt;/em&gt; I'd been right by accident, and stopped me from being wrong on purpose.&lt;/p&gt;

&lt;p&gt;So the corrected version of the thesis at the top of this post:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The tools make the model safe to swap. The hardware decides whether it's worth swapping.&lt;/strong&gt; Those are two different axes, and I had quietly collapsed them into one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;p&gt;The order that worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the tool server first, not the agent.&lt;/strong&gt; FastMCP (or the SDK of your choice) over HTTP, bearer-auth, bound to localhost or a private network. Never exposed publicly. Start with read-only tools; you'll be surprised how far that gets you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a read-only database role&lt;/strong&gt; and connect through it. Grants, not string checks. Add a row cap and a statement timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the audit table&lt;/strong&gt; on day one, not later. Log the tool, a hash of the args, latency, result kind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire Claude to it&lt;/strong&gt; as an MCP client. Use it for a week. The tools you actually reach for are the ones worth hardening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then add the local model.&lt;/strong&gt; Ollama, one &lt;code&gt;pull&lt;/code&gt;, and a small tool-calling loop: send the tool schemas, parse &lt;code&gt;tool_calls&lt;/code&gt;, dispatch to the MCP server, feed results back, cap the iterations (six is plenty).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each entry point its own tool list.&lt;/strong&gt; Bot: read-only. Watchdog: read + gated write. This costs you ten lines and buys the whole safety story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;systemd units&lt;/strong&gt; for the server, the bot, and a timer for the watchdog.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  About the model
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Size the model to your hardware — and then measure it. Do not trust the model card, and do not trust me.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two things I had wrong until I ran the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bigger isn't just slower; it can be infinite.&lt;/strong&gt; A thinking-enabled &lt;code&gt;qwen3:8b&lt;/code&gt; never returned a single tool-calling turn on CPU. Not "took a while" — never came back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A weaker model is &lt;em&gt;safe&lt;/em&gt;, but that doesn't make it &lt;em&gt;usable&lt;/em&gt;.&lt;/strong&gt; The guardrails don't live in the model, so a dumber one costs you nothing in risk. It can still cost you everything in latency. On CPU, tool-calling speed is the binding constraint, not intelligence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practically: a 3B-class model is the only thing I'd put behind a &lt;em&gt;synchronous&lt;/em&gt; tool on CPU, and even that is erratic under contention. A 7B–8B is fine for a &lt;em&gt;background&lt;/em&gt; worker where nobody is waiting. A 30B-class model wants ~18–20 GB and minutes per turn. With a GPU, none of this is a conversation.&lt;/p&gt;

&lt;p&gt;And if the model can think: &lt;strong&gt;turn thinking off.&lt;/strong&gt; Ollama makes swapping a model a one-line change. It does not make the consequences a one-line change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pros, and the honest cons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always-on, and it never bills a token.&lt;/strong&gt; It watches while I don't. Read the cons before you call it free, though — it bills in CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; The local model never sends a byte off the machine. For anyone whose logs or schema can't leave the building, this is the whole ballgame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A smaller token bill, and better answers.&lt;/strong&gt; Every call returns the &lt;em&gt;answer&lt;/em&gt; instead of the raw material, so context stays dense. Cheaper and less wrong, from the same design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model becomes safe to swap.&lt;/strong&gt; Because the fence is in the tools, I can change the local model without touching the safety posture. Note the word: &lt;em&gt;safe&lt;/em&gt;, not &lt;em&gt;free&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One audit trail, two models.&lt;/strong&gt; Whoever acted, it's in the same ledger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It makes you harden the tools properly&lt;/strong&gt;, because now something less careful than Claude is holding them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons, honestly&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On a shared box, inference is the most expensive tenant.&lt;/strong&gt; Not merely slow — &lt;em&gt;unpredictable&lt;/em&gt;. The identical call took 29 s or 250 s depending purely on what the live systems left free, while eating ~7 of 12 cores. Fine for an async watchdog. Fatal for anything synchronous. Measure your own box before believing anyone, including me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model can be a trap.&lt;/strong&gt; A thinking-enabled model behind tools on CPU may never return at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local tool-calling is unreliable.&lt;/strong&gt; Expect hallucinated tool names and malformed arguments. Plan for it — that's what the client-side gate is for — rather than being surprised by it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not smart enough for judgment.&lt;/strong&gt; It triages, diagnoses, and proposes. It does not decide. Every gate, every promotion, every merge is still mine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More moving parts.&lt;/strong&gt; Four units instead of one. Worth it only if you genuinely have something running 24/7 that you'd like watched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;Part 4's rule was: &lt;em&gt;the tool enforces the rule, not the prompt.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The corollary is why this setup works at all: &lt;strong&gt;if the enforcement is in the tools, the model is just a client.&lt;/strong&gt; Interchangeable. You can hand a cheap, imperfect model the same hands and let it be wrong, because being wrong can't cost you anything.&lt;/p&gt;

&lt;p&gt;But I very nearly shipped a second, sloppier corollary — &lt;em&gt;therefore the model is free&lt;/em&gt; — and the only reason I didn't is that I measured it. It isn't free. On a box that's already working, it's the hungriest process on the machine, and its latency is a coin flip.&lt;/p&gt;

&lt;p&gt;So the honest pair, which is what this post is really about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The tools decide whether a model is safe to swap. The hardware decides whether it's worth swapping.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Get the first one right and you can afford to experiment freely. Get the second one wrong and you'll ship something that works perfectly in every respect except the one that matters.&lt;/p&gt;

&lt;p&gt;That's how a 24/7 ops agent stops being an infrastructure project and becomes a &lt;code&gt;systemd&lt;/code&gt; unit — and how a clever delegation tool becomes a config flag set to &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;A companion to &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-4-your-first-mcp-server-give-claude-safe-hands-on-your-own-b8p"&gt;Part 4&lt;/a&gt; and the &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive-299m"&gt;hardening deep dive&lt;/a&gt;, part of Claude Code, Beyond the Prompt. The retrieval layer behind &lt;code&gt;code_search&lt;/code&gt; and &lt;code&gt;docs_search&lt;/code&gt; is open source: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>ollama</category>
      <category>devops</category>
    </item>
    <item>
      <title>Clearing an off grid price bug out of Polymarket's order path</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Tue, 14 Jul 2026 20:33:36 +0000</pubDate>
      <link>https://dev.to/gde03/clearing-an-off-grid-price-bug-out-of-polymarkets-order-path-2g13</link>
      <guid>https://dev.to/gde03/clearing-an-off-grid-price-bug-out-of-polymarkets-order-path-2g13</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://polymarket.com/" rel="noopener noreferrer"&gt;Polymarket&lt;/a&gt; ships a unified Python SDK, &lt;a href="https://github.com/Polymarket/py-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;py-sdk&lt;/code&gt;&lt;/a&gt;, for building on their prediction market: constructing, pricing, signing, and submitting orders. It's currently in beta and moving fast, which is exactly where money path bugs like to hide. I've been running the SDK against live markets, so I pointed an audit pass at its order validation and signing code and found a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;Before an order is signed, the SDK validates the price against the market's &lt;strong&gt;tick size&lt;/strong&gt; (the smallest allowed price increment). Two functions do this, &lt;code&gt;_resolve_price&lt;/code&gt; for limit orders and &lt;code&gt;_resolve_protected_market_price&lt;/code&gt; for market orders, and both check the wrong thing. They validate the price's &lt;strong&gt;decimal place count&lt;/strong&gt;, not whether the price is an actual multiple of the tick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;decimal_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UserInputError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price must conform to tick size &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tick_size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round_normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decimal place count equals tick grid membership only for power of ten ticks (0.1, 0.01, 0.001, 0.0001). The SDK also supports two half step ticks, &lt;strong&gt;0.005&lt;/strong&gt; and &lt;strong&gt;0.0025&lt;/strong&gt;, and for those the two measures diverge. A price like &lt;code&gt;0.007&lt;/code&gt; has three decimal places, so it passes the check, but it is not a multiple of &lt;code&gt;0.005&lt;/code&gt;. The grid is {0.005, 0.010, 0.015, ...}, and &lt;code&gt;0.007&lt;/code&gt; is not on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;polymarket._internal.actions.orders.limit&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;_resolve_price&lt;/span&gt;

&lt;span class="nf"&gt;_resolve_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.007&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.005&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# returns Decimal("0.007"), no error, even though 0.007 is off the tick grid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consequence is worse than a cosmetic slip. Polymarket orders are signed with &lt;code&gt;EIP-712&lt;/code&gt;, and the price is baked into the signature. That means the exchange cannot round an off grid price onto the grid without invalidating the signature, so it can only reject the order. The client side guard that exists specifically to prevent that wasted signing round trip does not fire, on exactly the markets where it is needed. It stays invisible on every classic market, because there decimal count and grid membership happen to agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Merged PR:&lt;/strong&gt; &lt;a href="https://github.com/GiulioDER/py-sdk/pull/1" rel="noopener noreferrer"&gt;GiulioDER/py-sdk#1&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Reported upstream:&lt;/strong&gt; &lt;a href="https://github.com/Polymarket/py-sdk/issues/162" rel="noopener noreferrer"&gt;Polymarket/py-sdk#162&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is eight lines, a grid membership check added after the existing decimal check in both validators:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;tick_size&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UserInputError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; must be a multiple of tick size &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tick_size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is purely additive. Any price that validated before is a tick multiple, so it still passes; only genuinely off grid prices are newly rejected, and those were going to be rejected by the exchange anyway, just later and less clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;I did not want to ship a "looks right to me" patch into someone else's live money path, so the fix carries its proof:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A red to green test suite.&lt;/strong&gt; New unit tests assert that off grid prices are rejected on both validators, that on grid prices (including the exact range boundaries &lt;code&gt;price == tick&lt;/code&gt; and &lt;code&gt;price == 1 - tick&lt;/code&gt;) still pass, and that the pre existing decimal place error is unchanged. Plus an end to end test that drives the real public &lt;code&gt;prepare_limit_order_draft&lt;/code&gt; path with a mocked 0.005 tick market and confirms the guard fires there too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An exhaustive correctness sweep.&lt;/strong&gt; For every supported tick, I enumerated every on grid multiple across the whole valid range and every in allowance off grid probe: about 23,000 cases, zero false rejects and zero false accepts. &lt;code&gt;Decimal % Decimal&lt;/code&gt; is exact, so there is no floating point residue to worry about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean gates.&lt;/strong&gt; &lt;code&gt;ruff format&lt;/code&gt;, &lt;code&gt;ruff check&lt;/code&gt;, and &lt;code&gt;pyright&lt;/code&gt; all pass; the full order path unit suite stays green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An adversarial review.&lt;/strong&gt; I had the change reviewed by an independent pass whose only job was to find a reason a maintainer would reject it. Its strongest counter argument, "maybe the server is meant to snap off grid prices," is exactly what &lt;code&gt;EIP-712&lt;/code&gt; signing rules out: a signed order cannot be silently repriced. That turned into the clearest line in the writeup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since the repository limits pull requests to collaborators, I merged the fix on a fork (allowed by the contest rules) and filed a full report as an upstream issue, so the maintainers have the bug, the repro, and the patch in one place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I took away:&lt;/strong&gt; a validation check should test the invariant it actually claims to enforce, not a proxy that happens to coincide with it. This one promised the price "must conform to tick size" but tested decimal place count, and those two agree on every classic market and diverge exactly on the newer half step ticks. A single assertion on a 0.005 tick would have caught it.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>showdev</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Green all the way down: a trading bot that lied to me in four different languages</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Tue, 14 Jul 2026 20:28:40 +0000</pubDate>
      <link>https://dev.to/gde03/green-all-the-way-down-a-trading-bot-that-lied-to-me-in-four-different-languages-1e1k</link>
      <guid>https://dev.to/gde03/green-all-the-way-down-a-trading-bot-that-lied-to-me-in-four-different-languages-1e1k</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's a fun way to lose confidence in every dashboard you own.&lt;/p&gt;

&lt;p&gt;I run a small fleet of automated trading bots. One of them, I'll call it &lt;strong&gt;the index bot&lt;/strong&gt; because it trades index CFDs and gold rather than FX, sat one afternoon at exactly its starting balance, zero open positions, having placed no trades for a suspiciously long time.&lt;/p&gt;

&lt;p&gt;Nothing was on fire. &lt;code&gt;systemctl&lt;/code&gt; said the service was &lt;strong&gt;active&lt;/strong&gt;. The bot's own risk circuit reported &lt;strong&gt;ok&lt;/strong&gt;. The fix for the bug it was hitting had, according to git, been &lt;strong&gt;shipped weeks ago&lt;/strong&gt;. The last deploy had said &lt;strong&gt;done&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every single one of those was a lie. Here they are in the order I peeled them off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #1: &lt;code&gt;active&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl is-active index-bot
&lt;span class="go"&gt;active
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except it wasn't running. It was stuck in a crash loop, and had been for &lt;strong&gt;three days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A stray systemd config override, left over from an unrelated experiment, had repointed the service at a minimal Python virtualenv that was missing two libraries the bot imports on startup (the Postgres driver and the market data client). So the process would launch, import its way a few lines in, hit the missing module, die, and get restarted by &lt;code&gt;Restart=always&lt;/code&gt;. Launch, die, restart. Forever.&lt;/p&gt;

&lt;p&gt;The trap: &lt;code&gt;systemctl is-active&lt;/code&gt; doesn't answer "is this service working?" It answers "does a process exist right now?" A restart loop keeps a process &lt;em&gt;existing&lt;/em&gt;. Poll it and you almost always land in the brief window between crashes, where the answer is, technically, &lt;code&gt;active&lt;/code&gt;. Three days, no alert, because the one signal anyone was watching was structurally incapable of noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a restart policy launders a dead process into one that looks healthy. Alert on &lt;code&gt;NRestarts&lt;/code&gt; climbing, and make your health check prove the process did its &lt;em&gt;work&lt;/em&gt;, wrote a heartbeat row or answered a ping, not merely that it drew breath.&lt;/p&gt;

&lt;p&gt;I fixed the venv override. The bot came up and &lt;em&gt;stayed&lt;/em&gt; up. Progress! Onto lie number two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #2: &lt;code&gt;ok&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Now that it was actually running, the journal filled with this, once a minute, forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;poll: 1 new order intent
place_order INDEX500: INVALID_REQUEST "Relative stop loss has invalid precision"
router summary: { placed: 0, errors: 1, circuit_state: 'ok' }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that last line again. It placed &lt;strong&gt;zero&lt;/strong&gt; orders. It logged &lt;strong&gt;one&lt;/strong&gt; error. And it declared its circuit state &lt;strong&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The bot has a risk circuit breaker, and it's a good one. It trips on drawdown, on stale prices, on daily loss limits. But a broker rejecting &lt;em&gt;100% of your orders&lt;/em&gt; is not a drawdown event. No money is being lost because no orders exist. So the circuit, which only ever learned to watch for &lt;em&gt;losing&lt;/em&gt; money, cheerfully reported green while the bot failed to trade at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a guard that watches one failure mode is blind to every other one. "Am I losing money?" and "am I actually able to place a trade?" are different questions, and a health signal that answers only the first will glow green through the second. A 100% rejection rate should page as loudly as a drawdown breach.&lt;/p&gt;

&lt;p&gt;So why was every order rejected? That's the actual bug, and it's a beauty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug all four green lights were hiding: the decoder and the encoder disagreed about what a price &lt;em&gt;is&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The bot talks to &lt;a href="https://spotware.com/" rel="noopener noreferrer"&gt;cTrader&lt;/a&gt;'s Open API. For a market order, cTrader won't take an absolute stop loss price; it wants the stop as an &lt;strong&gt;integer distance&lt;/strong&gt; expressed in a fixed wire scale. cTrader streams prices on a fixed scale of 100,000 integer units per price unit, and the adapter &lt;strong&gt;decoded&lt;/strong&gt; incoming prices with exactly that constant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_SPOT_PRICE_SCALE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100_000.0&lt;/span&gt;          &lt;span class="c1"&gt;# cTrader's fixed wire scale
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;_SPOT_PRICE_SCALE&lt;/span&gt;     &lt;span class="c1"&gt;# correct
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But when it came time to &lt;strong&gt;encode&lt;/strong&gt; the stop loss distance to send back, the adapter used the &lt;em&gt;symbol's display digits&lt;/em&gt; instead of the wire scale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# digits = the number of decimals the symbol quotes to
&lt;/span&gt;&lt;span class="n"&gt;sl_units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop_distance&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# this line is the bug
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what those two lines assume. The decoder says a price unit is worth &lt;code&gt;100_000&lt;/code&gt;. The encoder says it's worth &lt;code&gt;10 ** digits&lt;/code&gt;. Those are the same number &lt;strong&gt;only when &lt;code&gt;digits == 5&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A EUR/USD style FX pair quotes to &lt;strong&gt;5&lt;/strong&gt; digits, so &lt;code&gt;10**5 == 100_000&lt;/code&gt;, encoder and decoder agree, and orders sail through.&lt;/li&gt;
&lt;li&gt;An index CFD or gold quotes to &lt;strong&gt;2&lt;/strong&gt; digits, so &lt;code&gt;10**2 == 100&lt;/code&gt;, the stop is encoded &lt;strong&gt;1000× too small&lt;/strong&gt;, and cTrader rejects it as &lt;em&gt;"invalid precision."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bug had existed the whole time. It was invisible on every account that traded 5 digit FX, &lt;strong&gt;where the wrong answer happens to equal the right one.&lt;/strong&gt; The lone account trading 2 digit instruments, an index CFD and gold, was the only place the two scales diverged, and it failed 100% of the time.&lt;/p&gt;

&lt;p&gt;The fix is a single line: encode on the same scale you decode with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop_distance_to_units&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_SPOT_PRICE_SCALE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if you decode with one constant and encode with another, you've planted a bug that hides everywhere the two constants coincide and detonates the first time they don't. Encode and decode through the &lt;em&gt;same&lt;/em&gt; function, and write the unit test for the case where they'd diverge. Here, a single assertion on a 2 digit symbol would have caught it years earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lies #3 and #4: &lt;code&gt;shipped&lt;/code&gt;, and &lt;code&gt;done&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the part that made me laugh, then wince.&lt;/p&gt;

&lt;p&gt;That fix? It was already in &lt;code&gt;master&lt;/code&gt;. Past me had already found this, written &lt;code&gt;stop_distance_to_units&lt;/code&gt;, and left a comment right next to it warning not to use the display digits, because they encode 2 digit instruments far too small. Per git, the bug was solved and shipped.&lt;/p&gt;

&lt;p&gt;The live box was running the old code anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt;  &amp;lt;live&amp;gt;/ctrader_adapter.py                       &lt;span class="c"&gt;# c5bfc46f…&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git show origin/master:…/ctrader_adapter.py | &lt;span class="nb"&gt;sha256sum&lt;/span&gt;    &lt;span class="c"&gt;# f2948754…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different bytes. The box had never received the fix. The most consistent explanation is grim: that file lives in a hardened, &lt;strong&gt;immutable&lt;/strong&gt; set, locked with &lt;code&gt;chattr +i&lt;/code&gt; so nothing can quietly tamper with the money path, and a plain &lt;code&gt;scp&lt;/code&gt; over an immutable file &lt;em&gt;fails&lt;/em&gt;. A blocked copy and a successful one look identical unless someone checks. And nobody was: whatever deployed this never compared the bytes on the box to the bytes in git, because if it had, &lt;em&gt;this exact drift is what it would have caught.&lt;/em&gt; The deploy said &lt;code&gt;done&lt;/code&gt;. The bytes said otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a deploy that doesn't verify its end state is a wish, not a deployment. Copy the file, then assert &lt;code&gt;sha256(target) == sha256(source)&lt;/code&gt;, or you will one day discover, as I did, that "deployed weeks ago" and "running in production" are unrelated facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: liveness is not health
&lt;/h2&gt;

&lt;p&gt;Line them up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The signal&lt;/th&gt;
&lt;th&gt;What it claimed&lt;/th&gt;
&lt;th&gt;What was true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;systemctl is-active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stuck in a crash loop for 3 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;circuit breaker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;circuit_state: ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejecting 100% of orders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git log&lt;/code&gt; on master&lt;/td&gt;
&lt;td&gt;fix shipped&lt;/td&gt;
&lt;td&gt;fix never on the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the deploy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bytes never changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four independent "success" signals, four different lies, and each was &lt;em&gt;technically correct&lt;/em&gt; about the narrow thing it measured. The unit did exist. The account wasn't drawing down. The commit was on master. The deploy command did run. None of them measured &lt;strong&gt;whether the system was doing its job&lt;/strong&gt;, and the gap between "it's running" and "it's working" is where this whole afternoon lived.&lt;/p&gt;

&lt;p&gt;Five things I'm taking into every system I touch after this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alert on restart counts, not just liveness.&lt;/strong&gt; &lt;code&gt;Restart=always&lt;/code&gt; turns a corpse into a green light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks must assert work happened,&lt;/strong&gt; a heartbeat, a filled order, a written row, never just "a process is up."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every guard is blind outside its one failure mode.&lt;/strong&gt; Enumerate the failure modes; "can't lose money" is not the same as "can trade."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode and decode through the same constant,&lt;/strong&gt; and test the input where two scales would disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deploy must verify its end state.&lt;/strong&gt; Hash the target. &lt;code&gt;scp&lt;/code&gt; onto an immutable file fails silently, and silence reads as success.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How this was actually caught
&lt;/h2&gt;

&lt;p&gt;Full disclosure, because it's the most interesting part: I didn't find most of this by staring at logs. I pointed an AI agent (Claude) at the live journal and had it diff the running bytes against git, part of an audit pipeline I've been building whose entire premise is &lt;em&gt;don't trust a green dashboard.&lt;/em&gt; A human who "knows the system is fine" skims right past &lt;code&gt;circuit_state: 'ok'&lt;/code&gt;. A skeptical reader with no such prior stops on it and asks the dumb, correct question: &lt;em&gt;ok according to whom?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That question, asked four times, at four layers, was the whole fix. The bugs were boring: a stray config file, a guard that watched too little, a units mismatch, an unverified copy. No clever algorithm, no exotic race. And that's exactly why they'd survived for weeks: nothing about them looked wrong, because everything that was supposed to tell me they were wrong was busy reporting green.&lt;/p&gt;

&lt;p&gt;The least glamorous bugs are the ones that quietly cost the most. Smash them by distrusting your own status lights.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If your infra has ever said &lt;code&gt;active&lt;/code&gt; while doing absolutely nothing, I'd love to hear your version in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Fine-Tuning and RAG: What a Dozen Failed Experiments Taught Me</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Tue, 14 Jul 2026 12:29:58 +0000</pubDate>
      <link>https://dev.to/gde03/fine-tuning-and-rag-what-a-dozen-failed-experiments-taught-me-4apb</link>
      <guid>https://dev.to/gde03/fine-tuning-and-rag-what-a-dozen-failed-experiments-taught-me-4apb</guid>
      <description>&lt;p&gt;The internet has a strong opinion about fine-tuning versus RAG, and most of it comes from people who never ran the experiment.&lt;/p&gt;

&lt;p&gt;I ran about a dozen — fine-tuning LLMs, fine-tuning embedders, six flavors of RAG — on a real system that makes forward-looking predictions against noisy financial outcomes, with the kind of statistical rigor that kills your favorite result: walk-forward splits, permutation tests, multiple-comparison correction, pre-registered kill-gates. Most of the experiments died. The autopsies turned out to be far more useful than any hot take, because they all point at the same rule.&lt;/p&gt;

&lt;p&gt;Here's that rule up front, so the rest of the post has somewhere to land:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Fine-tuning and RAG both operate on the &lt;em&gt;input&lt;/em&gt; side — the vocabulary the model knows, and the context it can see. Neither changes whether the thing you're trying to predict is actually predictable from your data.&lt;/strong&gt; Where the task is "know the words / find the fact / recall the knowledge," both work. Where the task is "predict a noisy outcome," neither manufactures a signal that isn't there — they just hand you more convincing ways to fool yourself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let me show you the bodies.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on why my results differ from the blog consensus
&lt;/h2&gt;

&lt;p&gt;Almost every experiment below looked &lt;em&gt;good&lt;/em&gt; on a naive train/test split. The failures only showed up under discipline: walk-forward with embargo, permutation tests for significance, Holm/BH-FDR correction because I was testing many cells, an SPY-counterfactual to check I wasn't just capturing market beta, and a binding calibration gate that a result had to clear no matter how good the P&amp;amp;L looked.&lt;/p&gt;

&lt;p&gt;That gap is the whole story. If you evaluate fine-tuning or RAG on a single random split and read the headline metric, you will "discover" edges that evaporate the moment you test them honestly. The technique is not the hard part. The evaluation is.&lt;/p&gt;

&lt;h1&gt;
  
  
  Part 1 — Fine-tuning: four autopsies
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Bigger model, better loss, worse decisions
&lt;/h2&gt;

&lt;p&gt;I fine-tuned a 7B and a 14B open model on the same ~777 labeled examples for the same task. The 14B finished with a &lt;em&gt;better&lt;/em&gt; evaluation loss (0.97 vs 1.01). It also made &lt;em&gt;worse&lt;/em&gt; decisions on held-out data: 46.2% win rate versus the 7B's 68.4% — a 22-point gap, p=0.019.&lt;/p&gt;

&lt;p&gt;Two lessons, both expensive to learn the hard way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Generative loss does not predict downstream quality.&lt;/strong&gt; The 14B was better at the thing loss measures (reproducing tokens) and worse at the thing I cared about (being right). If you pick your model by &lt;code&gt;eval_loss&lt;/code&gt;, you will ship the worse one with confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small data punishes big models.&lt;/strong&gt; With a few hundred to a few thousand examples, extra capacity doesn't find deeper structure — it memorizes the &lt;em&gt;label generator&lt;/em&gt;, including its noise and mistakes. A smaller model's limited capacity is implicit regularization; it's forced to learn the simple, general pattern. Under ~3k examples, reach for the smaller model.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fine-tuning a classifier learns the safe answer
&lt;/h2&gt;

&lt;p&gt;Three separate attempts to fine-tune a model to make a directional call, three times the same outcome: the model converged to the safe, majority answer. It didn't learn a signal — it learned the &lt;em&gt;distribution of the labels&lt;/em&gt;, which for a noisy target means "predict the common class and stop taking risks."&lt;/p&gt;

&lt;p&gt;This is what fine-tuning does when there's no signal to find: it fits the label distribution beautifully and tells you nothing. The confident, low-loss model is not evidence the edge exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiny-transformer trap
&lt;/h2&gt;

&lt;p&gt;I tokenized order-flow into sequences and trained a ~130k-parameter transformer on a direction target. It never converged — training loss sat exactly at the &lt;code&gt;-log(0.5)&lt;/code&gt; coin-flip floor. Meanwhile a boring gradient-boosted model, on the &lt;em&gt;same target&lt;/em&gt; with 12 hand-built features, reached AUC 0.71.&lt;/p&gt;

&lt;p&gt;The signal was real and learnable; the architecture just couldn't touch it at that scale. "Use a transformer" is not a substitute for either signal or scale — a 130k-param model has nowhere near the capacity to discover from raw tokens what a GBM reads off 12 good features. Match the architecture to the data you actually have, not the one on the paper you're copying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-tuning an embedder: it depends entirely on your vocabulary
&lt;/h2&gt;

&lt;p&gt;This one has a happy ending, and it's the most useful result of all because it tells you &lt;em&gt;when&lt;/em&gt; fine-tuning pays. I domain-adapted a small embedder (&lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt;) on two corpora and measured retrieval quality (MRR, nDCG@10) on held-out queries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A "rich" corpus&lt;/strong&gt; the base model already understood: base MRR 1.00 → fine-tuned MRR 1.00. &lt;strong&gt;Δ+0.00.&lt;/strong&gt; Zero lift — and that's the &lt;em&gt;correct&lt;/em&gt; result, because there was no headroom to recover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An "opaque-jargon" corpus&lt;/strong&gt;, where concepts hid behind codenames the base model had never seen: base MRR 0.306 → fine-tuned 0.547. &lt;strong&gt;+0.24 MRR, +79% relative.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The conclusion writes itself: &lt;strong&gt;fine-tune the embedder only as much as your corpus's vocabulary diverges from what the base model already knows.&lt;/strong&gt; Well-covered domain? You get nothing. Private jargon, internal codenames, a specialist vocabulary? That's exactly where fine-tuning earns its keep. (Full controlled study, open source, in &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tuning through-line:&lt;/strong&gt; fine-tuning is a &lt;em&gt;vocabulary and behavior&lt;/em&gt; tool. It teaches words, formats, and styles the model didn't have. It does not manufacture predictive signal from a noisy target — and measured by loss instead of the downstream metric, it will actively mislead you.&lt;/p&gt;

&lt;h1&gt;
  
  
  Part 2 — RAG: the eight-times-falsified idea
&lt;/h1&gt;

&lt;h2&gt;
  
  
  "It fires, it just doesn't help"
&lt;/h2&gt;

&lt;p&gt;The seductive idea: retrieve relevant history and inject it into the classifier's prompt so it decides with more context. I tested it &lt;strong&gt;eight to nine times&lt;/strong&gt; — across two models, three embedding stacks, four retrieval targets (own history, sector peers, an outcome-supervised REPLUG-LSR adapter, and niche-conditional slicing), and three paradigms (similarity, outcome-supervised distillation, structured-feature extraction).&lt;/p&gt;

&lt;p&gt;Every single time, the sanity check passed: RAG &lt;em&gt;fired&lt;/em&gt;, changing 25–33% of the model's decisions. And every single time, those changed decisions were statistically indistinguishable from noise against the real forward outcome. ΔWR hovered around zero with confidence intervals straddling it, through round after round, under multiple-comparison correction.&lt;/p&gt;

&lt;p&gt;The mechanism of failure is worth stating precisely: &lt;strong&gt;RAG amplified the score's magnitude without knowing &lt;em&gt;when&lt;/em&gt; the retrieved past was relevant.&lt;/strong&gt; Positive historical context pushed the model more bullish — even when the current situation had reversed. It added confidence, not correctness. "It fires" and "it helps" are completely different claims, and only the first one was ever true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The apparent-alpha trap
&lt;/h2&gt;

&lt;p&gt;One RAG variant looked like a winner. A structured-feature setup posted +11%/year of apparent alpha — the kind of number that gets a strategy shipped. Its ranking AUC was &lt;strong&gt;0.486&lt;/strong&gt;. Worse than a coin.&lt;/p&gt;

&lt;p&gt;The +11% was regime exploitation, not skill. The model predicted the majority class more often, and the evaluation window happened to reward that class. A ranker with AUC below 0.5 has, by definition, no ability to tell winners from losers — so any P&amp;amp;L it produces is a property of the period, not the model.&lt;/p&gt;

&lt;p&gt;This is the most dangerous failure in the whole post, because the metric that flatters you (P&amp;amp;L) and the metric that tells the truth (AUC/calibration) point in opposite directions. &lt;strong&gt;Make calibration a binding gate, not a footnote.&lt;/strong&gt; A profitable backtest on a below-chance model is a mirage, and it will happily survive right up until it's live with real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where RAG actually earned its place
&lt;/h2&gt;

&lt;p&gt;Here's the part the "RAG is dead" crowd gets wrong: RAG didn't fail everywhere. It failed at &lt;em&gt;prediction&lt;/em&gt;. It stayed on, permanently, for everything retrieval-shaped — the searchable knowledge base, pulling relevant context a human reads, giving the system access to facts it wasn't trained on.&lt;/p&gt;

&lt;p&gt;That's the exact line the rest of my writing lives on. RAG to &lt;em&gt;find code&lt;/em&gt; by meaning (&lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-5-why-i-stopped-letting-claude-grep-semantic-code-search-1n4e"&gt;semantic code search&lt;/a&gt;) works beautifully. RAG to give an agent a working &lt;em&gt;memory&lt;/em&gt; (&lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;) works beautifully. RAG to make a hard forward-looking &lt;em&gt;call&lt;/em&gt; better failed nine times. Same technique, opposite outcomes — because they're different problems wearing the same acronym.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The RAG through-line:&lt;/strong&gt; RAG changes what the model &lt;em&gt;sees&lt;/em&gt;, not whether the target is &lt;em&gt;predictable&lt;/em&gt;. If the signal is in the data, retrieval helps you surface it. If it isn't, retrieval just gives the model more confident-looking ways to be wrong.&lt;/p&gt;

&lt;h1&gt;
  
  
  Part 3 — The rule that explains all of it
&lt;/h1&gt;

&lt;p&gt;Line the failures up and they're the same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fine-tuning operates on the model's &lt;strong&gt;vocabulary and behavior&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;RAG operates on the model's &lt;strong&gt;available context&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Neither operates on the only question that decides a prediction task: &lt;strong&gt;is the target actually a function of the inputs?&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the decision isn't "fine-tune vs RAG." It's &lt;em&gt;what kind of problem do you have?&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"The model doesn't know my words / format / domain."&lt;/strong&gt; → Fine-tune. The embedder if it's a retrieval problem (and only if your vocabulary genuinely diverges — see the RE-call result); the LLM if it's a behavior or style problem. Measure on the downstream metric, never on loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The model can't access facts / code / memory it wasn't trained on."&lt;/strong&gt; → RAG. This is retrieval's home turf and it's excellent at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The model should predict my noisy outcome better."&lt;/strong&gt; → Neither will save you. No amount of fine-tuning or retrieval creates signal that isn't in the data — and both will hand you a convincing way to believe otherwise (a lower loss, a fatter backtest, a RAG that "fires"). Go find real signal, or accept there isn't any and stop paying to re-discover that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the meta-lesson, the one that made every result above trustworthy: nearly all of them looked &lt;em&gt;good&lt;/em&gt; until the evaluation got honest. The bigger model won on loss. The dead RAG variant won on P&amp;amp;L. The naive splits won on the headline number. Walk-forward, permutation, multiple-comparison correction, and a binding calibration gate are what turned a pile of exciting-but-fake wins into a dozen reliable *no*s and two real *yes*es.&lt;/p&gt;

&lt;p&gt;The techniques are easy. Knowing whether they worked is the entire job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The retrieval side of all this — how to build RAG that actually works, and the full controlled study on when fine-tuning an embedder pays — is open source: &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. The applied side — RAG for code search and agent memory in day-to-day Claude Code — is my series &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-5-why-i-stopped-letting-claude-grep-semantic-code-search-1n4e"&gt;Claude Code, Beyond the Prompt&lt;/a&gt;. If you're about to fine-tune or bolt on RAG, run the honest evaluation first — it's cheaper than the mirage.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>rag</category>
      <category>programming</category>
    </item>
    <item>
      <title>Claude Code, Beyond the Prompt — Part 7: How I Cut Claude Code's Token Bill (and Made It Faster)</title>
      <dc:creator>Giulio D'Erme</dc:creator>
      <pubDate>Tue, 14 Jul 2026 12:21:42 +0000</pubDate>
      <link>https://dev.to/gde03/claude-code-beyond-the-prompt-part-7-how-i-cut-claude-codes-token-bill-and-made-it-faster-3hoj</link>
      <guid>https://dev.to/gde03/claude-code-beyond-the-prompt-part-7-how-i-cut-claude-codes-token-bill-and-made-it-faster-3hoj</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 7 (finale) of Claude Code, Beyond the Prompt — patterns from running a live automated trading system on Claude Code. &lt;a href="https://dev.to/gde03/claude-code-beyond-the-prompt-part-6-github-as-claudes-task-queue-issues-prs-the-5cjj"&gt;Part 6: GitHub as Claude's task queue&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every token Claude reads costs you twice: once in money, once in time. A bloated context isn't just a bigger bill — it's a slower response, every single turn.&lt;/p&gt;

&lt;p&gt;When people want to fix this, they reach for the wrong lever: &lt;em&gt;switch to a cheaper model.&lt;/em&gt; But the model isn't where the waste is. The waste is in making Claude &lt;strong&gt;read things it doesn't need to&lt;/strong&gt; and &lt;strong&gt;redo things it shouldn't have to.&lt;/strong&gt; And it turns out the six pieces from this series — built for other reasons — are, almost by accident, a token-reduction system.&lt;/p&gt;

&lt;p&gt;Here's how the savings actually work, and how to measure your own. This is the payoff article, so it's concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core insight: tokens are the currency of both cost &lt;em&gt;and&lt;/em&gt; speed
&lt;/h2&gt;

&lt;p&gt;This is the mental model that changes how you work: &lt;strong&gt;tokens are the shared currency of your bill and your latency.&lt;/strong&gt; Anything that reduces what Claude has to read or regenerate wins on &lt;em&gt;both&lt;/em&gt; axes at once. You're not trading cost against speed — you're buying both with the same coin.&lt;/p&gt;

&lt;p&gt;So the optimization target isn't "cheaper model." It's &lt;strong&gt;information density&lt;/strong&gt;: maximum relevant context, minimum noise, in every turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tokens actually leak
&lt;/h2&gt;

&lt;p&gt;Watch a naive session and the waste is obvious once you know where to look. Each leak maps to a piece we already built:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The leak&lt;/th&gt;
&lt;th&gt;The fix&lt;/th&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Re-explaining your whole project every session&lt;/td&gt;
&lt;td&gt;Claude reads a small memory file instead&lt;/td&gt;
&lt;td&gt;Part 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redoing work because it acted on stale state&lt;/td&gt;
&lt;td&gt;Ground first; no wasted work on wrong assumptions&lt;/td&gt;
&lt;td&gt;Part 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-typing long procedural prompts&lt;/td&gt;
&lt;td&gt;One command invocation instead of a paragraph&lt;/td&gt;
&lt;td&gt;Part 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pasting giant logs and command dumps&lt;/td&gt;
&lt;td&gt;MCP tools return small, structured results&lt;/td&gt;
&lt;td&gt;Part 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading ten whole files to find one function&lt;/td&gt;
&lt;td&gt;Semantic search returns the relevant chunk&lt;/td&gt;
&lt;td&gt;Part 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-deriving history and "what changed"&lt;/td&gt;
&lt;td&gt;GitHub holds the record; Claude reads only what it needs&lt;/td&gt;
&lt;td&gt;Part 6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest two are almost always the first and the fifth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-explaining context (Part 1):&lt;/strong&gt; without a memory file, you spend the opening of every session re-establishing your stack, conventions, and current state — hundreds of tokens of preamble before any work happens. With one, that context is a compact file Claude reads once. Multiply by every session, forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading whole files (Part 5):&lt;/strong&gt; this is the giant. Grepping-and-reading to locate code routinely pulls ~6,000 tokens across five files to find the 30 lines that matter. A good semantic lookup returns ~300 tokens — the right chunk. That's roughly a &lt;strong&gt;20× reduction on a single lookup&lt;/strong&gt;, and lookups happen constantly. (Illustrative, not a lab measurement — but the order of magnitude is real, and you'll see it yourself the first time you compare.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Three platform levers most people miss
&lt;/h2&gt;

&lt;p&gt;Beyond the six pieces, Claude Code gives you three more levers that are pure token savings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Prompt caching.&lt;/strong&gt; The stable prefix of your context gets cached — so a &lt;em&gt;stable, skimmable&lt;/em&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; stays cached across turns and is cheap and fast to reuse. This is a concrete, dollars-and-milliseconds reason the Part 1 split pays off: the &lt;strong&gt;stable&lt;/strong&gt; file caches cleanly; the &lt;strong&gt;dynamic&lt;/strong&gt; file is kept small. Churn your always-loaded context and you keep busting the cache and paying full freight. Stability isn't just tidy — it's cached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Lazy / deferred tool loading.&lt;/strong&gt; Every tool you load carries a schema that sits in context. Load two hundred tools eagerly and you've spent thousands of tokens before saying a word. Claude Code can defer tool schemas and load them on demand, so a session only pays for the tools it actually uses. Skills work the same way (Part 3): only the short description is always loaded; the body loads when relevant. Progressive disclosure is a token strategy, not just an organizing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Subagents for heavy reading.&lt;/strong&gt; When a task needs Claude to read &lt;em&gt;a lot&lt;/em&gt; — search across dozens of files, sweep a big directory — spin up a subagent to do the bulk reading in &lt;em&gt;its own&lt;/em&gt; context and return only the distilled conclusion. The 50,000 tokens of spelunking happen off to the side; your main thread receives the 500-token answer. For big fan-out work this is one of the largest single savings available, and it keeps your main context clean for the actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point is that they compound
&lt;/h2&gt;

&lt;p&gt;Here's why this is a finale and not a footnote: these don't add, they &lt;strong&gt;multiply&lt;/strong&gt;, because each attacks a different part of the session.&lt;/p&gt;

&lt;p&gt;A naive session looks like: &lt;em&gt;re-explain everything&lt;/em&gt; + &lt;em&gt;grep-read ten files&lt;/em&gt; + &lt;em&gt;paste a wall of logs&lt;/em&gt; + &lt;em&gt;redo work that assumed stale state&lt;/em&gt;. Stack the fixes and that same session becomes: &lt;em&gt;read a compact memory file&lt;/em&gt; + &lt;em&gt;one semantic lookup&lt;/em&gt; + &lt;em&gt;structured tool results&lt;/em&gt; + &lt;em&gt;grounded, no-rework execution&lt;/em&gt;. Every phase got denser. The totals aren't close.&lt;/p&gt;

&lt;p&gt;I won't hand you a tidy "I cut costs 80%" number, because it would be dishonest — your codebase, your tasks, and your model choice all move it. What I'll tell you plainly: the direction is large and consistent, and the two biggest wins are &lt;strong&gt;memory&lt;/strong&gt; (stop re-explaining) and &lt;strong&gt;semantic search&lt;/strong&gt; (stop reading whole files). Start there; they're most of the gain for least of the effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own
&lt;/h2&gt;

&lt;p&gt;Don't take my word for any of it — instrument it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code can show your token and cost usage. Check it before and after adopting these patterns.&lt;/li&gt;
&lt;li&gt;Watch &lt;strong&gt;two&lt;/strong&gt; things specifically: tokens-per-session, and &lt;em&gt;how often Claude reads whole files versus targeted chunks.&lt;/em&gt; The second is the leading indicator — when it drops, the bill follows.&lt;/li&gt;
&lt;li&gt;Notice the latency, not just the invoice. Denser context means faster turns, and on a long working day that's the part you actually feel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're optimizing information density. Treat context as a budget and spend it on signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this article: &lt;strong&gt;stop asking "how do I prompt better" and start asking "what is Claude reading that it doesn't need to, and what is it redoing that it shouldn't have to?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every token you remove is money saved and latency cut. That reframe — context as a scarce budget spent on signal — is what turns a big monthly bill into a small one and a sluggish assistant into a fast one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the series lands
&lt;/h2&gt;

&lt;p&gt;Seven parts ago I made a claim: getting dramatically more out of Claude Code has almost nothing to do with writing better prompts. I hope the case is made now.&lt;/p&gt;

&lt;p&gt;The leverage was never in the prompt. It was in the scaffolding around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt; so it never forgets your project (Part 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rituals&lt;/strong&gt; so it's grounded in what's true before it acts (Part 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commands&lt;/strong&gt; so your workflows and judgment are captured once (Part 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP tools&lt;/strong&gt; so it can act on your systems inside a fence you built (Part 4).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic search&lt;/strong&gt; so it finds by meaning, not by guessing (Part 5).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; so there's a shared, persistent source of truth (Part 6).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token sense&lt;/strong&gt; so all of it runs cheap and fast (Part 7).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it required a bigger model. Every piece is something you can build incrementally, starting with a ten-minute memory file this afternoon. Do them in order and each one pays for itself before you build the next. Together they turn Claude Code from a chat box into the operational layer for real work — which is exactly what it's been for me, running a live system on it every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going deeper
&lt;/h2&gt;

&lt;p&gt;The one piece I kept pointing &lt;em&gt;away&lt;/em&gt; from — the retrieval machinery under semantic search and memory — has its own home: my open-source &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;. It digs into giving an agent a memory that genuinely works — the architecture, the evaluation harness, and the honest findings, including the experiments that &lt;em&gt;failed&lt;/em&gt; — with a full writeup in &lt;code&gt;docs/WRITEUP.md&lt;/code&gt;. If the RAG parts of this series were your favorite, that's where to go next.&lt;/p&gt;

&lt;p&gt;And if the &lt;em&gt;evidence&lt;/em&gt; is what hooks you: I ran about a dozen fine-tuning and RAG experiments on a real prediction system — walk-forward splits, permutation tests, pre-registered kill-gates — and most of them died. The autopsies are worth more than any hot take: why a bigger model with a &lt;em&gt;better&lt;/em&gt; loss made &lt;em&gt;worse&lt;/em&gt; decisions, why RAG-in-the-prompt failed eight straight times, and the one rule that explains all of it. It's all here: &lt;strong&gt;&lt;a href="https://dev.to/gde03/fine-tuning-and-rag-what-a-dozen-failed-experiments-taught-me-4apb"&gt;Fine-Tuning and RAG: What a Dozen Failed Experiments Taught Me&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Thanks for reading the whole way. If you build even one of these, tell me how it goes — I read every reply.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The finale of Claude Code, Beyond the Prompt. Building something similar, or hiring people who do? The deeper agent-memory research is open source — &lt;a href="https://github.com/GiulioDER/RE-call" rel="noopener noreferrer"&gt;RE-call&lt;/a&gt;, linked above. Follow for the next series.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
