<?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: Tatiana Radchenko 🇩🇰</title>
    <description>The latest articles on DEV Community by Tatiana Radchenko 🇩🇰 (@taniacoder).</description>
    <link>https://dev.to/taniacoder</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%2F3999372%2F7af2116b-b834-4900-9ae3-ee2e2d294004.jpg</url>
      <title>DEV Community: Tatiana Radchenko 🇩🇰</title>
      <link>https://dev.to/taniacoder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/taniacoder"/>
    <language>en</language>
    <item>
      <title>My drift board learned to say "I don't know". Here is what it still cannot say.</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Wed, 30 Sep 2026 21:05:55 +0000</pubDate>
      <link>https://dev.to/taniacoder/my-drift-board-learned-to-say-i-dont-know-here-is-what-it-still-cannot-say-18i1</link>
      <guid>https://dev.to/taniacoder/my-drift-board-learned-to-say-i-dont-know-here-is-what-it-still-cannot-say-18i1</guid>
      <description>&lt;p&gt;&lt;em&gt;Archival copy of record, committed before this post: &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH/blob/main/docs/reports/2026-09-22-weather-report-02.md" rel="noopener noreferrer"&gt;https://github.com/Tania-coder/SEISMOGRAPH/blob/main/docs/reports/2026-09-22-weather-report-02.md&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provenance.&lt;/strong&gt; Every figure below is recomputed from two committed&lt;br&gt;
snapshots of the public board by &lt;code&gt;scripts/weather_window_stats.py&lt;/code&gt;,&lt;br&gt;
which runs in the project's test gate. The snapshots are&lt;br&gt;
&lt;code&gt;docs/evidence/weather-2026-09-09T173824Z.json&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;docs/evidence/weather-2026-09-22T104310Z.json&lt;/code&gt;; the update block uses&lt;br&gt;
a third and a fourth, &lt;code&gt;docs/evidence/weather-2026-09-30T193201Z.json&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;docs/evidence/weather-2026-09-30T205449Z.json&lt;/code&gt;. Nothing here is&lt;br&gt;
arithmetic done once in prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, 2026-09-30.&lt;/strong&gt; This report was written on 2026-09-22 and is&lt;br&gt;
published eight days later. The body below is unchanged, and its&lt;br&gt;
figures are as of the 2026-09-22 read. Before publishing I read the&lt;br&gt;
board again, at 19:32 UTC, and recomputed with the same instrument:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mistral&lt;/strong&gt; has written no row since 2026-09-19T09:31:44Z. Its newest
row is now &lt;strong&gt;274.01 h&lt;/strong&gt; old, eleven days, and the board publishes
&lt;code&gt;STALE&lt;/code&gt;, which agrees. It still prints a sample count of 10 over a
window that opens on 2026-09-02. Why it stopped is still not
diagnosed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;google&lt;/strong&gt; is collecting at &lt;strong&gt;0.8926&lt;/strong&gt; of its nominal rate, newest
row &lt;strong&gt;8.26 h&lt;/strong&gt; old, &lt;code&gt;STABLE&lt;/code&gt;, which agrees. Its saturation bound is
now at most &lt;strong&gt;41.6%&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the findings below changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Later the same evening.&lt;/strong&gt; The cause was found: the API key had&lt;br&gt;
expired. I had created it with an expiry date and forgotten. With a new&lt;br&gt;
key the leg wrote a row at 2026-09-30T20:48:38Z, and the next read&lt;br&gt;
publishes &lt;code&gt;STABLE&lt;/code&gt;. That is limitation 2 below, happening live: the&lt;br&gt;
newest row is &lt;strong&gt;0.10 h&lt;/strong&gt; old, but the ten samples span &lt;strong&gt;363.20 h&lt;/strong&gt; and&lt;br&gt;
the leg collects at &lt;strong&gt;0.2974&lt;/strong&gt; of its nominal rate. Fresh, and still a&lt;br&gt;
fifteen-day average that nobody should read as recent. The logs also&lt;br&gt;
showed that the probe saw every request fail and threw the reason away&lt;br&gt;
instead of writing it down. That is the next fix.&lt;/p&gt;




&lt;p&gt;SEISMOGRAPH is an open-source early-warning network for silent drift in&lt;br&gt;
third-party LLM APIs -- the 2am question of whether the model changed&lt;br&gt;
underneath you or your prompt is simply worse today. It runs a fixed&lt;br&gt;
canary suite against provider endpoints on a schedule, ships only&lt;br&gt;
hashes and differentially-private aggregates, and publishes a public&lt;br&gt;
board.&lt;/p&gt;

&lt;p&gt;The same two things, up front, because everything below depends on&lt;br&gt;
them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is exactly one observer, and it is me.&lt;/strong&gt; A public drift alert
requires cross-observer agreement from three independent observers.
At one observer, no public alert can fire &lt;em&gt;by construction&lt;/em&gt;. That is
a property of the design, not a gap in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live board's baseline was re-established on 2026-08-04.&lt;/strong&gt; There
is no continuous history before that date. Separately, on historical
replay of a known incident, a seeded backtest flags it 38 days before
the postmortem. That is a synthetic replay, not a live catch, and I
do not describe it as one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://dev.to/taniacoder/i-gave-my-drift-monitor-a-denominator-the-first-thing-it-exposed-was-a-hole-in-my-own-data-5508"&gt;Report #1&lt;/a&gt;&lt;br&gt;
ended with a list of limitations. The sixth read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No staleness signal.&lt;/strong&gt; mistral's 56-hour-old row is visible on the&lt;br&gt;
board but not flagged, because "stale after N hours" is a judgement&lt;br&gt;
that needs its own defence. The timestamps are published; the reader&lt;br&gt;
concludes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one is now closed, and closing it turned out to be the smaller&lt;br&gt;
half of the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same leg, eighteen days apart
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mistral/mistral-small-latest&lt;/th&gt;
&lt;th&gt;2026-09-09 17:38 UTC&lt;/th&gt;
&lt;th&gt;2026-09-22 10:43 UTC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sample_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-09-02T09:38:39Z&lt;/td&gt;
&lt;td&gt;2026-09-19T09:31:44Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;age of newest row&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;176.00 h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73.19 h&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;published &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;STABLE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;STALE&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;status agrees with age&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing about that leg got better. A third status did.&lt;/p&gt;

&lt;p&gt;The board's &lt;code&gt;status&lt;/code&gt; used to read one table: &lt;code&gt;DRIFTING&lt;/code&gt; if a&lt;br&gt;
quorum-verified alert existed, &lt;code&gt;STABLE&lt;/code&gt; otherwise. At one observer no&lt;br&gt;
alert can exist, so &lt;code&gt;STABLE&lt;/code&gt; was not a finding. &lt;strong&gt;The green light was&lt;br&gt;
produced by the system's own inability to raise an alarm.&lt;/strong&gt; A leg whose&lt;br&gt;
probe had been dead for a week published it with a healthy-looking&lt;br&gt;
count of ten.&lt;/p&gt;

&lt;p&gt;There are now three states, and a leg whose newest sample is older than&lt;br&gt;
30 hours publishes &lt;code&gt;STALE&lt;/code&gt; regardless of what the alert table says. The&lt;br&gt;
threshold is not a guess in a report: the probe fires twice a day and&lt;br&gt;
was measured running 2.5 to 4.5 hours behind its own cron slot, so 30&lt;br&gt;
hours is one missed slot plus that observed lateness. Below it, "merely&lt;br&gt;
late" and "already stopped" are not separable, and I would rather the&lt;br&gt;
board say nothing than say the wrong one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;STALE&lt;/code&gt; does not hide or alter the leg's published metrics, and it does&lt;br&gt;
not attempt to say &lt;em&gt;why&lt;/em&gt; the leg stopped. It is a statement about the&lt;br&gt;
data, not about the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the counts still cannot see
&lt;/h2&gt;

&lt;p&gt;Here is the part I got wrong in Report #1, and the instrument is what&lt;br&gt;
showed me.&lt;/p&gt;

&lt;p&gt;Report #1 treated "the mistral row is 56 hours old" and "the mistral&lt;br&gt;
leg is losing runs" as one defect. They are two, they are independent,&lt;br&gt;
and only one of them is now visible on the board.&lt;/p&gt;

&lt;p&gt;Recomputed from the snapshots:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;2026-09-09&lt;/th&gt;
&lt;th&gt;2026-09-22&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;window span&lt;/td&gt;
&lt;td&gt;112.19 h&lt;/td&gt;
&lt;td&gt;407.88 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean interval between samples&lt;/td&gt;
&lt;td&gt;12.47 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;45.32 h&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nominal interval (twice daily)&lt;/td&gt;
&lt;td&gt;12.00 h&lt;/td&gt;
&lt;td&gt;12.00 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;collection rate&lt;/td&gt;
&lt;td&gt;0.9627&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.2648&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 2026-09-09 that leg was &lt;strong&gt;stale but collecting normally&lt;/strong&gt; -- 12.47&lt;br&gt;
hours between samples against a 12.00 hour schedule. Today it is&lt;br&gt;
&lt;strong&gt;flagged stale and collecting at a quarter of its own cadence&lt;/strong&gt;: ten&lt;br&gt;
rows spread across seventeen days. Its &lt;code&gt;window_start&lt;/code&gt; is still&lt;br&gt;
2026-09-02T09:38:39Z, the frozen timestamp from the September outage,&lt;br&gt;
because ten rows in seventeen days is not enough to evict it from a&lt;br&gt;
last-ten window.&lt;/p&gt;

&lt;p&gt;So the published "recent average output length" for that leg covers&lt;br&gt;
&lt;strong&gt;seventeen days&lt;/strong&gt;, and the google leg's covers five. The board does&lt;br&gt;
not say so. Both legs print &lt;code&gt;10 / 10 / 10&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the same shape as Report #1's finding, one level up. A run that&lt;br&gt;
exhausts its retries writes no row at all, so a count can never witness&lt;br&gt;
its own losses. Adding a third status fixed the &lt;em&gt;age&lt;/em&gt; of the newest&lt;br&gt;
row. It did not make the &lt;em&gt;rate&lt;/em&gt; visible, and the rate is what tells you&lt;br&gt;
whether a number means anything.&lt;/p&gt;

&lt;p&gt;The cadence figures above are not prose. &lt;code&gt;nominal interval&lt;/code&gt; is parsed&lt;br&gt;
out of the cron line in the probe's own workflow file, so if I change&lt;br&gt;
the schedule and forget a report, the test suite fails instead of the&lt;br&gt;
report quietly lying. That parse refuses anything it cannot interpret&lt;br&gt;
rather than guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second way a stream can look stable
&lt;/h2&gt;

&lt;p&gt;There is a failure mode in this design that no staleness flag reaches,&lt;br&gt;
and it is worse than a dark leg because it is not dark.&lt;/p&gt;

&lt;p&gt;The probe clamps each output length to 320 characters before averaging,&lt;br&gt;
because the differential-privacy noise is calibrated to that bound. On&lt;br&gt;
the corpus I measure with, that clamp removes about a third of the&lt;br&gt;
mean, and the signal that separates two model generations survives it&lt;br&gt;
at roughly ten times the noise. I had predicted the clamp would destroy&lt;br&gt;
that signal. It did not, and the prediction was wrong for an&lt;br&gt;
interesting reason: what matters is not the mean length but the&lt;br&gt;
&lt;strong&gt;fraction of records above the cap&lt;/strong&gt;, which depends on spread.&lt;/p&gt;

&lt;p&gt;Take a corpus with a tight spread and a mean above the cap. Every&lt;br&gt;
record saturates. Both legs then report exactly 320, a difference of&lt;br&gt;
exactly 0.00 where the unclamped difference would have been -141.6&lt;br&gt;
characters -- and with the DP noise applied, the stream emits &lt;strong&gt;-3.12&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That number is the dangerous one. It does not read as "not measured".&lt;br&gt;
It reads as "almost perfectly stable".&lt;/p&gt;

&lt;p&gt;A saturated stream is not a stable model. It is an unmeasured one, and&lt;br&gt;
in every field the board publishes, the two are identical. I do not&lt;br&gt;
have this measured on the live legs; from the published means I can&lt;br&gt;
only bound it -- at most 41.9% of google's records and at most 28.6% of&lt;br&gt;
mistral's can be at the cap. Near-total saturation is ruled out on both.&lt;br&gt;
The exact fraction is not measured, and I am not going to state it as&lt;br&gt;
though it were.&lt;/p&gt;

&lt;p&gt;The rule I took from this, and now apply: &lt;strong&gt;a number that appears in a&lt;br&gt;
published artefact has an instrument that recomputes it in the test&lt;br&gt;
gate.&lt;/strong&gt; Prose recording a measurement is a report of a measurement, not&lt;br&gt;
the measurement. Every figure in this report satisfies that rule; the&lt;br&gt;
script that produces them is in the repository and runs in the gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a benchmark comes in, and why not yet
&lt;/h2&gt;

&lt;p&gt;The feedback I keep getting on the 50-prompt canary suite is the&lt;br&gt;
correct feedback: don't build your own benchmark, build the&lt;br&gt;
infrastructure around the ones that exist and let the operator pick.&lt;br&gt;
That is the direction, and it is decided: only a suite registered in a&lt;br&gt;
pinned public registry can ever enter cross-observer correlation, while&lt;br&gt;
private corpora stay fleet-only. Naive pluggability would make the&lt;br&gt;
observer count one &lt;em&gt;per suite&lt;/em&gt; and quietly destroy the quorum that&lt;br&gt;
makes a public alert mean anything.&lt;/p&gt;

&lt;p&gt;What the clamp finding adds is a prerequisite I did not see before.&lt;br&gt;
External benchmarks have wildly different output profiles -- a&lt;br&gt;
multiple-choice suite sits far below the cap, a code or essay suite may&lt;br&gt;
sit entirely above it. Running one of those through this probe today&lt;br&gt;
could produce exactly the -3.12 shape above. So before any external&lt;br&gt;
suite enters the network, its stream has to be classified interpretable&lt;br&gt;
by the saturation instrument. That is a direction and a constraint, not&lt;br&gt;
a shipping date, and no external benchmark has been run through this&lt;br&gt;
probe yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was wrong about the Google leg
&lt;/h2&gt;

&lt;p&gt;Worth recording because the correction only happened because someone&lt;br&gt;
asked the right question.&lt;/p&gt;

&lt;p&gt;The google leg authenticates with a key in a format the provider is&lt;br&gt;
migrating to. Two auth paths were tested; both failed; I wrote down&lt;br&gt;
that the observer could not be restored, and started reasoning about&lt;br&gt;
which of three bad options to take.&lt;/p&gt;

&lt;p&gt;Then I was asked what had actually been tested, as against what was&lt;br&gt;
being claimed. Four paths, with the response body read rather than the&lt;br&gt;
status code alone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;native endpoint&lt;/th&gt;
&lt;th&gt;OpenAI-compatible layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;provider-specific key header&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400, missing or invalid Authorization header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Authorization: Bearer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key is live. The account is live. What is incompatible is the auth&lt;br&gt;
&lt;em&gt;scheme&lt;/em&gt; -- the compatible layer demands a header the new key format is&lt;br&gt;
rejected in, and my provider code speaks only that layer. The leg needs&lt;br&gt;
code, not a new credential, and the model tuple and board history are&lt;br&gt;
preserved.&lt;/p&gt;

&lt;p&gt;Two tested paths were generalised into a property of the world. To be&lt;br&gt;
precise about what is still inferred: the 200 was against a model&lt;br&gt;
listing, not against a completion. That a generation call with the same&lt;br&gt;
header also succeeds is expected and &lt;strong&gt;not yet measured&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, stated rather than buried
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One observer.&lt;/strong&gt; No public alert can fire. Everything above is
single-organisation fleet data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The collection rate is still not on the board.&lt;/strong&gt; &lt;code&gt;STALE&lt;/code&gt; flags the
age of the newest row. A leg collecting at a quarter of its cadence
with a fresh newest row would publish &lt;code&gt;STABLE&lt;/code&gt; and be within its
rights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The censoring described in Report #1 is unfixed.&lt;/strong&gt; Runs are
discarded when prompts exhaust retries on 429; 429 correlates with
provider load; provider load is when a provider is most likely to
shift behaviour. The surviving samples systematically exclude the
periods most worth measuring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The window is last-10-by-id, not time-bounded.&lt;/strong&gt; Publishing the
bounds makes its age visible; it does not change what it selects.
The mistral leg above is the consequence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live saturation fraction is bounded, not measured.&lt;/strong&gt; See above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the mistral leg stopped on 2026-09-19 is not diagnosed.&lt;/strong&gt; An
earlier stoppage on that leg was an account-level rate limit. This
one has not been investigated, and I am not assuming it is the same
cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 30-hour threshold is reasoned, not optimised.&lt;/strong&gt; It was chosen
from the measured scheduler lateness, not swept against a cost of
false flags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlapping windows.&lt;/strong&gt; The last-10 window slides; two successive
reads share rows, so a difference between them is not a difference
between independent samples.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The board is at &lt;a href="https://seismograph-weather.onrender.com/dashboard" rel="noopener noreferrer"&gt;https://seismograph-weather.onrender.com/dashboard&lt;/a&gt; and&lt;br&gt;
the probe is &lt;code&gt;pip install seismograph-probe&lt;/code&gt;. If you run agents or&lt;br&gt;
evals against a third-party API on a schedule, you already have the&lt;br&gt;
data this network needs; a second observer is the only thing that turns&lt;br&gt;
any of this into an alert anyone can act on.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>observability</category>
      <category>opensource</category>
      <category>mlops</category>
    </item>
    <item>
      <title>I gave my drift monitor a denominator. The first thing it exposed was a hole in my own data collection</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:23:38 +0000</pubDate>
      <link>https://dev.to/taniacoder/i-gave-my-drift-monitor-a-denominator-the-first-thing-it-exposed-was-a-hole-in-my-own-data-5508</link>
      <guid>https://dev.to/taniacoder/i-gave-my-drift-monitor-a-denominator-the-first-thing-it-exposed-was-a-hole-in-my-own-data-5508</guid>
      <description>&lt;p&gt;SEISMOGRAPH is an open-source early-warning network for silent drift in&lt;br&gt;
third-party LLM APIs -- the 2am question of whether the model changed&lt;br&gt;
underneath you or your prompt is simply worse today. It runs a fixed&lt;br&gt;
canary suite against provider endpoints on a schedule, ships only hashes&lt;br&gt;
and differentially-private aggregates, and publishes a public board.&lt;/p&gt;

&lt;p&gt;Two things about its current state, up front, because everything below&lt;br&gt;
depends on them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is exactly one observer, and it is me.&lt;/strong&gt; This is a one-person
project, and that matters for reading everything below. A public drift alert
requires cross-observer agreement from three independent observers. At
one observer, no public alert can fire &lt;em&gt;by construction&lt;/em&gt;. That is a
property of the design, not a gap in it: a single organisation seeing
a wobble is not evidence that a provider changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live board's baseline was re-established on 2026-08-04.&lt;/strong&gt; There
is no continuous history before that date. Separately, on historical
replay of a known incident, a seeded backtest flags it 38 days before
the postmortem. That is a synthetic replay, not a live catch, and I do
not describe it as one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Last week I shipped a small read-side change: &lt;code&gt;/v1/weather&lt;/code&gt; now&lt;br&gt;
publishes, alongside each metric, the number of samples it rests on and&lt;br&gt;
the time bounds of the window those samples came from. Publishing a rate&lt;br&gt;
without its base is a defect; I had been doing it.&lt;/p&gt;

&lt;p&gt;This is what the denominator found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The board, read 2026-09-04
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;google/gemini-3.5-flash-lite&lt;/th&gt;
&lt;th&gt;mistral/mistral-small-latest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;status&lt;/td&gt;
&lt;td&gt;STABLE&lt;/td&gt;
&lt;td&gt;STABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sample_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json_sample_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;length_sample_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-08-28T01:34:25Z&lt;/td&gt;
&lt;td&gt;2026-08-28T17:27:23Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-09-04T09:46:39Z&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-09-02T09:38:39Z&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window span&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;176.2 h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;112.2 h&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean inter-sample interval&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19.6 h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The probe is scheduled twice a day. Ten samples should therefore span&lt;br&gt;
about &lt;strong&gt;108 hours&lt;/strong&gt;. The google leg's ten samples span 176.2 hours: it&lt;br&gt;
is emitting at 61% of its own cadence. And the mistral leg's window&lt;br&gt;
ends on 2026-09-02 -- as of the read at &lt;strong&gt;2026-09-04 18:00 UTC&lt;/strong&gt; that&lt;br&gt;
row is &lt;strong&gt;56 hours old&lt;/strong&gt;, four scheduled slots ago. Both figures are&lt;br&gt;
recomputable from the bounds in the table by anyone reading this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every count on that table reads 10.&lt;/strong&gt; Both legs. The whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the counts could never have found it
&lt;/h2&gt;

&lt;p&gt;The probe runs a 50-prompt canary suite and flushes one aggregated row&lt;br&gt;
per run. If any single prompt exhausts its retries, the runner discards&lt;br&gt;
the entire suite: flushing at a reduced &lt;em&gt;n&lt;/em&gt; would change the&lt;br&gt;
differential-privacy sensitivity of the stream (&lt;code&gt;MAX/n&lt;/code&gt;), so a partial&lt;br&gt;
batch is not a smaller sample, it is a differently-calibrated one.&lt;/p&gt;

&lt;p&gt;The consequence is that a lost run writes &lt;strong&gt;no row at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A class of loss that removes rows entirely is invisible to any count,&lt;br&gt;
however many counts you publish. &lt;code&gt;sample_count&lt;/code&gt; answers &lt;em&gt;how many rows&lt;br&gt;
survived into this metric&lt;/em&gt;. Only the window bounds answer &lt;em&gt;how long it&lt;br&gt;
took to collect them&lt;/em&gt; -- and it was the second question that found the&lt;br&gt;
defect. I did not anticipate this when I scoped the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it moved legs
&lt;/h2&gt;

&lt;p&gt;The interesting part is what happened between two reads 48 hours apart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;leg&lt;/th&gt;
&lt;th&gt;2026-09-02&lt;/th&gt;
&lt;th&gt;2026-09-04&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;google window span&lt;/td&gt;
&lt;td&gt;196.2 h&lt;/td&gt;
&lt;td&gt;176.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;google &lt;code&gt;avg_output_length&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;131.060&lt;/td&gt;
&lt;td&gt;130.398&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral window span&lt;/td&gt;
&lt;td&gt;112.2 h&lt;/td&gt;
&lt;td&gt;112.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral &lt;code&gt;avg_output_length&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;89.42253&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.42253&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;google partially recovered. mistral froze -- byte-identical values,&lt;br&gt;
byte-identical bounds, because not one new row arrived. The leg I was&lt;br&gt;
about to describe as the clean one is the leg that is currently dark.&lt;/p&gt;

&lt;p&gt;Both still publish &lt;code&gt;10 / 10 / 10&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the run logs actually say
&lt;/h2&gt;

&lt;p&gt;I reconciled the board against the scheduler for the window&lt;br&gt;
2026-08-24T05:58Z to 2026-09-01T10:11Z:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16&lt;/strong&gt; scheduled runs fired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9&lt;/strong&gt; succeeded, &lt;strong&gt;7&lt;/strong&gt; failed, &lt;strong&gt;0&lt;/strong&gt; were cancelled at the run level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10&lt;/strong&gt; rows reached the board from the google leg.&lt;/li&gt;
&lt;li&gt;So &lt;strong&gt;6&lt;/strong&gt; runs produced no google row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ten rows from nine run-level successes is not a typo. A run is marked&lt;br&gt;
failed if &lt;strong&gt;any&lt;/strong&gt; provider leg fails, and a failed run can still carry a&lt;br&gt;
successful google leg -- which is the first hint that run-level status is&lt;br&gt;
a poor proxy for per-leg collection.&lt;/p&gt;

&lt;p&gt;That kills the comfortable explanations. The scheduler ran. Nothing hit&lt;br&gt;
a job timeout. The rows were lost &lt;em&gt;inside&lt;/em&gt; runs that happened.&lt;/p&gt;

&lt;p&gt;Per-run detail, from the emission logs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;run&lt;/th&gt;
&lt;th&gt;leg&lt;/th&gt;
&lt;th&gt;outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#118&lt;/td&gt;
&lt;td&gt;google&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;40/50 prompts completed&lt;/code&gt;, 10 failed ids scattered mid-suite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#122&lt;/td&gt;
&lt;td&gt;google&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;49/50 prompts completed&lt;/code&gt;, &lt;strong&gt;one&lt;/strong&gt; failed id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#126&lt;/td&gt;
&lt;td&gt;mistral&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0/50 prompts completed&lt;/code&gt;, all fifty failed, job dead in 75 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The emission policy is printed by the probe itself: &lt;code&gt;pacing: 4500 ms&lt;br&gt;
between prompts, &amp;lt;= 2 retries on 429/503&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the mechanism on the google leg is not a budget running out. It is&lt;br&gt;
&lt;strong&gt;one prompt out of fifty losing three attempts to a transient 429, and&lt;br&gt;
taking the other forty-nine with it.&lt;/strong&gt; In run #122 the suite was 98%&lt;br&gt;
complete and published nothing.&lt;/p&gt;

&lt;p&gt;The mistral leg is a different failure entirely: zero completions in 75&lt;br&gt;
seconds is not rate-limit turbulence, it is a hard condition at the&lt;br&gt;
endpoint. I do not yet know which, and that is its own finding -- the&lt;br&gt;
run log records the failed prompt ids and nothing else. No status code,&lt;br&gt;
no response body, no retry spend. &lt;strong&gt;I built a drift detector whose own&lt;br&gt;
collection failures are not diagnosable from its own logs.&lt;/strong&gt; That gets&lt;br&gt;
fixed before the collection logic does.&lt;/p&gt;

&lt;p&gt;One more thing worth stating because it invalidates arithmetic I had&lt;br&gt;
been doing: scheduled runs fire &lt;strong&gt;2.5 to 4.5 hours after&lt;/strong&gt; their cron&lt;br&gt;
slot. Any reasoning that treats missing samples as exact multiples of&lt;br&gt;
the 12-hour interval is unsound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters: the surviving samples are biased, not just sparse
&lt;/h2&gt;

&lt;p&gt;This is the finding I would most want a reader to take away, and it is&lt;br&gt;
uncomfortable.&lt;/p&gt;

&lt;p&gt;A run is discarded when prompts exhaust retries on 429. 429 correlates&lt;br&gt;
with provider load. And provider load under stress is precisely the&lt;br&gt;
condition under which a provider is most likely to shift behaviour --&lt;br&gt;
it is the mechanism this project exists to detect.&lt;/p&gt;

&lt;p&gt;So the samples that survive systematically exclude the periods most&lt;br&gt;
worth measuring. The google leg's drift readings are computed over a&lt;br&gt;
&lt;strong&gt;censored sample&lt;/strong&gt;, censored by a variable correlated with the thing&lt;br&gt;
being measured. This is not a footnote about data quality. It changes&lt;br&gt;
what the number means.&lt;/p&gt;

&lt;p&gt;For completeness: across the two reads neither leg shows a drift&lt;br&gt;
signal. google's normalised JSON validity moved +0.0016 in raw units&lt;br&gt;
(+0.12 sd of the per-batch DP noise) and its average output length&lt;br&gt;
-0.66 characters (-0.33 sd); mistral moved by exactly zero because it&lt;br&gt;
has no new data. &lt;strong&gt;Read those as "nothing to report from a sample I&lt;br&gt;
have just told you is compromised", not as "the providers are stable".&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, stated rather than buried
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One observer.&lt;/strong&gt; No public alert can fire. Everything above is
single-organisation fleet data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The censoring above.&lt;/strong&gt; The google leg's numbers are not a random
sample of provider behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlapping windows.&lt;/strong&gt; The last-10 window slides; two successive
reads share rows, so a difference between them is not a difference
between independent samples. The sd figures are indicative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The window is last-10-by-id, not time-bounded.&lt;/strong&gt; Publishing the
bounds makes its age visible; it does not change what it selects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counts do not say why a row was excluded.&lt;/strong&gt; Enough for a
denominator, not enough for a diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No staleness signal.&lt;/strong&gt; mistral's 56-hour-old row is visible on the
board but not flagged, because "stale after N hours" is a judgement
that needs its own defence. The timestamps are published; the reader
concludes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publishing volume is not authenticated.&lt;/strong&gt; Sample counts on the
public payload are not yet quorum-gated, so forged volume would
raise apparent support for an average. Open, tracked, undefended.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why publish this
&lt;/h2&gt;

&lt;p&gt;Because the honest version is more useful than the polished one, and&lt;br&gt;
because the bottleneck is structural rather than technical: at one&lt;br&gt;
observer this network is correct and silent. It becomes an early-warning&lt;br&gt;
system at three.&lt;/p&gt;

&lt;p&gt;If you run a canary suite against a provider API -- any provider, any&lt;br&gt;
suite -- and would consider contributing observations, or just want to&lt;br&gt;
tell me the collection design is wrong, the code and the board are open.&lt;/p&gt;

&lt;p&gt;Engine: &lt;strong&gt;SEISMOGRAPH&lt;/strong&gt; (Apache-2.0). Service: &lt;strong&gt;Drift Defense&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Board: &lt;a href="https://seismograph-weather.onrender.com/dashboard" rel="noopener noreferrer"&gt;https://seismograph-weather.onrender.com/dashboard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;https://github.com/Tania-coder/SEISMOGRAPH&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Probe: &lt;code&gt;pip install seismograph-probe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;DOI: &lt;a href="https://doi.org/10.5281/zenodo.21045517" rel="noopener noreferrer"&gt;https://doi.org/10.5281/zenodo.21045517&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All figures above are measured, dated, and reproducible from the public&lt;br&gt;
endpoint and the public Actions history at the time of writing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>opensource</category>
      <category>llm</category>
    </item>
    <item>
      <title>Did the model get worse, or is it just you? How to tell when an LLM API silently changes</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:30:34 +0000</pubDate>
      <link>https://dev.to/taniacoder/did-the-model-get-worse-or-is-it-just-you-how-to-tell-when-an-llm-api-silently-changes-1lia</link>
      <guid>https://dev.to/taniacoder/did-the-model-get-worse-or-is-it-just-you-how-to-tell-when-an-llm-api-silently-changes-1lia</guid>
      <description>&lt;p&gt;You ship a feature on top of &lt;code&gt;gpt-x&lt;/code&gt; or &lt;code&gt;claude-y&lt;/code&gt;. It works. Three weeks later your users say the outputs feel worse — vaguer, sloppier, failing on the same prompts that used to pass. You didn't change your code. Did the model change, or are you imagining it?&lt;/p&gt;

&lt;p&gt;This question eats an astonishing number of engineering hours, and it almost always dissolves into a vibes argument: half the thread says "it's clearly worse", the other half says "you have no evidence, it's confirmation bias." Both sides are right, because nobody has instrumented the one thing that would settle it. Here is how to settle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your gut is useless here (and so is a single comparison)
&lt;/h2&gt;

&lt;p&gt;Two things move at once when a hosted model "feels worse":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The provider's side&lt;/strong&gt; — a new checkpoint, a routing change, an inference optimization (batching, speculative decoding, quantization), or an infrastructure bug. You are not told when these happen. Often the provider doesn't announce it, and sometimes it isn't even a model change — it's an infra regression that only &lt;em&gt;looks&lt;/em&gt; like the model got dumber.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your side&lt;/strong&gt; — a prompt tweak, a temperature change, a new library version, a different context length, or just which prompts you happened to hit today.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From a single account, on a handful of anecdotal prompts, these two are &lt;strong&gt;indistinguishable&lt;/strong&gt;. That's the trap. You need two things to escape it: an &lt;em&gt;objective, dated signal&lt;/em&gt;, and a way to tell "it changed for me" from "it changed for everyone."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Fix a canary suite
&lt;/h2&gt;

&lt;p&gt;Pick a small, frozen set of prompts — 20 to 100 — that exercise the capabilities you actually depend on. Keep it small and cheap; this is a smoke detector, not a benchmark. Freeze it: same prompts, same parameters (&lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, &lt;code&gt;max_tokens&lt;/code&gt;), same model string, forever. The moment you change the suite you lose your baseline.&lt;/p&gt;

&lt;p&gt;For each canary, define a &lt;strong&gt;cheap scalar&lt;/strong&gt; you can compute automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON/schema validity rate (did it return parseable, valid output?)&lt;/li&gt;
&lt;li&gt;Output length (a sudden shift often precedes a quality change)&lt;/li&gt;
&lt;li&gt;A pass/fail against a fixed assertion (does the function it wrote compile?)&lt;/li&gt;
&lt;li&gt;A rubric score from a cheap grader&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are not trying to measure "quality" in the abstract. You are trying to detect &lt;em&gt;change&lt;/em&gt;. A boring, stable scalar is exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Record a baseline while things are good
&lt;/h2&gt;

&lt;p&gt;Run the suite on a schedule — hourly, daily, whatever your budget allows — and store &lt;code&gt;(timestamp, prompt_id, metric)&lt;/code&gt;. Do this &lt;strong&gt;before&lt;/strong&gt; you suspect anything. The baseline is the whole game: without "what normal looked like last month" you have nothing to compare today against. Estimate the mean &lt;code&gt;mu0&lt;/code&gt; and standard deviation &lt;code&gt;sigma0&lt;/code&gt; of your metric per prompt from a stable window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Run a change-point test, not a threshold
&lt;/h2&gt;

&lt;p&gt;A fixed threshold ("alert if success rate &amp;lt; 90%") is noisy and late. What you want is a &lt;strong&gt;sequential change-point detector&lt;/strong&gt; that accumulates small, consistent shifts and fires with a timestamp. The classic is &lt;strong&gt;CUSUM&lt;/strong&gt; (Page, 1954). It's a few lines:&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;cusum_alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mu0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigma0&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="mf"&gt;0.5&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="mf"&gt;5.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;Two-sided CUSUM on standardized observations.
    k = slack (in std devs), h = alarm threshold.
    Returns the index where a sustained shift is detected, or None.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;s_pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s_neg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;mu0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sigma0&lt;/span&gt;          &lt;span class="c1"&gt;# standardize against the baseline
&lt;/span&gt;        &lt;span class="n"&gt;s_pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&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="n"&gt;s_pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;z&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="c1"&gt;# catches upward shifts
&lt;/span&gt;        &lt;span class="n"&gt;s_neg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&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="n"&gt;s_neg&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;z&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="c1"&gt;# catches downward shifts
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s_pos&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s_neg&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;h&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;i&lt;/span&gt;                     &lt;span class="c1"&gt;# dated change-point
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it your per-prompt metric over time. Instead of "feels worse", you now get &lt;em&gt;"json_success_rate on prompt #14 stepped down starting Tuesday"&lt;/em&gt; — a claim with a date on it that you can put in a ticket. (&lt;code&gt;h=5.0&lt;/code&gt;, &lt;code&gt;k=0.5&lt;/code&gt; are reasonable starting points for standardized data; tune on your own history. Bayesian Online Change-Point Detection is a fancier alternative if you want posterior probabilities.)&lt;/p&gt;

&lt;p&gt;This alone puts you ahead of ~95% of "the model got worse" threads, which never get past anecdotes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — The part one account can never answer: "is it just me?"
&lt;/h2&gt;

&lt;p&gt;Here's the ceiling you hit: even with a perfect change-point signal, &lt;strong&gt;you still can't tell a provider-side change from your own drift&lt;/strong&gt;, because from one seat they look identical. Your CUSUM fired — but was it the model, or the LlamaIndex bump you did last week, or a flaky region?&lt;/p&gt;

&lt;p&gt;The only way to separate them is &lt;strong&gt;correlation across independent observers&lt;/strong&gt;. If your canary fires on Tuesday, and several &lt;em&gt;other&lt;/em&gt; organizations watching the &lt;em&gt;same&lt;/em&gt; model on the &lt;em&gt;same&lt;/em&gt; metric also fire around Tuesday, that agreement is the signal that it's provider-side — not your harness. One observer has N=1 forever; that's why these threads never converge. This is a genuinely different kind of detection: not "monitor my app" (every observability tool does that), but "did this change for &lt;em&gt;everyone&lt;/em&gt;, privately confirmed across the fleet."&lt;/p&gt;

&lt;p&gt;That cross-observer angle is what &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;SEISMOGRAPH&lt;/a&gt; is built around: a lightweight probe runs your canary suite, a change-point detector flags shifts locally, and only privacy-preserving distributional features — never your prompts or outputs — are shared, so a public "model weather" alert only fires when &lt;em&gt;multiple independent observers agree&lt;/em&gt;. A single org's signal stays private.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this actually work? A worked example
&lt;/h2&gt;

&lt;p&gt;The method isn't hypothetical. Take the September 2025 Anthropic incident: for a stretch, &lt;code&gt;claude-sonnet-4&lt;/code&gt; degraded because of infrastructure bugs — notably a context-window routing error — &lt;strong&gt;not&lt;/strong&gt; a model update (Anthropic said so explicitly in &lt;a href="https://www.anthropic.com/engineering/a-postmortem-of-three-recent-issues" rel="noopener noreferrer"&gt;their postmortem&lt;/a&gt;). Exactly the "is it the model or the infra?" fog this whole article is about.&lt;/p&gt;

&lt;p&gt;Replaying a canary suite over that period with the CUSUM method above: &lt;strong&gt;a seeded backtest flags it 38 days before the postmortem.&lt;/strong&gt; Not a live catch — a reproducible backtest, seeded and open — but it shows the signal was sitting in cheap distributional metrics well before the official acknowledgement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Probe (open source, Apache-2.0):&lt;/strong&gt; &lt;code&gt;pip install seismograph-probe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live "model weather" dashboard:&lt;/strong&gt; &lt;a href="https://driftdefense.dev?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=evergreen_howto" rel="noopener noreferrer"&gt;https://driftdefense.dev?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=evergreen_howto&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code + method:&lt;/strong&gt; &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;https://github.com/Tania-coder/SEISMOGRAPH&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need any of that to start — the four steps above work with a cron job, a CSV, and the ten lines of CUSUM. But if you want the "is it just me?" half answered, that needs more than one observer, and that's the part worth federating.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Did GPT / Claude / Gemini actually get worse recently?&lt;/strong&gt;&lt;br&gt;
Sometimes yes, sometimes it's your setup, and often it's a provider-side infra change that isn't a "worse model" at all. The point of this article is that you shouldn't answer from vibes — instrument it and get a dated signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I prove a hosted model changed?&lt;/strong&gt;&lt;br&gt;
You can't &lt;em&gt;prove&lt;/em&gt; it from one account, but you can get strong objective evidence: a frozen canary suite + a baseline + a change-point test gives you a dated shift. Provider-side vs. local drift is only separable with agreement across multiple independent observers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isn't this just LLM observability / evals?&lt;/strong&gt;&lt;br&gt;
Related but different. Evals score quality at a point in time; observability watches &lt;em&gt;your&lt;/em&gt; traffic. This is about detecting &lt;em&gt;change over time&lt;/em&gt; in a hosted dependency you don't control — and, uniquely, correlating that change across organizations to rule out "it's just me."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What metrics should I track?&lt;/strong&gt;&lt;br&gt;
Start with the cheapest ones that move when quality moves: schema/JSON validity, output length, and a single fixed pass/fail per canary. Add a rubric score later if you want resolution.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>monitoring</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your LLM didn't get worse. It changed — and nobody told you.</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:22:37 +0000</pubDate>
      <link>https://dev.to/taniacoder/your-llm-didnt-get-worse-it-changed-and-nobody-told-you-4ecl</link>
      <guid>https://dev.to/taniacoder/your-llm-didnt-get-worse-it-changed-and-nobody-told-you-4ecl</guid>
      <description>&lt;p&gt;It's 2am. Your JSON parse errors are up 12%. Latency: normal. Uptime: 100%. Your prompt didn't change. Your code didn't change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it you, or did the model silently change underneath you?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every team building on third-party LLM APIs eventually hits this moment. And the frustrating part: your monitoring stack can't answer the question. Latency, error rates, uptime — all green. The thing that changed is &lt;em&gt;behavior&lt;/em&gt;, and behavior emits no infrastructure signal.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;SEISMOGRAPH&lt;/a&gt; — an open-source, privacy-preserving early-warning network for exactly this failure mode. This post covers what silent drift is, how to detect it without leaking a single prompt, and a reproducible backtest where the detector flags a real, publicly documented provider incident &lt;strong&gt;38 days before the official postmortem&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "silent drift" actually is
&lt;/h2&gt;

&lt;p&gt;Provider APIs do not broadcast behavioral changes. An endpoint that returns 200 can still start producing subtly different outputs: degraded JSON fidelity, shifted response-length distributions, changed reasoning patterns.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical. In September 2025, Anthropic published a detailed postmortem describing &lt;strong&gt;three infrastructure bugs&lt;/strong&gt; that silently degraded Claude output quality across August–September 2025. The most long-lived one: a context-window routing error, introduced on August 5, that misrouted a fraction of Claude Sonnet 4 requests to servers configured for the 1M-token context window. It started at ~0.8% of traffic. On August 29 a load-balancer change escalated it to ~16%. The public postmortem landed on September 17.&lt;/p&gt;

&lt;p&gt;Note what this incident was &lt;em&gt;not&lt;/em&gt;: it was not a model update, and not intentional degradation — Anthropic was explicit about that. It was infrastructure. Which is precisely the point: &lt;strong&gt;the provider's own uptime and latency dashboards stayed green the whole time.&lt;/strong&gt; The signal only existed at the semantic layer.&lt;/p&gt;

&lt;p&gt;So the question I wanted to answer in Phase 0 of the project: would a lightweight behavioral canary have caught it, and how early?&lt;/p&gt;

&lt;h2&gt;
  
  
  The detection idea: canaries + change-point statistics
&lt;/h2&gt;

&lt;p&gt;The approach is deliberately boring:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;canary suite&lt;/strong&gt; — a fixed, content-addressed set of prompts (≤200, temperature 0) — runs on a schedule against the API you depend on. Deterministic prompts against a deterministic-ish endpoint give you a behavioral baseline. Every suite version is immutably hash-addressed, so a baseline can never silently mutate either.&lt;/p&gt;

&lt;p&gt;Each response is reduced to &lt;strong&gt;distributional features&lt;/strong&gt;: JSON parse success rate, output length, result counts. Not the text — the shape of the behavior.&lt;/p&gt;

&lt;p&gt;Those features feed a &lt;strong&gt;Page-CUSUM change-point detector&lt;/strong&gt; per &lt;code&gt;(model_tuple, metric_name)&lt;/code&gt; stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S+(n) = max(0, S+(n-1) + z(n) - k)    # upward shifts
S-(n) = max(0, S-(n-1) - z(n) - k)    # downward shifts
Alert when S+ or S- &amp;gt; h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CUSUM accumulates small standardized deviations from a learned baseline (mu0, sigma0 from the first 30 observations). That's what makes it good at this job: a 0.8% fault doesn't produce a visible spike on any single day, but it produces a &lt;em&gt;persistent small negative bias&lt;/em&gt; — and CUSUM integrates exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy boundary (the part that makes federation possible)
&lt;/h2&gt;

&lt;p&gt;One org's canary is a smoke detector. A &lt;em&gt;network&lt;/em&gt; of canaries across independent orgs is an early-warning system — if orgs are willing to participate. They're only willing if participation leaks nothing.&lt;/p&gt;

&lt;p&gt;So the probe enforces a hard perimeter. &lt;strong&gt;Raw prompts and outputs never leave your infrastructure.&lt;/strong&gt; What gets transmitted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SHA-256 hash of each response (not the response)&lt;/li&gt;
&lt;li&gt;DP-noised aggregates: &lt;code&gt;json_success_rate&lt;/code&gt;, &lt;code&gt;avg_output_length&lt;/code&gt; (Laplace mechanism, ε = 2.0 per flush)&lt;/li&gt;
&lt;li&gt;the content-addressed canary suite version hash&lt;/li&gt;
&lt;li&gt;a pseudonymous Ed25519 public key — batches are signed, org identity is not disclosed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the alerting side has a matching rule: &lt;strong&gt;a single organization's signal is never promoted to a public alert.&lt;/strong&gt; A quorum scorer requires ≥ 2 independent orgs to agree on the same &lt;code&gt;(model, metric)&lt;/code&gt; drift before anything goes public. That one gate filters probe bugs, network hiccups, and Sybil injection in a single move — and it means your private fleet data stays private fleet data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof: a reproducible backtest
&lt;/h2&gt;

&lt;p&gt;Here's the Phase 0 experiment (seeded, fully reproducible — &lt;code&gt;SEED=42&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;Reconstruct the Aug–Sep 2025 incident timeline from the public postmortem as synthetic daily probe traffic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Dates&lt;/th&gt;
&lt;th&gt;json_success_rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Jul 1 – Aug 4&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 1 (0.8% misrouting)&lt;/td&gt;
&lt;td&gt;Aug 5 – Aug 28&lt;/td&gt;
&lt;td&gt;0.982&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 2 (16% misrouting)&lt;/td&gt;
&lt;td&gt;Aug 29 – Sep 17&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Feed it through the exact CUSUM detector from the codebase (&lt;code&gt;h=5.0, k=0.5&lt;/code&gt;). Watch when it fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;Date&lt;/span&gt;        &lt;span class="k"&gt;Phase&lt;/span&gt;          &lt;span class="k"&gt;rate&lt;/span&gt;    &lt;span class="k"&gt;S&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;      &lt;span class="k"&gt;note&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-03&lt;/span&gt;  &lt;span class="k"&gt;Stable&lt;/span&gt;         &lt;span class="mf"&gt;0.9902&lt;/span&gt;  &lt;span class="mf"&gt;0.000&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-04&lt;/span&gt;  &lt;span class="k"&gt;Stable&lt;/span&gt;         &lt;span class="mf"&gt;0.9881&lt;/span&gt;  &lt;span class="mf"&gt;0.000&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-05&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9855&lt;/span&gt;  &lt;span class="mf"&gt;0.598&lt;/span&gt;   &lt;span class="err"&gt;[&lt;/span&gt;&lt;span class="k"&gt;bug&lt;/span&gt; &lt;span class="k"&gt;introduced&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-06&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9857&lt;/span&gt;  &lt;span class="mf"&gt;1.142&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-07&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9786&lt;/span&gt;  &lt;span class="mf"&gt;3.309&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-08&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9877&lt;/span&gt;  &lt;span class="mf"&gt;3.396&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-09&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9816&lt;/span&gt;  &lt;span class="mf"&gt;4.889&lt;/span&gt;
&lt;span class="ld"&gt;2025-08-10&lt;/span&gt;  &lt;span class="k"&gt;Phase&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="mf"&gt;0.9777&lt;/span&gt;  &lt;span class="mf"&gt;7.278&lt;/span&gt;   &lt;span class="err"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;FIRST&lt;/span&gt; &lt;span class="k"&gt;ALERT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First alert: &lt;strong&gt;August 10&lt;/strong&gt; — five days into the subtle 0.8% window.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;19 days before&lt;/strong&gt; the escalation made the problem visible to users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;38 days before&lt;/strong&gt; the official postmortem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can re-run this yourself: &lt;code&gt;python scripts/anthropic_backtest.py&lt;/code&gt; in the repo regenerates the full report and asserts the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this does and doesn't prove
&lt;/h3&gt;

&lt;p&gt;I want to be precise here, because this is where drift-detection claims usually get slippery:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This is a backtest on synthetic data&lt;/strong&gt;, not a live catch. The timeline and magnitudes come from the public postmortem; real probe noise may differ, and real lead time could be shorter or longer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a single simulated observer.&lt;/strong&gt; A real public alert requires the ≥2-org quorum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The simulation omits live DP noise&lt;/strong&gt;, which adds variance and may delay an alert by a few days.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What it &lt;em&gt;does&lt;/em&gt; prove: a metric as cheap as "did the JSON parse?" — measured consistently, at temperature 0, against a fixed suite — carries enough signal to surface a 0.8%-of-traffic infrastructure fault weeks before it becomes user-visible. Conventional monitoring is structurally blind to this class of failure. A $0.10/day canary is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one pass
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your infra                          public
┌─────────────────────────┐
│ probe SDK               │
│  canary suite (≤200,    │
│  temp 0, hash-addressed)│
│  → features + DP noise  │
│  → Ed25519 signature    │
└──────────┬──────────────┘
           ▼
   ingestion gateway (FastAPI)
   schema validation, signature check
           ▼
   CUSUM per (model, metric)  →  local alert (private)
           ▼
   quorum scorer (≥2 orgs)    →  public drift alert
           ▼
   "model weather" dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stack: Python probe SDK (OpenTelemetry GenAI semantic conventions), FastAPI gateway, SQLAlchemy storage, CUSUM + Bayesian online change-point detection, 127 tests, ruff-clean CI. Apache-2.0.&lt;/p&gt;

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

&lt;p&gt;The public &lt;strong&gt;"model weather" dashboard&lt;/strong&gt; (no login) shows live drift status for four production models:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://seismograph-weather.onrender.com/dashboard" rel="noopener noreferrer"&gt;seismograph-weather.onrender.com/dashboard&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(free host — first load may take ~30s)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run the probe against your own endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;seismograph-probe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or clone the whole stack — gateway, dashboard, the federated quorum demo (&lt;code&gt;scripts/demo_simulation.py&lt;/code&gt; shows two orgs independently confirming a drift and the public dashboard flipping only when quorum is met):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;github.com/Tania-coder/SEISMOGRAPH&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've been burned by a silent model change — I'd like to hear the story. That failure mode is exactly what this network exists to catch, and every additional independent observer makes the signal stronger.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;SEISMOGRAPH is open source (Apache-2.0), archived at &lt;a href="https://doi.org/10.5281/zenodo.21045517" rel="noopener noreferrer"&gt;DOI 10.5281/zenodo.21045517&lt;/a&gt;. Built by &lt;a href="https://github.com/Tania-coder" rel="noopener noreferrer"&gt;Tatiana Radchenko&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
    <item>
      <title>SEISMOGRAPH now has a live public dashboard</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:04:48 +0000</pubDate>
      <link>https://dev.to/taniacoder/seismograph-now-has-a-live-public-dashboard-nhj</link>
      <guid>https://dev.to/taniacoder/seismograph-now-has-a-live-public-dashboard-nhj</guid>
      <description>&lt;p&gt;When I wrote about detecting silent LLM provider drift, the dashboard was localhost-only — you had to take my word for it. Now it's live and you can touch it:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://seismograph-weather.onrender.com/dashboard" rel="noopener noreferrer"&gt;https://seismograph-weather.onrender.com/dashboard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It shows real-time "model weather" for 4 production models (GPT-4o, GPT-4o-mini, Claude 3.5 Sonnet, Claude 3 Haiku) — no login. The same engine flagged the Claude 3.5 Sonnet degradation 38 days before the official postmortem on a reproducible backtest.&lt;/p&gt;

&lt;p&gt;Open-source, Apache-2.0, 107 tests.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pip install seismograph-probe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/Tania-coder/SEISMOGRAPH" rel="noopener noreferrer"&gt;https://github.com/Tania-coder/SEISMOGRAPH&lt;/a&gt; — building in public, feedback welcome.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl327p4l00scdk8ag9fue.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl327p4l00scdk8ag9fue.png" alt=" " width="799" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>I've been building SEISMOGRAPH for 3 weeks. Here's what shipped today</title>
      <dc:creator>Tatiana Radchenko 🇩🇰</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:42:47 +0000</pubDate>
      <link>https://dev.to/taniacoder/ive-been-building-seismograph-for-3-weeks-heres-what-shipped-today-401h</link>
      <guid>https://dev.to/taniacoder/ive-been-building-seismograph-for-3-weeks-heres-what-shipped-today-401h</guid>
      <description>&lt;p&gt;tl;dr: pip install seismograph-probe — a Python probe that detects silent LLM API drift using CUSUM change-point detection, with privacy-preserving signal aggregation. 103 tests passing. Dashboard live. Open source.&lt;/p&gt;

&lt;p&gt;Three weeks ago I asked a question I couldn't answer:&lt;/p&gt;

&lt;p&gt;"Did GPT-4 just change underneath me, or is it my prompt?"&lt;/p&gt;

&lt;p&gt;No latency spike. No downtime. Just subtly different outputs from the same prompts, same parameters, same everything. I spent days debugging something that wasn't my fault.&lt;/p&gt;

&lt;p&gt;So I built a detector.&lt;/p&gt;

&lt;p&gt;Today I'm shipping it publicly.&lt;/p&gt;

&lt;p&gt;What's actually working right now&lt;br&gt;
This isn't a concept post. Here's what's live:&lt;br&gt;
The probe SDK — on PyPI today&lt;br&gt;
pip install seismograph-probe&lt;/p&gt;

&lt;p&gt;from probe.sdk import ProbeSDK&lt;/p&gt;

&lt;p&gt;sdk = ProbeSDK(provider="openai", model="gpt-4-turbo")&lt;/p&gt;

&lt;p&gt;result = sdk.run_canary_suite()&lt;/p&gt;

&lt;p&gt;print(result.drift_score)  # 0.0 stable → 1.0 significant shift&lt;/p&gt;

&lt;p&gt;The probe runs ≤200 canary prompts at temperature=0 daily. These are semantically stable tasks — deterministic questions, structured reasoning, format-adherence checks. The goal is a reliable behavioral baseline, not a capability benchmark.&lt;/p&gt;

&lt;p&gt;Privacy boundary: raw prompts and model outputs never leave your machine. The probe extracts SHA-256 feature hashes, distributional stats, and DP-noised aggregates. That's all that transmits.&lt;br&gt;
CUSUM change-point detection — running&lt;br&gt;
The correlation engine uses CUSUM (Cumulative Sum) — a sequential statistical test that's sensitive to gradual drift, not just threshold crossings.&lt;/p&gt;

&lt;p&gt;When I backtest against a known LLM behavioral shift event (Aug–Sep 2025):&lt;/p&gt;

&lt;p&gt;Day 0:   CUSUM statistic: 0.12  (stable baseline)&lt;/p&gt;

&lt;p&gt;Day 11:  First elevation detected&lt;/p&gt;

&lt;p&gt;Day 19:  Alert threshold crossed  ← SEISMOGRAPH fires&lt;/p&gt;

&lt;p&gt;Day 57:  Public postmortem published&lt;/p&gt;

&lt;p&gt;38-day lead time. That's the number I keep coming back to.&lt;br&gt;
Ingestion gateway — deployed&lt;br&gt;
FastAPI gateway with:&lt;/p&gt;

&lt;p&gt;Ed25519-signed batch verification (unsigned batches rejected atomically)&lt;br&gt;
Pydantic v2 schema validation&lt;br&gt;
SQLAlchemy ORM + SQLite (ClickHouse migration planned for Phase 2)&lt;br&gt;
Bearer token auth on audit export endpoint&lt;br&gt;
Public dashboard — live at localhost, hosted version coming&lt;br&gt;
Dark-mode model weather dashboard. Polls /v1/weather every 60 seconds. Shows per-model drift status across your fleet.&lt;/p&gt;

&lt;p&gt;GET /v1/weather&lt;/p&gt;

&lt;p&gt;→ [{ "model": "gpt-4-turbo", "status": "STABLE", ... },&lt;/p&gt;

&lt;p&gt;{ "model": "claude-3-5-sonnet", "status": "STABLE", ... }]&lt;br&gt;
Test suite — 103/103 passing&lt;br&gt;
Not "it works on my machine." 103 tests across probe SDK, storage layer, gateway, CUSUM detector, privacy boundary, and auth. Zero ruff violations across 22 Python files.&lt;br&gt;
Provider ToS compliance — checked&lt;br&gt;
Before adding any provider to the canary suite, I verify it doesn't violate their Terms of Service. Done for: OpenAI ✅, Anthropic ✅, Google Gemini ✅, Mistral ✅, Cohere ✅. Documented in docs/PROVIDER_TOS_CHECKS.md.&lt;/p&gt;

&lt;p&gt;What's NOT done yet (being honest)&lt;br&gt;
No hosted gateway yet. The gateway runs locally. Public ingestion endpoint is Phase 1.&lt;br&gt;
No Bayesian online detector yet. CUSUM is running. BayesianOnlineDetector.update() is deferred — it's on the backlog.&lt;br&gt;
No federation yet. Right now it's single-org. The cross-observer agreement scoring that makes it genuinely valuable is Phase 2.&lt;br&gt;
No cloud dashboard. localhost:8000 only for now.&lt;/p&gt;

&lt;p&gt;This is Phase 0: I'm proving the detection logic works before scaling it.&lt;/p&gt;

&lt;p&gt;The architecture in one diagram&lt;br&gt;
Your app&lt;/p&gt;

&lt;p&gt;│  (gen_ai.* OTel spans)&lt;/p&gt;

&lt;p&gt;▼&lt;/p&gt;

&lt;p&gt;ProbeSDK&lt;/p&gt;

&lt;p&gt;│  SHA-256 hashes + DP-noised stats only&lt;/p&gt;

&lt;p&gt;│  Ed25519-signed batch&lt;/p&gt;

&lt;p&gt;▼&lt;/p&gt;

&lt;p&gt;Ingestion Gateway (FastAPI)&lt;/p&gt;

&lt;p&gt;│  signature check → schema validation → store&lt;/p&gt;

&lt;p&gt;▼&lt;/p&gt;

&lt;p&gt;SQLite / ClickHouse&lt;/p&gt;

&lt;p&gt;│&lt;/p&gt;

&lt;p&gt;▼&lt;/p&gt;

&lt;p&gt;CUSUM Detector ──► DriftAlert&lt;/p&gt;

&lt;p&gt;│&lt;/p&gt;

&lt;p&gt;▼&lt;/p&gt;

&lt;p&gt;/v1/weather dashboard&lt;/p&gt;

&lt;p&gt;OTel-native throughout. If you're already emitting gen_ai.* spans, the adapter plugs straight in.&lt;/p&gt;

&lt;p&gt;Why this matters (and why it has to be federated)&lt;br&gt;
A single organization's drift signal is almost useless. Your outputs change because your users change. Your prompts change. Your context windows change.&lt;/p&gt;

&lt;p&gt;But if 15 independent organizations running the same canary suite all see correlated semantic drift on the same day — that's a model change. That's the signal you can act on.&lt;/p&gt;

&lt;p&gt;Single-org signal = private fleet data (yours only).&lt;br&gt;
Multi-org correlated signal = public drift alert.&lt;/p&gt;

&lt;p&gt;That's the design. Federation is Phase 2. The local probe is shippable today.&lt;/p&gt;

&lt;p&gt;Try it / follow along&lt;br&gt;
GitHub: github.com/Tania-coder/SEISMOGRAPH&lt;br&gt;
PyPI: pypi.org/project/seismograph-probe&lt;/p&gt;

&lt;p&gt;If you've been burned by a silent model change — I want to hear about it. Open an issue, or find me on Twitter @tatyanti.&lt;/p&gt;

&lt;p&gt;The probe is Apache 2.0. The gateway will be too.&lt;/p&gt;

&lt;p&gt;Tatiana Radchenko · AI Infrastructure · Aarhus, Denmark&lt;br&gt;
Building in public. Phase 0 of 3.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
