<?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: Whatsonyourmind</title>
    <description>The latest articles on DEV Community by Whatsonyourmind (@whatsonyourmind).</description>
    <link>https://dev.to/whatsonyourmind</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%2F3853543%2F7a1097de-0d5b-4ff6-9f15-8c33dcd87d8b.png</url>
      <title>DEV Community: Whatsonyourmind</title>
      <link>https://dev.to/whatsonyourmind</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/whatsonyourmind"/>
    <language>en</language>
    <item>
      <title>Conformal intervals under a log transform: the blow-up isn't a back-transform bug</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Mon, 06 Jul 2026 00:16:30 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/conformal-intervals-under-a-log-transform-the-blow-up-isnt-a-back-transform-bug-460k</link>
      <guid>https://dev.to/whatsonyourmind/conformal-intervals-under-a-log-transform-the-blow-up-isnt-a-back-transform-bug-460k</guid>
      <description>&lt;p&gt;Someone log-transforms their target, fits a model, wraps it in conformal prediction, inverse-transforms the intervals back with &lt;code&gt;expm1&lt;/code&gt;, and the upper bound comes out at &lt;strong&gt;350x the point forecast&lt;/strong&gt;. The natural reaction is "the back-transform is broken." I ran into exactly this framing on a real bug report recently, and the interesting part is that the back-transform is &lt;em&gt;correct&lt;/em&gt; — the interval is doing precisely what it should. What looks like a bug is two honest effects stacking. Here's the mental model, with a runnable check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;You have skewed, positive data (demand, counts, prices). The standard move is to fit on &lt;code&gt;log1p(y)&lt;/code&gt; and report on the original scale with &lt;code&gt;expm1&lt;/code&gt;. You also want distribution-free intervals, so you add split conformal prediction on top. The conformal machinery runs in the model's scale — here, log space — and hands you interval endpoints &lt;code&gt;lo&lt;/code&gt; and &lt;code&gt;hi&lt;/code&gt; in log space. You &lt;code&gt;expm1&lt;/code&gt; those two columns and get your original-scale interval.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;hi&lt;/code&gt; explodes. &lt;code&gt;point ≈ 400&lt;/code&gt;, &lt;code&gt;lo ≈ 200&lt;/code&gt;, &lt;code&gt;hi ≈ 145,000&lt;/code&gt;. Surely you transformed something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one fact that resolves it
&lt;/h2&gt;

&lt;p&gt;Coverage is invariant under a monotone transform.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;g&lt;/code&gt; is strictly increasing (and &lt;code&gt;expm1&lt;/code&gt; is), then for any interval &lt;code&gt;[lo, hi]&lt;/code&gt; in log space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;y ∈ [lo, hi]   ⟺   g(y) ∈ [g(lo), g(hi)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two events are &lt;em&gt;the same event&lt;/em&gt;. So if your log-space interval covers the truth 90% of the time, the &lt;code&gt;expm1&lt;/code&gt;-transformed interval &lt;code&gt;[expm1(lo), expm1(hi)]&lt;/code&gt; covers the (back-transformed) truth 90% of the time too — exactly, not approximately. The back-transform adds zero error.&lt;/p&gt;

&lt;p&gt;The crucial detail: you transform the &lt;strong&gt;endpoints&lt;/strong&gt;, not the scores. &lt;code&gt;expm1(lo)&lt;/code&gt; and &lt;code&gt;expm1(hi)&lt;/code&gt;, never &lt;code&gt;expm1(score)&lt;/code&gt; added to a point forecast. Transforming an additive score through a nonlinear map is what actually breaks — but returning endpoint columns and mapping those is the correct operation, and it's usually what libraries already hand you.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why is &lt;code&gt;hi&lt;/code&gt; enormous?
&lt;/h2&gt;

&lt;p&gt;Two things, both honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lognormal skew under a convex map.&lt;/strong&gt; &lt;code&gt;expm1&lt;/code&gt; is convex, so a roughly symmetric interval in log space is &lt;em&gt;supposed&lt;/em&gt; to become very asymmetric and wide on the top in the original scale. A right-skewed quantity has a genuinely long upper tail. Some of that 350x is simply correct.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;An inflated log-space score, then exponentiated.&lt;/strong&gt; Conformal width is set by an upper quantile of the calibration residuals. On a short, badly-specified, or thin calibration set, that quantile is large &lt;em&gt;and&lt;/em&gt; unstable — and &lt;code&gt;exp&lt;/code&gt; of a large number is a very large number. This is small-sample variance amplified by the transform, not miscoverage.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Show me
&lt;/h2&gt;

&lt;p&gt;Self-contained, no forecasting library needed — just the conformal mechanism on lognormal data:&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;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&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;def&lt;/span&gt; &lt;span class="nf"&gt;split_conformal_logspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cal_true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cal_pred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mean_log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cal_true&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cal_pred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# |residual| in log space
&lt;/span&gt;    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="n"&gt;k&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;         &lt;span class="c1"&gt;# finite-sample rank
&lt;/span&gt;    &lt;span class="n"&gt;qhat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;mean_log&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;qhat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mean_log&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;qhat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qhat&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&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;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;span class="n"&gt;cal&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;test&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qhat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_conformal_logspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cov_log&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# log space
&lt;/span&gt;&lt;span class="n"&gt;cov_orig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# after expm1
&lt;/span&gt;
&lt;span class="nf"&gt;print&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;log-space coverage : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cov_log&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&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;orig-space coverage: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cov_orig&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&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;identical          : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cov_log&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;cov_orig&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&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;hi/point ratio     : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&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;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;log-space coverage &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.8999&lt;/span&gt;
&lt;span class="na"&gt;orig-space coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.8999&lt;/span&gt;
&lt;span class="na"&gt;identical          &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;True&lt;/span&gt;
&lt;span class="na"&gt;hi/point ratio     &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.3x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Coverage is &lt;em&gt;bit-for-bit identical&lt;/em&gt; across scales. Now shrink and corrupt the calibration set the way a misspecified model on a short series would — few effective residuals, inflated variance:&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;for&lt;/span&gt; &lt;span class="n"&gt;n_cal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_cal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_cal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_cal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# misspecification
&lt;/span&gt;    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_conformal_logspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;n=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n_cal&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; extra=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  qhat=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;5.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  hi/point=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n=2000 extra=0.0  qhat= 0.82  hi/point=    2.3x
n=  30 extra=0.0  qhat= 0.92  hi/point=    2.5x
n=  30 extra=1.5  qhat= 2.99  hi/point=   19.9x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upper endpoint balloons with &lt;code&gt;qhat&lt;/code&gt; — driven by the calibration set, not the back-transform — and coverage stays valid the whole way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep transforming endpoints, not scores.&lt;/strong&gt; The &lt;code&gt;expm1(lo)&lt;/code&gt;, &lt;code&gt;expm1(hi)&lt;/code&gt; step is correct. Don't "fix" it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read a giant upper bound as a diagnostic.&lt;/strong&gt; It usually means your log-space score quantile is unstable: more calibration history, more windows, or a better-specified model. Inspecting intervals in the modeling scale makes this obvious.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer an internal transform when the library supports it.&lt;/strong&gt; If the transform lives inside the model (e.g. a working Box-Cox path), conformity scores are computed and back-transformed consistently and you never hand-roll &lt;code&gt;log1p&lt;/code&gt;/&lt;code&gt;expm1&lt;/code&gt; — which sidesteps the whole class of confusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect asymmetry, and treat it as honest.&lt;/strong&gt; For right-skewed targets a wide, one-sided upper interval is the correct answer, not a rendering glitch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The through-line: a monotone transform is one of the few things conformal prediction handles for free. The coverage guarantee rides along untouched. When an interval looks absurd after &lt;code&gt;expm1&lt;/code&gt;, the transform is rarely the culprit — the calibration is where to look.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Marginal coverage is a lie of averages: the conformal diagnostics that catch it</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Sun, 05 Jul 2026 02:49:53 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/marginal-coverage-is-a-lie-of-averages-the-conformal-diagnostics-that-catch-it-496c</link>
      <guid>https://dev.to/whatsonyourmind/marginal-coverage-is-a-lie-of-averages-the-conformal-diagnostics-that-catch-it-496c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You wrapped your classifier in a conformal predictor, calibrated it for 90% coverage, checked the held-out set, and saw 90.2%. Ship it.&lt;/p&gt;

&lt;p&gt;That number is real — and it can still be hiding a model that badly under-covers exactly the cases you care about. Marginal coverage is an average, and averages launder failure. This is a different problem from &lt;a href="https://dev.to/whatsonyourmind/conformal-prediction-silently-breaks-under-drift-and-how-to-make-it-hold-466g"&gt;conformal prediction breaking under drift&lt;/a&gt;: here the exchangeability holds and the marginal guarantee is genuinely met — the method is just quietly unfair across the slices of your data. Two cheap diagnostics catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the marginal number actually promises
&lt;/h2&gt;

&lt;p&gt;Split-conformal prediction gives you a &lt;strong&gt;marginal&lt;/strong&gt; coverage guarantee: over a fresh exchangeable sample, the true label lands in the prediction set &lt;code&gt;C(x)&lt;/code&gt; at least &lt;code&gt;1 − α&lt;/code&gt; of the time. That's it. It says nothing about coverage &lt;em&gt;conditional&lt;/em&gt; on the input, the true class, or the difficulty of the example.&lt;/p&gt;

&lt;p&gt;And marginal coverage is trivially satisfiable. A predictor can hit 90% on the nose by over-covering the easy region and under-covering the hard one — the two errors net out in the average. The guarantee is honest; your reading of it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 90% predictor that fails a third of your classes
&lt;/h2&gt;

&lt;p&gt;Three classes, 100 calibration-held-out points. Suppose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Classes &lt;strong&gt;A&lt;/strong&gt; and &lt;strong&gt;B&lt;/strong&gt;: 80 points, true label in the set for 76 of them → &lt;strong&gt;95%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Class &lt;strong&gt;C&lt;/strong&gt;: 20 points, true label in the set for 14 → &lt;strong&gt;70%&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Marginal coverage = &lt;code&gt;(76 + 14) / 100 = 90%&lt;/code&gt;. Exactly on target. And class C — maybe your rare-but-critical class, the fraud case, the malignant scan — is covered 70% of the time. The headline number told you none of this.&lt;/p&gt;

&lt;p&gt;The fix is to stop averaging over the thing that matters. Report the &lt;strong&gt;worst-class coverage gap&lt;/strong&gt;:&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;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worst_class_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_classes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# in_set[i] = True iff the true label of sample i is in its prediction set
&lt;/span&gt;    &lt;span class="n"&gt;y_true&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;in_set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;per_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_classes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;per_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&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;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_class&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;per_class&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;min&lt;/code&gt; over per-class coverage turns "90% overall" into "70% on class C" — the number you'd actually want on a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure marginal coverage hides even from per-class checks: set size
&lt;/h2&gt;

&lt;p&gt;Class-conditional coverage catches &lt;em&gt;which label&lt;/em&gt; gets shortchanged. But conformal sets have a second axis that leaks coverage: &lt;strong&gt;size&lt;/strong&gt;. A method can be systematically overconfident on the inputs it thinks are easy — the ones it hands a &lt;strong&gt;singleton&lt;/strong&gt; &lt;code&gt;{ŷ}&lt;/code&gt; — and lean on big, cautious sets elsewhere to make the average whole.&lt;/p&gt;

&lt;p&gt;Angelopoulos &amp;amp; Bates call the diagnostic &lt;strong&gt;size-stratified coverage (SSC)&lt;/strong&gt;: bucket the samples by the size of their prediction set &lt;code&gt;|C(x)|&lt;/code&gt;, then check coverage &lt;em&gt;within each bucket&lt;/em&gt;. A conditionally honest method covers &lt;code&gt;≥ 1 − α&lt;/code&gt; in every size stratum. A method that under-covers its singletons — the confident-but-wrong region — shows it here and nowhere else:&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;size_stratified_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_stratum&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&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;s&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coverage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;in_set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())}&lt;/span&gt;
    &lt;span class="c1"&gt;# ignore tiny strata (noisy); report the worst of the rest
&lt;/span&gt;    &lt;span class="n"&gt;eligible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coverage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&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;min_stratum&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eligible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;eligible&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your size-1 stratum sits at 82% while everything else is at 95% and the marginal lands at 90%, you don't have a 90% predictor. You have a predictor that is wrong one time in five &lt;em&gt;exactly when it tells you it's sure&lt;/em&gt; — and a single averaged number will never say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  While you're at it: is the set even useful?
&lt;/h2&gt;

&lt;p&gt;Coverage is only half the story, because coverage is free: the set containing &lt;strong&gt;all&lt;/strong&gt; &lt;code&gt;K&lt;/code&gt; classes covers 100% of the time and tells you nothing. So pair coverage with an informativeness read — average set size, singleton rate, and a size &lt;em&gt;efficiency&lt;/em&gt; relative to the trivial all-&lt;code&gt;K&lt;/code&gt; set:&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;size_efficiency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="n"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&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;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 1 = all singletons, 0 = all-K sets
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I use: &lt;strong&gt;only credit tightness on the strata that actually pass coverage.&lt;/strong&gt; A razor-thin set that under-covers isn't efficient, it's wrong — rewarding it for being small is how you talk yourself into shipping the 82% singleton region.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;You cannot get &lt;em&gt;exact&lt;/em&gt; conditional coverage for free. Distribution-free conditional coverage is impossible in finite samples (Vovk, 2012; Barber, Candès, Ramdas &amp;amp; Tibshirani, 2021) — that's a theorem, not a tooling gap. Class-conditional coverage and SSC are &lt;strong&gt;diagnostics&lt;/strong&gt;, not guarantees: they stratify by things you can observe (label, set size) and surface where the marginal average is covering for a conditional failure. They won't certify conditional validity; they'll just stop you from shipping a number that lies by omission.&lt;/p&gt;

&lt;p&gt;I'm adding both as first-class diagnostics to &lt;a href="https://github.com/Khanz9664/TrustLens" rel="noopener noreferrer"&gt;TrustLens&lt;/a&gt; (an open-source model-reliability library), because "report the worst stratum, not just the mean" is the same discipline that makes any reliability metric trustworthy. But you don't need a library — the three functions above are the whole idea. Compute them next to your marginal number, and the next time a predictor claims 90%, you'll know whether it means it.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>ai</category>
    </item>
    <item>
      <title>A Capability Token for Agent Tool Calls: One Signed Object That Is Both the Gate and the Audit</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Fri, 03 Jul 2026 15:44:38 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/a-capability-token-for-agent-tool-calls-one-signed-object-that-is-both-the-gate-and-the-audit-2k8</link>
      <guid>https://dev.to/whatsonyourmind/a-capability-token-for-agent-tool-calls-one-signed-object-that-is-both-the-gate-and-the-audit-2k8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When an LLM agent decides to call a tool, something has to say "yes." In most codebases that "yes" is one of two things: a boolean returned from a policy check, or a row appended to an event log after the fact. Both are weak. A boolean carries no evidence — it's gone the instant the branch is taken. An event log carries no authority — it's written &lt;em&gt;after&lt;/em&gt; the executor already committed to running the tool, so it can't gate anything, and it can be edited later without anyone noticing.&lt;/p&gt;

&lt;p&gt;This piece is the sequel to my earlier one, &lt;a href="https://dev.to/whatsonyourmind/stop-trusting-the-agent-bind-tool-call-approvals-to-the-exact-call-5080"&gt;Stop trusting the agent: bind tool-call approvals to the exact call&lt;/a&gt;, where I argued you should &lt;strong&gt;bind the approval to the exact call's arguments&lt;/strong&gt; so an approval for one payload can't be reused on another. That fixed one attack. It left three others standing. Here I want to define the full object — a &lt;strong&gt;capability token&lt;/strong&gt; — and show that the same object is simultaneously (a) the thing the executor checks &lt;em&gt;before&lt;/em&gt; running the tool and (b) the audit record &lt;em&gt;after&lt;/em&gt;. Enforcement and evidence collapse into one signed value. The earlier article bound one approval to one call; this one specifies the whole token and checks it, unchanged, across three agent frameworks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CapabilityToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256(canonical(args))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caller_context_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256(agent_id | session_id | user_id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved_for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pol-2026-07-03:9f21c...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# or content-hash of the rule set
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prev_entry_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256(previous ledger entry)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1751560000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                             &lt;span class="c1"&gt;# wall-clock, still present
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ed25519(all of the above)&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;Each field exists to kill a specific failure that a boolean or a plain event log cannot catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;args_hash&lt;/code&gt; — bind to the exact arguments.&lt;/strong&gt; This is the predecessor's whole point, so briefly: an approval for &lt;code&gt;transfer(amount=10)&lt;/code&gt; must not be replayable onto &lt;code&gt;transfer(amount=10000)&lt;/code&gt;. Hash the canonicalized args into the signed token; the executor recomputes the hash from the &lt;em&gt;actual&lt;/em&gt; call and rejects on mismatch. Done. Move on.&lt;/p&gt;

&lt;p&gt;The next three are the failure classes that per-call args-binding &lt;strong&gt;alone&lt;/strong&gt; cannot catch. This is where the article advances past the last one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;caller_context_hash&lt;/code&gt; — bind to who is calling.&lt;/strong&gt; Args-binding stops payload swaps but says nothing about &lt;em&gt;context&lt;/em&gt;. A token minted for agent A in session S is still a perfectly valid signature over &lt;code&gt;transfer(amount=10)&lt;/code&gt;. Lift it into agent B's run, or a different user's session, and the args still match. Bind a hash of the caller identity (agent id, session id, user id) into the token and the executor rejects any call whose live context doesn't reproduce the hash. A token becomes non-transferable across contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;approved_for {step_index, attempt}&lt;/code&gt; — two clocks, not one.&lt;/strong&gt; Wall-clock &lt;code&gt;exp&lt;/code&gt; is necessary but not sufficient. Consider a retry queue: an approval is minted, the attempt fails, the payload sits in the queue, and a later retry picks it up and executes "fresh" — still inside its wall-clock window, args still matching, context still matching. Time-based freshness passed and the wrong thing happened. The fix is a &lt;em&gt;second clock&lt;/em&gt;: bind the approval to a point in the execution sequence — approved for step N, attempt M. An approval is for a &lt;em&gt;specific attempt&lt;/em&gt;, not for every retry that happens to reuse its payload. The executor checks both: not expired &lt;strong&gt;and&lt;/strong&gt; this is the attempt it was minted for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;policy_version&lt;/code&gt; — reconstructable authority, not a dangling pointer.&lt;/strong&gt; Suppose you log "rule R42 fired." R42 lives in a mutable policy store. Six weeks later, during an incident review, you look up R42 — and it now says something different, because someone edited the policy. Your log told you &lt;em&gt;which&lt;/em&gt; rule fired but not &lt;em&gt;what it said at decision time&lt;/em&gt;. Bind the policy version (or, better, a content-hash of the exact rule set) into the token. Now the entry reconstructs the &lt;em&gt;authority&lt;/em&gt; under which the call ran — the decision is reproducible, not merely telemetry pointing at a moving target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;prev_entry_hash&lt;/code&gt; + a periodic external anchor — chain integrity.&lt;/strong&gt; Here's the failure that individually-perfect tokens still miss: &lt;strong&gt;absence&lt;/strong&gt;. Every entry can be well-formed, correctly signed, args-bound, context-bound — and the tail can be silently &lt;em&gt;gone&lt;/em&gt;. A crash mid-write, an aggressive log rotation, or a deliberate tamper drops the last N entries, and a dropped tail is indistinguishable from "those calls never happened." You cannot tell missing from removed. So hash-chain the entries — each token carries the hash of the previous one — and periodically publish a checkpoint hash &lt;em&gt;outside the ledger's own trust domain&lt;/em&gt; (a transparency log, a second account, anything the ledger's writer doesn't control). Now a broken chain is visible, and a truncation past the last anchor is detectable. Absence of an entry becomes distinguishable from removal of an entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;code&gt;sig&lt;/code&gt; — the audit half.&lt;/strong&gt; Sign with an asymmetric key so the token is non-repudiable and verifiable by parties who can't mint tokens. This is what lets the &lt;em&gt;same object&lt;/em&gt; be evidence: anyone with the public key can check it, later, offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same token, three frameworks
&lt;/h2&gt;

&lt;p&gt;The token is the invariant. Frameworks only differ in &lt;em&gt;where&lt;/em&gt; you check it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google ADK (&lt;code&gt;adk-python&lt;/code&gt;).&lt;/strong&gt; &lt;code&gt;BaseTool&lt;/code&gt; gives you &lt;code&gt;before_tool_callback&lt;/code&gt; and &lt;code&gt;after_tool_callback&lt;/code&gt;. The before-callback is the gate; the after-callback writes the evidence — the same object.&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;before_tool_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# args_hash, caller_context, approved_for, policy, sig
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capability check failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# block the call
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;after_tool_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;append_to_ledger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;  &lt;span class="c1"&gt;# chain + anchor
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Microsoft Semantic Kernel.&lt;/strong&gt; The natural hook is an auto-function-invocation filter. &lt;code&gt;DENY&lt;/code&gt; = don't call &lt;code&gt;next()&lt;/code&gt; / return a refusal. &lt;code&gt;REDACT&lt;/code&gt; = mutate &lt;code&gt;context.arguments&lt;/code&gt; before &lt;code&gt;next()&lt;/code&gt;. The genuinely missing primitive is &lt;code&gt;REQUIRE_APPROVAL&lt;/code&gt;, and SK's shape forces its meaning: the auto-invoke loop is &lt;strong&gt;synchronous&lt;/strong&gt;, so "approval" cannot be an in-loop &lt;code&gt;await&lt;/code&gt; — that would hold the chat-completion connection open while a human decides. It has to mean &lt;strong&gt;terminate-and-resume&lt;/strong&gt;:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_auto_invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_cap_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;                                   &lt;span class="c1"&gt;# skip next(); refuse
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;REQUIRE_APPROVAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;                 &lt;span class="c1"&gt;# exit the loop; resume later
&lt;/span&gt;        &lt;span class="c1"&gt;# ...resume by re-invoking with a fresh, argument-bound token for this exact call
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;REDACT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;pydantic-ai.&lt;/strong&gt; Check the token in the tool wrapper / &lt;code&gt;RunContext&lt;/code&gt; before the body runs.&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;guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;ToolDenied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&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;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&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;wrapper&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three call sites, one object. The freshness bug (field 3), the context-lift bug (field 2), and the moving-policy bug (field 4) are caught identically in all three, because they live in the token, not the framework. These are the same problems being worked through right now in upstream discussions I've taken part in — the &lt;a href="https://github.com/google/adk-python/issues/6099" rel="noopener noreferrer"&gt;ADK decision-ledger issue&lt;/a&gt;, the &lt;a href="https://github.com/microsoft/semantic-kernel/issues/14072" rel="noopener noreferrer"&gt;Semantic Kernel auto-function-invocation approval gap&lt;/a&gt;, and a &lt;a href="https://github.com/pydantic/pydantic-ai/issues/5536" rel="noopener noreferrer"&gt;pydantic-ai proposal to replace the plain &lt;code&gt;tool_call_approved&lt;/code&gt; bool with an HMAC-bound approval token&lt;/a&gt; carrying &lt;code&gt;(run_id, tool_call_id, expiry)&lt;/code&gt; — where the recurring question is always &lt;em&gt;where&lt;/em&gt; the check belongs, once you accept that the invariant is a single bound object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence, not telemetry
&lt;/h2&gt;

&lt;p&gt;That's the whole payoff. A plain event log is telemetry: it tells you a story about the past that you have to trust the storyteller for. A decision ledger of capability tokens is &lt;strong&gt;evidence&lt;/strong&gt; — each entry is tamper-evident (signed + chained + anchored) and still &lt;em&gt;meaningful when replayed later&lt;/em&gt; (args, caller, sequence position, and the exact policy text are all bound in). You can hand it to someone who wasn't there, who can't mint tokens, weeks after the fact, and they can check it. A boolean can't do that. A log line can't do that. One signed object does both jobs.&lt;/p&gt;

&lt;p&gt;The token itself is maybe a hundred lines of your own code. The discipline is deciding it's a first-class object in your agent, not an afterthought bolted on when something has already gone wrong.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>Traces show what your agent did - a decision ledger shows what it was allowed to do</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Thu, 25 Jun 2026 12:11:20 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/traces-show-what-your-agent-did-a-decision-ledger-shows-what-it-was-allowed-to-do-18b5</link>
      <guid>https://dev.to/whatsonyourmind/traces-show-what-your-agent-did-a-decision-ledger-shows-what-it-was-allowed-to-do-18b5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agent observability has gotten good at answering &lt;strong&gt;what happened&lt;/strong&gt;: OpenTelemetry spans for each model call and tool execution, structured event logs, replayable traces. If a run misbehaves, you can reconstruct the sequence.&lt;/p&gt;

&lt;p&gt;But for anything that has to stand up to an incident review or a compliance ask, "what happened" isn't the question. The question is &lt;strong&gt;what was authorized&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Why&lt;/em&gt; was this tool selected for this step?&lt;/li&gt;
&lt;li&gt;Under &lt;em&gt;whose&lt;/em&gt; authority did the call run — agent credentials, or a specific user's?&lt;/li&gt;
&lt;li&gt;What did a guardrail &lt;em&gt;refuse&lt;/em&gt;, and on what rule?&lt;/li&gt;
&lt;li&gt;What confirmation was required, and what approval made the action permissible?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those passes through a decision point in your agent runtime — a policy callback, a confirmation gate, a per-tool auth check. But traces describe &lt;strong&gt;execution&lt;/strong&gt;; almost nothing writes down the &lt;strong&gt;authority&lt;/strong&gt;. That's the gap a decision ledger fills.&lt;/p&gt;

&lt;p&gt;Here's the part that took me a while to get right: a decision ledger that's just "more events" buys you nothing. To be &lt;em&gt;auditable&lt;/em&gt; rather than merely verbose, it has to support a verifier that can prove &lt;strong&gt;&lt;code&gt;executed == authorized&lt;/code&gt;&lt;/strong&gt; without trusting the agent's own narration. That decomposes into three layers, and each catches a failure the others can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1 — Entry conformance
&lt;/h2&gt;

&lt;p&gt;Each decision and each outcome is a well-formed, canonicalized, hash-bound record. The load-bearing field is on the &lt;em&gt;outcome&lt;/em&gt;: it must commit to the decision that authorized it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;decision_event = { decision_id, action_ref, principal, auth_mode,
                   policy_version, decision_state, args_digest, ts }

outcome_event  = { action_ref,
                   decision_digest = SHA256(JCS(decision_event)),
                   result_digest, terminal_state, ts }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;action_ref&lt;/code&gt; answers &lt;em&gt;"are these two events about the same intended action?"&lt;/em&gt; — make it content-derived (e.g. &lt;code&gt;SHA256(JCS({agent_id, action_type, scope, ts}))&lt;/code&gt;) so any verifier can recompute it from the intent alone, with no shared runtime state.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;decision_digest&lt;/code&gt; answers a &lt;em&gt;different&lt;/em&gt; question: &lt;em&gt;"did this outcome commit to the exact decision that authorized it?"&lt;/em&gt; Keep the two separate — collapsing them loses your ability to catch a &lt;strong&gt;swapped outcome&lt;/strong&gt; (a result re-attributed to the wrong decision).&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2 — Log completeness
&lt;/h2&gt;

&lt;p&gt;Layer 1 can only reason about entries that &lt;em&gt;exist&lt;/em&gt;. It cannot see an entry that was &lt;strong&gt;never written&lt;/strong&gt; — and that's the highest-stakes failure for incident response, because a tool call that bypassed the policy path (or a crash between authority-grant and ledger-write) looks like &lt;em&gt;silence&lt;/em&gt;, not a malformed row.&lt;/p&gt;

&lt;p&gt;Close it by chaining: each entry carries &lt;code&gt;prev_digest&lt;/code&gt; pointing at the prior ledger head, and each turn/session close records the current &lt;code&gt;ledger_head_digest&lt;/code&gt;. Now the ledger is an append-only chain, and a dropped entry shows up as a &lt;strong&gt;broken chain&lt;/strong&gt; — detectable without trusting the writer.&lt;/p&gt;

&lt;p&gt;This catches two things Layer 1 can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orphaned authority&lt;/strong&gt; — a decision says &lt;code&gt;allowed&lt;/code&gt;, the handler then raises or times out, and no outcome is ever written. Indistinguishable from "allowed and silently succeeded" &lt;em&gt;unless&lt;/em&gt; the chain expects exactly one terminal outcome for every &lt;code&gt;allowed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent omission&lt;/strong&gt; — an entry simply missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Concurrency gotcha.&lt;/strong&gt; If your agent runs tool calls &lt;strong&gt;in parallel&lt;/strong&gt; (most frameworks do), a naive &lt;code&gt;prev_digest&lt;/code&gt; chain &lt;em&gt;forks&lt;/em&gt;: two appends both chain to head &lt;code&gt;H&lt;/code&gt;, and a fork becomes indistinguishable from a drop. Two fixes — &lt;strong&gt;serialize the append&lt;/strong&gt; (single-writer per session: a lock or a monotonic sequence, even while the tools themselves run concurrently), or model the ledger as an explicit &lt;strong&gt;DAG&lt;/strong&gt; where each entry records a parent &lt;em&gt;set&lt;/em&gt; and the head is a Merkle root over the closed frontier. Pick one, and make sure the verifier knows which shape it's checking: a linear verifier must &lt;em&gt;reject&lt;/em&gt; forks; a DAG verifier must &lt;em&gt;accept&lt;/em&gt; shared parents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Layer 3 — Execution completeness
&lt;/h2&gt;

&lt;p&gt;The final layer ties the ledger back to the execution trace you already emit. Require a &lt;strong&gt;bijection at the action boundary&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;every executed tool span maps to exactly one &lt;code&gt;allowed&lt;/code&gt; decision and exactly one terminal outcome — and vice versa.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The trace proves execution &lt;em&gt;happened&lt;/em&gt;; the ledger proves it was &lt;em&gt;authorized&lt;/em&gt;; the bijection between them is the "&lt;strong&gt;no tool executes off-ledger&lt;/strong&gt;" invariant. It's the omission detector that Layer 1's per-entry rules structurally cannot express, because it reasons across two independent systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why three layers
&lt;/h2&gt;

&lt;p&gt;Put together, the invariant a verifier can now assert is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nothing executed unauthorized, and nothing authorized vanished.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the actual compliance property — and you cannot get it from logging alone, no matter how thorough. Per-entry conformance proves each record is well-formed and bound; the chain proves the &lt;em&gt;set&lt;/em&gt; is complete; the bijection proves the set matches reality.&lt;/p&gt;

&lt;p&gt;The deeper principle is one I keep coming back to: a step that &lt;em&gt;reasons&lt;/em&gt; can only ask you to trust it; a step that emits a &lt;strong&gt;re-checkable artifact&lt;/strong&gt; — a content hash, a solver's optimality certificate, a recomputable digest — turns "we logged it" into "anyone can re-run it and get the same answer." Move the factual, state-changing parts of an agent through deterministic tools that leave certificates, and the audit stops being a leap of faith.&lt;/p&gt;

&lt;p&gt;(That re-checkable-certificate idea is what I've been building into &lt;a href="https://github.com/Whatsonyourmind/oraclaw" rel="noopener noreferrer"&gt;OraClaw&lt;/a&gt; — deterministic decision tools that return verifiable results — but the three-layer ledger above is framework-agnostic; it's worth wiring into whatever runtime you're on.)&lt;/p&gt;

&lt;p&gt;If you're building agents that will ever face an auditor, the cheapest time to add the ledger is before you need it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Stop letting your AI agent eyeball A/B picks — wire in a real contextual bandit via MCP (free, no key)</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 24 Jun 2026 06:33:45 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/stop-letting-your-ai-agent-eyeball-ab-picks-wire-in-a-real-contextual-bandit-via-mcp-free-no-gi1</link>
      <guid>https://dev.to/whatsonyourmind/stop-letting-your-ai-agent-eyeball-ab-picks-wire-in-a-real-contextual-bandit-via-mcp-free-no-gi1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you give an LLM agent a table of A/B variants and ask "which one should we send next?", it will confidently pick the one with the highest conversion rate.&lt;/p&gt;

&lt;p&gt;That feels right. It is often wrong.&lt;/p&gt;

&lt;p&gt;The model has no concept of &lt;em&gt;sample size&lt;/em&gt;, &lt;em&gt;exploration&lt;/em&gt;, or &lt;em&gt;regret&lt;/em&gt;. It pattern-matches "biggest number = winner" and moves on. For a one-off question, fine. But inside an agent loop that picks a variant on every request — email subject lines, ad copy, model routing, recommendation ranking — that naïve pick quietly accumulates regret and starves the options it never gave a fair chance.&lt;/p&gt;

&lt;p&gt;The fix isn't a better prompt. It's to &lt;strong&gt;not ask the LLM to do the math at all.&lt;/strong&gt; Route the decision to a real bandit algorithm and let the model do what it's good at (orchestration, language) while a deterministic solver does what &lt;em&gt;it's&lt;/em&gt; good at (the optimization).&lt;/p&gt;

&lt;p&gt;This post is a copy-paste demo you can run in your terminal &lt;strong&gt;right now&lt;/strong&gt;, no signup, no API key. I'll use &lt;a href="https://www.npmjs.com/package/@oraclaw/mcp-server" rel="noopener noreferrer"&gt;OraClaw&lt;/a&gt; — a deterministic decision-intelligence MCP server — but the point stands regardless of tool: stop letting the model guess at math it can verify.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap, concretely
&lt;/h2&gt;

&lt;p&gt;Here's a realistic state mid-experiment. Three subject lines, different amounts of traffic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Pulls&lt;/th&gt;
&lt;th&gt;Rewards (conversions)&lt;/th&gt;
&lt;th&gt;Raw rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;26.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ask an LLM "which should we send next?" and you'll usually get &lt;strong&gt;B&lt;/strong&gt; — it has the best rate among the well-tested variants, and C "only has 15 samples, too noisy to trust."&lt;/p&gt;

&lt;p&gt;That reasoning sounds responsible. It's exactly backwards. With only 15 pulls, C is &lt;em&gt;under-explored&lt;/em&gt; — we don't actually know it's worse, and the cost of finding out is tiny. A bandit's whole job is to weigh that uncertainty instead of hand-waving it away.&lt;/p&gt;

&lt;p&gt;Let's get a real answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Run it yourself: the no-key REST endpoint (60 seconds)
&lt;/h2&gt;

&lt;p&gt;OraClaw exposes a free, no-auth REST endpoint. Paste this into your terminal — nothing to install, nothing to sign up for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://oraclaw-api.onrender.com/api/v1/optimize/bandit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "algorithm": "ucb1",
    "arms": [
      {"id": "variant_a", "name": "Subject line A", "pulls": 120, "totalReward": 18},
      {"id": "variant_b", "name": "Subject line B", "pulls": 80,  "totalReward": 17},
      {"id": "variant_c", "name": "Subject line C", "pulls": 15,  "totalReward": 4}
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response (this is the actual output, abbreviated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"selected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"variant_c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Subject line C"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.4633997784480877&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"algorithm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ucb1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exploitation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.2666666666666666&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exploration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.196733111781421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"regret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cumulativeRegret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;18.333333333333314&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"averageRegret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.08527131782945728&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"estimatedOptimalArm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"variant_c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalPulls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;215&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UCB1 picks &lt;strong&gt;C&lt;/strong&gt;, and the response shows &lt;em&gt;why&lt;/em&gt; in a way you can audit: a low &lt;code&gt;exploitation&lt;/code&gt; term (its observed rate is mediocre) but a high &lt;code&gt;exploration&lt;/code&gt; bonus (we've barely tested it). The sum — the upper confidence bound — is what it actually optimizes. That's the principled "give the under-sampled option a shot" reasoning the LLM only gestured at.&lt;/p&gt;

&lt;p&gt;Two things worth noticing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's deterministic.&lt;/strong&gt; Run that &lt;code&gt;curl&lt;/code&gt; again and you get the &lt;em&gt;exact same&lt;/em&gt; &lt;code&gt;score: 1.4633997784480877&lt;/code&gt;. UCB1 has no randomness; the same inputs always yield the same decision. That's the difference between a tool you can put in a CI test and a model whose answer drifts run to run. (If you &lt;em&gt;want&lt;/em&gt; stochastic exploration, swap &lt;code&gt;"algorithm": "thompson"&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No key needed.&lt;/strong&gt; The free tier is IP-rate-limited, not auth-gated. You just verified the whole thing without handing over an email.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Now wire it into your agent (the actual point)
&lt;/h2&gt;

&lt;p&gt;The REST call is the proof. The real ergonomics come from MCP — your agent calls it like any other tool, no glue code.&lt;/p&gt;

&lt;p&gt;Add the server to Claude Code (or any MCP client) in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add oraclaw &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @oraclaw/mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or drop it straight into a client config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"oraclaw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@oraclaw/mcp-server"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your agent has an &lt;code&gt;optimize_bandit&lt;/code&gt; tool. Instead of &lt;em&gt;prompting&lt;/em&gt; the model to reason about exploration, you let it call the solver and act on a verifiable result. The MCP call returns the identical payload (same &lt;code&gt;score: 1.4633997784480877&lt;/code&gt;) — the MCP server and the REST API are the same engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the best choice depends on context
&lt;/h3&gt;

&lt;p&gt;The plain bandit assumes the best arm is fixed. Often it isn't — the right model/route/variant depends on the request. That's a &lt;strong&gt;contextual&lt;/strong&gt; bandit, and it's a one-tool swap (&lt;code&gt;optimize_contextual&lt;/code&gt;, a LinUCB implementation). Feed a feature vector describing the current situation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://oraclaw-api.onrender.com/api/v1/optimize/contextual-bandit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "arms": [
      {"id": "small",   "name": "small-fast-model"},
      {"id": "mid",     "name": "mid-model"},
      {"id": "frontier","name": "frontier-model"}
    ],
    "context": [0.9, 0.2, 1.0],
    "history": [
      {"armId": "small",    "context": [0.1, 0.1, 0.0], "reward": 1.0},
      {"armId": "frontier", "context": [0.9, 0.2, 1.0], "reward": 0.95},
      {"armId": "small",    "context": [0.9, 0.2, 1.0], "reward": 0.2}
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the context vector might encode &lt;code&gt;[task_difficulty, latency_budget, needs_reasoning]&lt;/code&gt;. The model that wins on an easy, latency-sensitive task is not the one that wins on a hard reasoning task — LinUCB learns that mapping from history instead of you maintaining a brittle &lt;code&gt;if difficulty &amp;gt; 0.7&lt;/code&gt; ladder by hand. This is the honest version of "let the agent pick which model to call": don't have the LLM introspect about cost/quality tradeoffs in a prompt — give it a learner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why route it out instead of prompting harder
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verifiable, not vibes.&lt;/strong&gt; A UCB score is a number you can assert on in a test. "The model usually picks the right one" is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic where it matters.&lt;/strong&gt; Same inputs → same decision (for UCB1/LinUCB). You can pin it in CI and diff it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tokens, fast.&lt;/strong&gt; It's arithmetic, not generation — runs in single-digit-to-low-tens of milliseconds and burns zero LLM tokens. You're not paying a frontier model to compute a confidence bound it'll round wrong anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right tool for the job.&lt;/strong&gt; The LLM stays in charge of orchestration and language. The math goes to a solver built for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bandit is one of ~20 algorithms in the same server — forecasting (ARIMA / Holt-Winters), anomaly detection, linear/MIP optimization (HiGHS), Monte Carlo, PageRank/graph analysis, CMA-ES, conformal scoring. Same pattern every time: the agent describes the problem, a deterministic solver returns an answer you can check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the &lt;code&gt;curl&lt;/code&gt;&lt;/strong&gt; above — verify the deterministic output yourself, no signup:
&lt;code&gt;https://oraclaw-api.onrender.com/api/v1/health&lt;/code&gt; (lists every endpoint).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add it to your agent&lt;/strong&gt; in one line:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   claude mcp add oraclaw &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @oraclaw/mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free MCP tools need no key.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building something that calls it a lot? A free key (just an email) raises the limits:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://oraclaw-api.onrender.com/api/v1/auth/signup &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you outgrow the free tier, higher limits start at $9/mo — &lt;a href="https://oraclaw-api.onrender.com/api/v1/billing/checkout?tier=starter" rel="noopener noreferrer"&gt;direct checkout here&lt;/a&gt;. But you can do everything in this post for $0.&lt;/p&gt;

&lt;p&gt;If your agent is making decisions, make them ones you can verify. Stop asking the model to eyeball the math — route it to something that gets it provably right.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Run the demo, then tell me in the comments what your agent was eyeballing that it shouldn't have been.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your bandit's exploration floor probably violates its own floor</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 22:19:34 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/your-bandits-exploration-floor-probably-violates-its-own-floor-24j2</link>
      <guid>https://dev.to/whatsonyourmind/your-bandits-exploration-floor-probably-violates-its-own-floor-24j2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most multi-armed bandit / A-B allocation systems add a &lt;strong&gt;minimum exploration weight&lt;/strong&gt;: every arm should get at least, say, 5% of traffic, so no variant is ever fully starved and you keep collecting data on all of them. The guarantee sounds simple — &lt;code&gt;p_i &amp;gt;= f&lt;/code&gt; for every arm — and the implementation looks even simpler:&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;clip_renorm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# raise anything below the floor up to it
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# renormalize so probabilities sum to 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is wrong, and it fails silently. The renormalize step pushes the floored arms &lt;strong&gt;back below the floor&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why clip-then-renormalize breaks
&lt;/h2&gt;

&lt;p&gt;Clipping raises the small weights up to &lt;code&gt;f&lt;/code&gt;, which makes the total exceed 1. Dividing by that total then scales &lt;em&gt;everything&lt;/em&gt; down — including the arms you just clipped to &lt;code&gt;f&lt;/code&gt;. So they land below &lt;code&gt;f&lt;/code&gt; again, and the floor you advertised is not the floor you enforce.&lt;/p&gt;

&lt;p&gt;Concrete case — 4 arms, a confident winner, floor &lt;code&gt;f = 0.10&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;w   = [0.94, 0.02, 0.02, 0.02]   floor = 0.10
clip-renorm -&amp;gt; [0.7581, 0.0806, 0.0806, 0.0806]   min = 0.0806  ❌ (&amp;lt; 0.10)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three starved arms each get &lt;strong&gt;8.06%&lt;/strong&gt;, not the 10% you promised. And it isn't an edge case. Over 100,000 random peaky weight vectors (Dirichlet, α=0.3, n=4, f=0.10):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;clip-and-renormalize violated the floor 97.2% of the time&lt;/strong&gt; — worst arm seen: 7.69% against a 10% floor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whenever one arm dominates (exactly when a bandit is exploiting), the floor leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one affine map onto the simplex
&lt;/h2&gt;

&lt;p&gt;Instead of clipping, &lt;strong&gt;mix&lt;/strong&gt; the learned weights with the uniform floor. Put the weights on the simplex (&lt;code&gt;sum(w) = 1&lt;/code&gt;), then:&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;additive_simplex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&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;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each output is &lt;code&gt;f + (non-negative)&lt;/code&gt;, so &lt;code&gt;p_i &amp;gt;= f&lt;/code&gt; holds &lt;strong&gt;exactly&lt;/strong&gt;, and the total is &lt;code&gt;n*f + (1 - n*f)*1 = 1&lt;/code&gt; by construction — no renormalization needed, so nothing gets dragged back under the floor. It also preserves the &lt;em&gt;ordering&lt;/em&gt; and relative spacing of &lt;code&gt;w&lt;/code&gt; (it's affine), so you don't distort the policy you learned. Same run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;additive-simplex -&amp;gt; [0.664, 0.112, 0.112, 0.112]   min = 0.112  ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over the same 100,000 vectors it violated the floor &lt;strong&gt;0.00%&lt;/strong&gt; of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one guard you do need
&lt;/h2&gt;

&lt;p&gt;The map needs &lt;code&gt;n * f &amp;lt;= 1&lt;/code&gt; — you can't promise four arms a 30% floor each (that's 120%). Handle it explicitly instead of producing negative weights:&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;exploration_floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;floor must be non-negative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# floor is infeasible -&amp;gt; uniform
&lt;/span&gt;    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&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;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole correct primitive: a non-negativity check, an infeasible-floor fallback to uniform, and the affine mix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it actually matters
&lt;/h2&gt;

&lt;p&gt;The exploration floor isn't cosmetic. It's what bounds worst-case regret and guarantees you keep collecting data on every arm — the property a lot of bandit regret arguments lean on, and often a fairness/SLA requirement too ("no variant ever drops below X%"). A floor that's silently 7.7% instead of 10% means the guarantee you reported to stakeholders, and any bound that depends on it, doesn't hold. The bug is invisible because the output still sums to 1 and still &lt;em&gt;looks&lt;/em&gt; floored — the smallest number is just quietly too small.&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;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;viol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirichlet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;       &lt;span class="c1"&gt;# clip-renorm
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1e-12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;viol&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;print&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;clip-renorm floor violations: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;viol&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ~97%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran into this reviewing a Thompson-sampling weighting routine and proposed the additive-simplex version (plus the two guards) as a fix upstream. If your bandit or weighted-experiment layer clips-then-renormalizes to enforce a minimum, it's worth a one-line check: does the smallest probability it emits actually clear the floor?&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>statistics</category>
    </item>
    <item>
      <title>A model with R-squared near 0 can still give valid 90% prediction intervals - here's why (and the catch)</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:33:25 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/a-model-with-r-squared-near-0-can-still-give-valid-90-prediction-intervals-heres-why-and-the-31jp</link>
      <guid>https://dev.to/whatsonyourmind/a-model-with-r-squared-near-0-can-still-give-valid-90-prediction-intervals-heres-why-and-the-31jp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I recently calibrated a recovery-rate model that had only two weak features. Its point accuracy was almost nothing — R² basically zero. I expected its uncertainty estimates to be junk too. They weren't: the 90% conformal prediction intervals covered ~89% of held-out outcomes. Valid, just &lt;em&gt;wide&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That surprised me enough to nail it down, because it contradicts a belief a lot of us carry around: &lt;em&gt;"my model isn't accurate, so I can't trust its uncertainty."&lt;/em&gt; For split conformal prediction, that's backwards. Here's the precise statement, a runnable demo, and the one caveat that actually bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage is a property of the procedure, not the model
&lt;/h2&gt;

&lt;p&gt;Split conformal prediction gives a distribution-free, finite-sample &lt;strong&gt;marginal coverage guarantee&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;P( Y ∈ Ĉ(X) ) ≥ 1 − α&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and it holds for &lt;strong&gt;any&lt;/strong&gt; point model, as long as the calibration and test data are exchangeable. The model is a black box. You fit it however you like, then on a held-out &lt;em&gt;calibration&lt;/em&gt; set you take the (1−α) quantile of the absolute residuals, and that quantile becomes the half-width of your intervals.&lt;/p&gt;

&lt;p&gt;Nowhere does that construction require the model to be good. A bad model just has large residuals, so the calibration quantile is large, so the intervals are &lt;strong&gt;wide&lt;/strong&gt; — wide enough to still cover at the stated rate. Accuracy doesn't buy you &lt;em&gt;validity&lt;/em&gt;; it buys you &lt;em&gt;efficiency&lt;/em&gt; (narrower intervals at the same coverage).&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo (numbers are reproducible, seed fixed)
&lt;/h2&gt;

&lt;p&gt;Same dataset and target, three models from strong to useless, target coverage 90%:&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;R²&lt;/th&gt;
&lt;th&gt;marginal coverage&lt;/th&gt;
&lt;th&gt;mean interval width&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gradient boosting&lt;/td&gt;
&lt;td&gt;0.741&lt;/td&gt;
&lt;td&gt;0.895&lt;/td&gt;
&lt;td&gt;5.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weak linear (1 noisy feature)&lt;/td&gt;
&lt;td&gt;0.061&lt;/td&gt;
&lt;td&gt;0.905&lt;/td&gt;
&lt;td&gt;10.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;predict-the-mean&lt;/td&gt;
&lt;td&gt;−0.000&lt;/td&gt;
&lt;td&gt;0.907&lt;/td&gt;
&lt;td&gt;10.83&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three land at ~90% coverage. The only thing that changes is width: the good model's intervals are &lt;strong&gt;half as wide&lt;/strong&gt;. That's the whole story in one table — validity is constant, efficiency tracks accuracy.&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;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LinearRegression&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.ensemble&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GradientBoostingRegressor&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20260617&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6000&lt;/span&gt;
&lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4500&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4500&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="n"&gt;Xtr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Xcal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Xte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ytr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ycal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;yte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ALPHA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;conformal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Xtr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ytr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ycal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Xcal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;k&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ALPHA&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# calibration quantile
&lt;/span&gt;    &lt;span class="n"&gt;pred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Xte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;covered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yte&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;pred&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yte&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;pred&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;yte&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;yte&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;yte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gcov&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;g&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;covered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;gte&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: R2=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; cov=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;covered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; width=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;5.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; group=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gcov&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;conformal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GradientBoostingRegressor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random_state&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strong&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Weak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LinearRegression&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&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;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X&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;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;conformal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Weak&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weak  &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;h2&gt;
  
  
  The catch: marginal ≠ conditional
&lt;/h2&gt;

&lt;p&gt;Here's the part you can't skip. The guarantee is &lt;strong&gt;marginal&lt;/strong&gt; — averaged over the whole distribution. It says nothing about coverage &lt;em&gt;within&lt;/em&gt; a subgroup. Watch what the same run reports per subgroup:&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;marginal&lt;/th&gt;
&lt;th&gt;group 0&lt;/th&gt;
&lt;th&gt;group 1&lt;/th&gt;
&lt;th&gt;group 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;strong GBM&lt;/td&gt;
&lt;td&gt;0.895&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.835&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.985&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.857&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;predict-the-mean&lt;/td&gt;
&lt;td&gt;0.907&lt;/td&gt;
&lt;td&gt;0.889&lt;/td&gt;
&lt;td&gt;0.933&lt;/td&gt;
&lt;td&gt;0.897&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;em&gt;strong&lt;/em&gt; model has the &lt;em&gt;worse&lt;/em&gt; conditional coverage — groups 0 and 2 sit at 83–86% while group 1 is over-covered at 98%. A single global residual quantile produces constant-width intervals that can't adapt to residuals that vary by group, so it robs the hard groups to pay the easy one. (The mean-only model looks more uniform here only because its residuals happen to be roughly homoskedastic across groups — luck, not virtue.)&lt;/p&gt;

&lt;p&gt;If your decisions are made per-subgroup — per region, per asset class, per customer segment — marginal coverage is not enough, and a high overall number can hide silent under-coverage where it matters. The fixes are &lt;strong&gt;Mondrian / group-conditional conformal&lt;/strong&gt; (calibrate a separate quantile per group) or a normalized/locally-weighted nonconformity score so interval width adapts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A weak model gives you &lt;strong&gt;wide but honest&lt;/strong&gt; intervals, not invalid ones. "The model is bad so the uncertainty is meaningless" is the wrong instinct — wide intervals &lt;em&gt;are&lt;/em&gt; the correct signal that the model doesn't know much.&lt;/li&gt;
&lt;li&gt;The genuinely dangerous case is the opposite: a confident-looking &lt;em&gt;narrow&lt;/em&gt; interval whose coverage is a lie. That happens not from low accuracy but from a &lt;strong&gt;broken exchangeability assumption&lt;/strong&gt; — distribution drift between calibration and deployment. (That failure mode, and adaptive conformal as the fix, is a separate write-up.)&lt;/li&gt;
&lt;li&gt;Always check &lt;strong&gt;conditional&lt;/strong&gt; coverage on the groups you actually act on. The marginal number is necessary, not sufficient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conformal prediction is one of the few tools that gives you a real guarantee with almost no assumptions. Just remember which guarantee it gives — coverage over the whole distribution — and verify the rest yourself.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Stop trusting the agent: bind tool-call approvals to the exact call</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:11:04 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/stop-trusting-the-agent-bind-tool-call-approvals-to-the-exact-call-5080</link>
      <guid>https://dev.to/whatsonyourmind/stop-trusting-the-agent-bind-tool-call-approvals-to-the-exact-call-5080</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agentic systems gate dangerous tool calls — file writes, money movement, deploys — behind an "approval": a human-in-the-loop click, or a policy check. Look at how that approval is usually represented and you'll often find a boolean sitting in the run/session state: &lt;code&gt;approved: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A boolean is the wrong primitive, and it fails in three ways that prompt injection is happy to exploit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways an approval boolean breaks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Flip.&lt;/strong&gt; Anything that can write the run state — a serialized context crossing a process/durable-execution boundary, a confused-deputy code path, an injection that steers state — turns &lt;code&gt;false&lt;/code&gt; into &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay.&lt;/strong&gt; You approved "read &lt;code&gt;report.csv&lt;/code&gt;". The approval is just &lt;code&gt;true&lt;/code&gt;, so the same flag is honored for the &lt;em&gt;next&lt;/em&gt; tool call too — "delete &lt;code&gt;prod.db&lt;/code&gt;". The boolean doesn't know which call it approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Argument drift.&lt;/strong&gt; You approved "transfer &lt;strong&gt;$10&lt;/strong&gt; to alice". Between approval and execution the args mutate to &lt;strong&gt;$10,000&lt;/strong&gt;. The boolean still says &lt;code&gt;approved&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The root cause is the same in all three: the approval is modeled as a &lt;strong&gt;property of the run&lt;/strong&gt;, when it should be &lt;strong&gt;evidence for one specific call&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind the approval to the call
&lt;/h2&gt;

&lt;p&gt;When approval is granted, mint a tag over the things that must not change: the tool-call id, a digest of the canonical arguments, the principal, and an expiry. Verify it at dispatch, against a per-run secret.&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;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# canonical serialization so benign reserialization doesn't invalidate a token.
&lt;/span&gt;    &lt;span class="c1"&gt;# (production: RFC 8785 JCS, which also normalizes numbers — 10 vs 10.0)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="sh"&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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;exp&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;principal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;   &lt;span class="c1"&gt;# replay onto another call
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;principal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;   &lt;span class="c1"&gt;# wrong principal
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;   &lt;span class="c1"&gt;# expired
&lt;/span&gt;    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;         &lt;span class="c1"&gt;# forged / flipped / arg-drift
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the three attacks against it (plus principal-swap and a forged tag):&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;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per-run-secret-not-a-global-one&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# approve $10 to alice
&lt;/span&gt;
&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# True   legit
&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# False  replay
&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# False  arg drift
&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:99&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# False  wrong principal
&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# False  forged
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag can no longer be flipped (no valid tag), replayed (call-id is in the MAC), or drifted (args digest is in the MAC). An attacker who fully controls the transported state still can't manufacture a token without the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three details that decide whether it actually holds
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Canonicalization.&lt;/strong&gt; Both sides must hash the &lt;em&gt;same bytes&lt;/em&gt;. Sort keys, and normalize numbers (&lt;code&gt;10&lt;/code&gt; vs &lt;code&gt;10.0&lt;/code&gt; vs &lt;code&gt;1e1&lt;/code&gt; must agree) — RFC 8785 (JSON Canonicalization Scheme) is the off-the-shelf answer. Put the canonicalization recipe id inside the hashed bytes so the two sides can't silently disagree about the rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed, with a &lt;em&gt;typed&lt;/em&gt; result.&lt;/strong&gt; Absent / expired / mismatched ⇒ a distinct "not approved" outcome — not a normal tool payload, and not a generic exception. Otherwise "approval missing" is indistinguishable downstream from "the tool ran and returned something falsy," and the caller can't tell whether to re-request approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One enforced checkpoint, deny-by-default.&lt;/strong&gt; This belongs at the single point right before dispatch: Semantic Kernel's &lt;code&gt;AUTO_FUNCTION_INVOCATION&lt;/code&gt; filter (don't call &lt;code&gt;next&lt;/code&gt; ⇒ the call is skipped), ADK's &lt;code&gt;before_tool&lt;/code&gt; callback, or the MCP tool-call boundary. Tools that need approval are classified as such; anything unclassified is denied, not allowed through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gotcha that bites in production: replay
&lt;/h2&gt;

&lt;p&gt;If your agent runs on a replay-based durable-execution engine (Temporal and friends), the per-run secret &lt;strong&gt;must survive replay&lt;/strong&gt;. Workflow code is re-executed from history on recovery, so a key minted with a non-deterministic call won't match the token already in history — approvals verify fine in dev and then &lt;strong&gt;fail closed after the first worker restart&lt;/strong&gt;, which is the worst possible time to discover it. Derive the key deterministically (&lt;code&gt;HKDF(server_secret, run_id)&lt;/code&gt;) or establish it once via a recorded side-effect, and make the expiry deterministic too rather than reading wall-clock inside workflow code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Authorization in an agent system shouldn't be ambient, mutable state that travels with the run. It should be &lt;strong&gt;evidence bound to a single call envelope&lt;/strong&gt; — this principal, this tool, these exact arguments, until this time — that the executor re-verifies at the moment of dispatch. The boolean isn't a simplification of that; it's the bug.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on reliability and verification for AI and numerical systems — agent authorization, determinism, and "prove the thing that claims to be authorized actually was." The snippet above is runnable as-is. Happy to compare notes if you're hardening an agent's tool boundary — &lt;a href="https://github.com/Whatsonyourmind" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>Conformal prediction silently breaks under drift - and how to make it hold</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 14:39:25 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/conformal-prediction-silently-breaks-under-drift-and-how-to-make-it-hold-466g</link>
      <guid>https://dev.to/whatsonyourmind/conformal-prediction-silently-breaks-under-drift-and-how-to-make-it-hold-466g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Conformal prediction is the easiest way to put a calibrated uncertainty band around &lt;em&gt;any&lt;/em&gt; model: wrap a point predictor, and you get intervals with a finite-sample coverage guarantee — no distributional assumptions. It's deservedly popular.&lt;/p&gt;

&lt;p&gt;There's a catch that bites in production: that guarantee is &lt;strong&gt;marginal&lt;/strong&gt; and it assumes &lt;strong&gt;exchangeability&lt;/strong&gt;. The moment your data drifts — almost any time series, any online-serving setting — exchangeability is gone, and split-conformal silently stops delivering the coverage it promises. No error, just a band that's quietly too narrow.&lt;/p&gt;

&lt;p&gt;Here's the failure, then a fix that actually holds, with runnable code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure, measured
&lt;/h2&gt;

&lt;p&gt;Target 90% intervals. Residuals whose spread drifts upward over time (a textbook covariate/heteroscedastic shift). Calibrate split-conformal on the first chunk and let it run:&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;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;                  &lt;span class="c1"&gt;# 90% target; W = calibration window
&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;    &lt;span class="c1"&gt;# residual spread drifts upward
&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;standard_normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# nonconformity = |residual|
&lt;/span&gt;
&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# frozen calibration quantile
&lt;/span&gt;&lt;span class="n"&gt;static&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="nf"&gt;print&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;static&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                 &lt;span class="c1"&gt;# -&amp;gt; 0.579
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;58% coverage where you asked for 90% — and in the &lt;strong&gt;last quarter&lt;/strong&gt; of the run, deep into the drift, it's &lt;strong&gt;35%&lt;/strong&gt;. A dashboard reporting "90% prediction intervals" would be off by more than half, with nothing flagging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it breaks, and the two things you have to fix
&lt;/h2&gt;

&lt;p&gt;There are two distinct ways drift kills coverage, and they need different fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The score scale goes stale.&lt;/strong&gt; Your calibration scores were collected when residuals were small; now they're large. The frozen quantile is simply too small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The miscoverage rate drifts.&lt;/strong&gt; Even with a reasonable scale, the realized error rate wanders away from &lt;code&gt;α&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Adaptive Conformal Inference&lt;/strong&gt; (Gibbs &amp;amp; Candès, 2021) fixes #2 directly. It treats the target miscoverage as a control variable and runs a feedback loop: after each step, nudge &lt;code&gt;α_t&lt;/code&gt; up if you've been covering too often, down if you've been missing too often.&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;alpha_t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha_t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;err_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# err_t = 1 if the point fell outside
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A miss pushes &lt;code&gt;α_t&lt;/code&gt; down → you use a higher quantile → wider next interval. It's a thermostat for coverage, and it gives a long-run coverage guarantee with &lt;em&gt;no&lt;/em&gt; exchangeability assumption.&lt;/p&gt;

&lt;p&gt;But ACI adapts the &lt;em&gt;level&lt;/em&gt;, not the &lt;em&gt;scale&lt;/em&gt;. Point it at a frozen calibration set and it helps a lot but hits a ceiling — once residuals exceed the largest score it ever saw, even &lt;code&gt;α_t → 0&lt;/code&gt; (the widest interval it can form) isn't wide enough. You also have to let the scores track the current regime, e.g. with a rolling window.&lt;/p&gt;

&lt;p&gt;Measured, same setup, four ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;method&lt;/th&gt;
&lt;th&gt;overall coverage&lt;/th&gt;
&lt;th&gt;coverage in late-drift tail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;static split-conformal&lt;/td&gt;
&lt;td&gt;0.579&lt;/td&gt;
&lt;td&gt;0.347&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACI only (frozen calibration)&lt;/td&gt;
&lt;td&gt;0.864&lt;/td&gt;
&lt;td&gt;0.786&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rolling window only&lt;/td&gt;
&lt;td&gt;0.862&lt;/td&gt;
&lt;td&gt;0.859&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;rolling window + ACI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.900&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.904&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither piece is enough alone. The rolling window supplies the right &lt;em&gt;scale&lt;/em&gt;; ACI supplies the &lt;em&gt;guarantee&lt;/em&gt;. Together they land exactly on target, even in the part of the series where the static method had collapsed to 35%.&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                              &lt;span class="c1"&gt;# rolling -&amp;gt; tracks the new scale
&lt;/span&gt;    &lt;span class="n"&gt;a_eff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;covered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a_eff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;covered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;covered&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# ACI feedback on miscoverage
&lt;/span&gt;&lt;span class="nf"&gt;print&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                         &lt;span class="c1"&gt;# -&amp;gt; 0.900
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three things that matter in practice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The score function decides marginal vs conditional coverage.&lt;/strong&gt; &lt;code&gt;|y − ŷ|&lt;/code&gt; gives you marginal coverage with a constant-width band. If your noise is heteroscedastic and you want bands that are &lt;em&gt;locally&lt;/em&gt; right (conditional coverage), normalize the score — &lt;code&gt;|y − ŷ| / σ̂(x)&lt;/code&gt;, or use Conformalized Quantile Regression (CQR) where the score is the signed distance to predicted quantiles. The choice changes whether wide intervals show up where the data is actually noisy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage is a usable drift signal — but a noisy one.&lt;/strong&gt; Rolling empirical coverage drifting away from &lt;code&gt;1 − α&lt;/code&gt; is a cheap, model-agnostic drift detector. Just remember it's a Bernoulli mean: its standard error is &lt;code&gt;sqrt(c(1−c)/n)&lt;/code&gt;, so over a 100-point window a 90%-coverage estimate has a ±3-point sampling wobble. Trigger on sustained deviation, not one short window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick &lt;code&gt;γ&lt;/code&gt; for your drift speed.&lt;/strong&gt; Larger &lt;code&gt;γ&lt;/code&gt; tracks faster but makes interval widths jumpier; smaller &lt;code&gt;γ&lt;/code&gt; is smoother but lags. &lt;code&gt;0.01–0.05&lt;/code&gt; is a sane starting range; tune against your realized coverage trace, not in the abstract.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A guarantee that assumes exchangeability is not a guarantee in production — it's an assumption wearing a guarantee's clothes. What makes ACI worth reaching for is that it &lt;em&gt;drops&lt;/em&gt; the assumption and replaces it with a feedback loop you can actually verify online: watch the realized coverage, and let it correct itself. If you serve intervals anywhere a too-narrow band is expensive, that self-correction is the difference between a number you can trust and one that quietly lies as the world moves.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on reliability and verification for numerical and AI systems — calibration, drift, and "does the guarantee actually hold under load" tooling. The benchmark above is fully runnable; I'm happy to compare notes if you're putting conformal methods into production — &lt;a href="https://github.com/Whatsonyourmind" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>statistics</category>
    </item>
    <item>
      <title>When your optimizer silently returns the wrong answer (and how to catch it)</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:49:40 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/when-your-optimizer-silently-returns-the-wrong-answer-and-how-to-catch-it-ll6</link>
      <guid>https://dev.to/whatsonyourmind/when-your-optimizer-silently-returns-the-wrong-answer-and-how-to-catch-it-ll6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Numerical solvers have a failure mode that is worse than crashing: every so often they return &lt;code&gt;status: Optimal&lt;/code&gt; and hand you a number that is simply wrong. No exception, no warning — just a confident, incorrect optimum. If that number drives a downstream decision (a schedule, an allocation, a price), you may never notice.&lt;/p&gt;

&lt;p&gt;I ran into a clean example of this in &lt;a href="https://github.com/ERGO-Code/HiGHS" rel="noopener noreferrer"&gt;HiGHS&lt;/a&gt; recently while reducing a bug that had surfaced through cvxpy, and the debugging path generalizes to any LP/QP/MILP stack. Here's the case, how I isolated it, and a short checklist you can apply to your own models.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: same model, two answers
&lt;/h2&gt;

&lt;p&gt;A mixed-integer model that HiGHS solves to &lt;code&gt;Optimal&lt;/code&gt; with objective &lt;code&gt;0.0&lt;/code&gt; under default settings — but solve the &lt;em&gt;same&lt;/em&gt; model with presolve turned off and you get &lt;code&gt;Optimal&lt;/code&gt; with objective ≈ &lt;code&gt;6.68e8&lt;/code&gt;. Both runs report success. One of them is wrong.&lt;/p&gt;

&lt;p&gt;When presolve-on and presolve-off disagree on a problem that has a well-defined, bounded optimum, that is not a tolerance issue — it means one of the reduction steps is mangling the model. (&lt;a href="https://github.com/ERGO-Code/HiGHS/issues/2900" rel="noopener noreferrer"&gt;This particular case&lt;/a&gt; is an open, actively-investigated issue; a separate wrong-answer I reduced to a standalone &lt;code&gt;.mps&lt;/code&gt; from a cvxpy program is &lt;a href="https://github.com/ERGO-Code/HiGHS/issues/3073" rel="noopener noreferrer"&gt;filed here&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The first diagnostic is free: flip presolve
&lt;/h2&gt;

&lt;p&gt;Before anything else, re-solve with presolve disabled and compare the two objectives:&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;import&lt;/span&gt; &lt;span class="n"&gt;highspy&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presolve&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;highspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Highs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setOptionValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presolve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setOptionValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getObjectiveValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;on&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.mps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model.mps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# disagree on a feasible, bounded model =&amp;gt; bug in a reduction
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same idea works through the modeling layer — in cvxpy, compare &lt;code&gt;prob.solve(solver=cp.HIGHS)&lt;/code&gt; against the same solve with &lt;code&gt;{"presolve": "off"}&lt;/code&gt;. If the two disagree, a reduction step is the culprit, and you have already cut the search space in half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scaling is so often the trigger
&lt;/h2&gt;

&lt;p&gt;The common thread in this family of bugs is &lt;strong&gt;coefficient magnitude&lt;/strong&gt;. HiGHS prints the coefficient ranges at the top of every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Coefficient ranges:
  Matrix  [4e-01, 5e+02]
  Cost    [2e+01, 3e+02]
  Bound   [1e+02, 1e+02]
  RHS     [3e+01, 2e+04]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a single constraint mixes coefficients spanning many orders of magnitude, bound-tightening and substitution accumulate floating-point error, and integer-rounding logic ("this RHS rounds up to the next integer bound") can tip the wrong way. The minimal reproducer I extracted kept exactly the rows whose coefficients carried the large magnitudes — drop them and the collapse disappears.&lt;/p&gt;

&lt;p&gt;The same root cause shows up across solvers, just wearing different clothes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OSQP&lt;/strong&gt; (QP): an &lt;a href="https://github.com/osqp/osqp/issues/760" rel="noopener noreferrer"&gt;open report&lt;/a&gt; where v1.0.0+ runs all the way to max-iterations with &lt;code&gt;gap = -nan&lt;/code&gt;, &lt;em&gt;even though&lt;/em&gt; the primal and dual residuals are already at &lt;code&gt;1e-14&lt;/code&gt;. The duality-gap termination criterion is poisoned by a NaN, so the solver never recognizes that it has already converged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clarabel&lt;/strong&gt; (conic/QP): a &lt;a href="https://github.com/oxfordcontrol/Clarabel.rs/issues/217" rel="noopener noreferrer"&gt;report&lt;/a&gt; where a wildly ill-scaled QP (objective on the order of &lt;code&gt;1e9&lt;/code&gt;) returns a false &lt;code&gt;PrimalInfeasible&lt;/code&gt; with equilibration on, but solves cleanly with &lt;code&gt;equilibrate_enable=False&lt;/code&gt;. Ruiz equilibration is capped at &lt;code&gt;equilibrate_max_scaling = 1e4&lt;/code&gt; by default — about four orders short of a &lt;code&gt;1e8&lt;/code&gt; dynamic range, so the post-scaling KKT system is still badly conditioned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different solvers, same lesson: magnitude is not cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to minimize a solver bug so it actually gets fixed
&lt;/h2&gt;

&lt;p&gt;A 350-row model is not a bug report a maintainer can act on. The reduction loop is mechanical and worth automating:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce on the latest release first.&lt;/strong&gt; Half of "bugs" are already fixed. Pin the version you tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greedily drop rows and columns.&lt;/strong&gt; Remove a chunk; if the wrong-answer signature survives, keep it removed; otherwise restore it and try a smaller chunk. Binary-search your way down. I took one case from 348×169 to 41×40 this way and it still collapsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the "still broken" check a predicate, not an eyeball.&lt;/strong&gt; Here it was &lt;code&gt;abs(on - off) &amp;gt; tol&lt;/code&gt; (or &lt;code&gt;status == Infeasible&lt;/code&gt; while presolve-off says &lt;code&gt;Optimal&lt;/code&gt;), re-evaluated after every removal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export the reduced model to a portable format&lt;/strong&gt; (&lt;code&gt;.mps&lt;/code&gt;) so the report is solver-version- and language-independent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File with three things: the version, the exact on/off command delta, and the minimal &lt;code&gt;.mps&lt;/code&gt;.&lt;/strong&gt; That is a report that gets triaged in minutes instead of sitting untouched.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A scaling-hygiene checklist
&lt;/h2&gt;

&lt;p&gt;Even when there is no solver bug, bad scaling silently erodes accuracy. Cheap habits that prevent most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the coefficient ranges on every run.&lt;/strong&gt; If the matrix or RHS spans more than ~&lt;code&gt;1e6&lt;/code&gt;, treat the result with suspicion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rescale units before the solver sees them&lt;/strong&gt; (dollars → millions, bytes → GB). Single highest-leverage fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not encode big-M larger than necessary.&lt;/strong&gt; An &lt;code&gt;M&lt;/code&gt; of &lt;code&gt;1e9&lt;/code&gt; where &lt;code&gt;1e4&lt;/code&gt; would do is how you manufacture these bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a presolve-off run in your test suite&lt;/strong&gt; for any model whose output you trust blindly — a periodic on/off agreement check is a cheap regression guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For QP/conic, check the equilibration cap&lt;/strong&gt; against your data's dynamic range, and prefer pre-scaling to relying on the solver to rescue pathological inputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The broader point
&lt;/h2&gt;

&lt;p&gt;These bugs are dangerous precisely because the solver's contract — "I returned &lt;code&gt;Optimal&lt;/code&gt;" — is exactly what you would normally trust. The on/off differential is so useful &lt;em&gt;because&lt;/em&gt; it doesn't trust that contract: it cross-checks two code paths that are supposed to agree and flags the moment they don't. That "verify the thing that claims to be correct" instinct is worth wiring into any pipeline where a wrong number is expensive.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on reliability and verification for numerical and AI systems — minimal reproducers, determinism, and "prove the output is what it claims" tooling; the HiGHS reducer above came out of that. The issues referenced are linked inline. If you hit something in this family, I'm happy to compare notes — &lt;a href="https://github.com/Whatsonyourmind" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>optimization</category>
      <category>python</category>
      <category>debugging</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Determinism as a feature: when to let your agent call a math API instead of reasoning</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Wed, 17 Jun 2026 09:16:18 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/determinism-as-a-feature-when-to-let-your-agent-call-a-math-api-instead-of-reasoning-10mf</link>
      <guid>https://dev.to/whatsonyourmind/determinism-as-a-feature-when-to-let-your-agent-call-a-math-api-instead-of-reasoning-10mf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;LLM agents are great at deciding &lt;em&gt;what&lt;/em&gt; to do and unreliable at &lt;em&gt;computing&lt;/em&gt; it. Ask one to allocate traffic across five variants, price tail risk, or solve a scheduling constraint and you'll get a confident, plausible, subtly-wrong number — tokens burned included.&lt;/p&gt;

&lt;p&gt;The fix usually isn't a better prompt. It's the same instinct that gave us the calculator: move the deterministic math out of the probabilistic engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell
&lt;/h2&gt;

&lt;p&gt;You have a determinism problem the moment your agent's output needs to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;reproducible&lt;/strong&gt; — same inputs → same answer, every run,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;auditable&lt;/strong&gt; — someone can check &lt;em&gt;why&lt;/em&gt; it's 0.62 and not 0.61, or&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;correct under adversarial inputs&lt;/strong&gt; — a fat-tailed return, an infeasible constraint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An LLM gives you none of those for free. A tool call does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to offload (and a cheap test for each)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Which variant should I ship?"&lt;/strong&gt; → a multi-armed / contextual bandit. The agent picks &lt;em&gt;the question&lt;/em&gt;; Thompson sampling picks the allocation. Test: ask your agent to allocate 1,000 users across 4 arms with the same conversion counts, twice. Different answers? Offload it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Is this metric anomalous?"&lt;/strong&gt; → score the series against a baseline; don't eyeball it inside the context window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"What's the 95% VaR / CVaR?"&lt;/strong&gt; → Monte Carlo paths, not a vibe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Schedule these tasks under these limits"&lt;/strong&gt; → an LP/MIP solver. LLMs can't reliably satisfy hard constraints; solvers can't violate them.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Expose the math as MCP tools so the agent calls them like any other tool — intent stays in the model, the number comes from code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// agent decides intent; the tool computes the answer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alloc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;optimize_contextual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;arms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// [{ id, name }]&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userFeatures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// segment, prior_open_rate, hour_of_day&lt;/span&gt;
  &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pastRewards&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// `alloc` is reproducible, sub-millisecond, and you can show your work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design details that bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delayed reward.&lt;/strong&gt; If reward trickles in (email opens over hours), set a fixed attribution window before crediting an arm — otherwise the bandit over-exploits early openers and collapses variant diversity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start.&lt;/strong&gt; Start each arm on a &lt;code&gt;Beta(1,1)&lt;/code&gt; prior (or an informed prior from past campaigns) so exploration doesn't die on run one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When &lt;em&gt;not&lt;/em&gt; to offload
&lt;/h2&gt;

&lt;p&gt;Determinism is a constraint, and constraints have cost. If the task is genuinely fuzzy — summarizing a doc, routing an intent, drafting copy — keep it in the model. A rule of thumb that's served me well:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you'd want a unit test for the output, it belongs in a tool, not a prompt.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;If you want a batteries-included set of these as MCP tools — bandits, forecasting, Monte Carlo, optimization, anomaly/risk — I maintain &lt;a href="https://github.com/Whatsonyourmind/oraclaw" rel="noopener noreferrer"&gt;OraClaw&lt;/a&gt; (&lt;code&gt;npx -y @oraclaw/mcp-server&lt;/code&gt;; 11 of the tools are free, no key). But the pattern matters more than the tool — wire in whatever solver you like. Disclosure: I built it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>What Happens When 1,000 Agents Make the Same Mistake Simultaneously</title>
      <dc:creator>Whatsonyourmind</dc:creator>
      <pubDate>Mon, 11 May 2026 18:00:38 +0000</pubDate>
      <link>https://dev.to/whatsonyourmind/what-happens-when-1000-agents-make-the-same-mistake-simultaneously-4icb</link>
      <guid>https://dev.to/whatsonyourmind/what-happens-when-1000-agents-make-the-same-mistake-simultaneously-4icb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;title: "What Happens When 1,000 Agents Make the Same Mistake Simultaneously"&lt;br&gt;
published: true&lt;br&gt;
description: "A fleet of agents sharing one foundation model do not give you 1,000 independent opinions — they give you one opinion at 1,000x scale, and the correlated risk is invisible until it cascades."&lt;br&gt;
tags: ai, mcp, agents, risk&lt;/p&gt;
&lt;h2&gt;
  
  
  canonical_url: 
&lt;/h2&gt;
&lt;h1&gt;
  
  
  What Happens When 1,000 Agents Make the Same Mistake Simultaneously
&lt;/h1&gt;

&lt;p&gt;Here is a scenario that has not happened yet at scale. It will.&lt;/p&gt;

&lt;p&gt;A hedge fund runs 1,000 AI trading agents. Each manages a slice of the portfolio independently. Each uses an LLM for risk assessment -- evaluating positions, interpreting market signals, deciding whether to hold, hedge, or exit. The agents are diverse: different prompts, different context windows, different position sizes. On paper, this is a well-diversified system.&lt;/p&gt;

&lt;p&gt;Tuesday morning, the market drops 3%.&lt;/p&gt;

&lt;p&gt;Each agent independently evaluates its positions. The LLM in each agent processes the drop, considers historical context, and concludes some version of: "A 3% drop is within normal volatility. Current positions are within risk tolerance. Recommendation: hold."&lt;/p&gt;

&lt;p&gt;This conclusion is reasonable. For any single agent, it is arguably correct. A 3% drop &lt;em&gt;is&lt;/em&gt; within normal volatility. Individual positions &lt;em&gt;are&lt;/em&gt; within their risk bands.&lt;/p&gt;

&lt;p&gt;But 1,000 agents just made the same decision for the same reason at the same time. Every single one is holding. The aggregate exposure has not decreased by a single dollar.&lt;/p&gt;

&lt;p&gt;Wednesday morning, the market drops another 5%. Total drawdown: 8%.&lt;/p&gt;

&lt;p&gt;Now the same LLMs reassess. But the loss is already locked in. Selling now crystallizes the damage. The agents that were trained on "don't panic sell" hold longer. The agents that weren't start selling into a falling market, driving prices lower, triggering stop-losses in the agents that were holding. Cascade.&lt;/p&gt;

&lt;p&gt;The fund loses 12% in 48 hours. Not because any individual agent made an irrational decision. Because every agent made the &lt;em&gt;same&lt;/em&gt; rational-looking decision, and nobody was watching the correlation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Invisible Risk: Correlated Failures
&lt;/h2&gt;

&lt;p&gt;Individual agent risk is measurable and manageable. System-level correlated risk is invisible until it detonates.&lt;/p&gt;

&lt;p&gt;This is not a new concept in finance. Long-Term Capital Management collapsed in 1998 for exactly this reason -- not because their models were wrong about individual positions, but because every sophisticated player in the market was running similar models and similar positions. When the correlation spiked, the diversification vanished.&lt;/p&gt;

&lt;p&gt;LLM-based agents introduce a new variant of this problem. Traditional quant funds at least used &lt;em&gt;different&lt;/em&gt; models -- different signals, different timeframes, different risk parameters. Agents running the same foundation model have a much deeper correlation: they share the same training data, the same reasoning patterns, the same blind spots.&lt;/p&gt;

&lt;p&gt;When GPT-4 thinks a 3% drop is fine, it is not one agent's opinion. It is the opinion of every agent built on GPT-4. The model's assessment is the market's assessment, because the model &lt;em&gt;is&lt;/em&gt; a large chunk of the market's decision-making apparatus. This circularity is invisible to each individual agent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three Failure Modes Nobody Is Monitoring
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Behavior correlation spikes.&lt;/strong&gt; In normal markets, 1,000 agents with different contexts and positions behave differently. In stress scenarios, their behavior converges because the underlying LLM's response to stress follows the same pattern. If you are not measuring inter-agent behavior correlation in real time, you will not see the convergence until it is too late.&lt;/p&gt;

&lt;p&gt;The fix is not better prompts. It is statistical monitoring that flags when the fleet's decisions become suspiciously aligned. When 950 out of 1,000 agents agree on the same action in a volatile market, that agreement itself is the risk signal -- regardless of whether the action looks correct individually. This is exactly the kind of deterministic guardrail OraClaw is built for: the agreement-correlation score is a number, not a narrative, and it does not share the foundation model's blind spots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Tail risk blindness.&lt;/strong&gt; LLMs trained on historical data learn the distribution of normal outcomes. They are systematically bad at reasoning about tail events -- the 1-in-100 scenarios where the most damage occurs. Ask any LLM what happens if the S&amp;amp;P drops 15% in a week, and you get a historically-informed narrative. You do not get a quantitative assessment of portfolio impact under correlated stress with proper fat-tail modeling.&lt;/p&gt;

&lt;p&gt;Risk metrics designed for tail events exist. They simulate thousands of extreme scenarios, account for correlation structures that only appear during crises, and produce numbers -- not narratives -- for worst-case exposure. These metrics should sit between the agent and any risk decision, as a hard mathematical guardrail that the LLM cannot override. OraClaw runs 5,000-path Monte Carlo and returns VaR + CVaR + worst-case scenario in under 5ms — math the agent calls but cannot rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ensemble agreement is not ensemble accuracy.&lt;/strong&gt; Many multi-agent systems use agreement as a confidence signal: "If 4 out of 5 agents agree, the decision is high-confidence." This is valid when the agents are genuinely independent. It is dangerous when they share a common foundation model.&lt;/p&gt;

&lt;p&gt;Five agents built on GPT-4 agreeing is not five independent opinions. It is one opinion expressed five times with slightly different wording. The agreement is measuring model consistency, not decision quality. Proper ensemble scoring detects when multiple models agree for the wrong reasons -- when agreement stems from shared bias rather than convergent evidence.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the Math Layer Looks Like
&lt;/h2&gt;

&lt;p&gt;Multi-agent systems need three things that LLMs cannot provide:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time correlation monitoring.&lt;/strong&gt; Measuring the statistical similarity of agent decisions across the fleet, with alerts when correlation exceeds safe thresholds. This is a streaming statistics problem, not a reasoning problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quantitative tail risk.&lt;/strong&gt; VaR and CVaR computed at the portfolio level, accounting for position correlation, with proper fat-tail distributions. Updated continuously, not narrated occasionally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calibrated ensemble scoring.&lt;/strong&gt; Measuring whether multi-agent agreement actually predicts accuracy, with correction factors for shared-model bias. Turning "4 out of 5 agree" into a real probability that the decision is correct.&lt;/p&gt;

&lt;p&gt;None of these require intelligence. They require math -- the kind that runs in milliseconds, produces auditable numbers, and does not share the blind spots of the system it is protecting. OraClaw's convergence-scoring tool does exactly this: Hellinger-distance over signal distributions, not vibe-checks over agent prose.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Stakes
&lt;/h2&gt;

&lt;p&gt;Single-agent failures are costly. Multi-agent correlated failures are catastrophic. The difference is not one of degree but of kind: individual mistakes are linear; correlated mistakes are exponential.&lt;/p&gt;

&lt;p&gt;Your agents need a math layer between them and catastrophic decisions. Not a smarter prompt. Not a better model. A statistical guardrail that measures what the agents cannot see about themselves.&lt;/p&gt;

&lt;p&gt;The math exists. The question is whether it will be deployed before or after the first correlated cascade.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try OraClaw
&lt;/h2&gt;

&lt;p&gt;OraClaw is an MCP server that gives Claude deterministic risk-and-correlation tools — calibrated probability, monotonic constraints, audit trails, ensemble scoring. The math layer your fleet needs before the first cascade, not after. Install in Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add oraclaw &lt;span class="nt"&gt;--&lt;/span&gt; npx @oraclaw/mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;17 tools, MIT licensed. Repo: github.com/Whatsonyourmind/oraclaw&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Whatsonyourmind/oraclaw" rel="noopener noreferrer"&gt;github.com/Whatsonyourmind/oraclaw&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;More posts: &lt;a href="https://dev.to/lukastan"&gt;dev.to/lukastan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;OraClaw provides anomaly detection, risk metrics, and ensemble scoring for multi-agent systems. &lt;a href="https://github.com/Whatsonyourmind/oraclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://oraclaw.com/clawhub" rel="noopener noreferrer"&gt;ClawHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
      <category>risk</category>
    </item>
  </channel>
</rss>
