<?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: Panagiotis Gkilis</title>
    <description>The latest articles on DEV Community by Panagiotis Gkilis (@bedvibe_studios).</description>
    <link>https://dev.to/bedvibe_studios</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%2F4057536%2F53ace3a5-ee4f-4cc1-abcb-f85ebf51ddc6.jpg</url>
      <title>DEV Community: Panagiotis Gkilis</title>
      <link>https://dev.to/bedvibe_studios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bedvibe_studios"/>
    <language>en</language>
    <item>
      <title>I Assumed My Retriever Failed at Stage One. The Bigger Failure Was at Stage Three.</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:38:31 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/i-assumed-my-retriever-failed-at-stage-one-the-bigger-failure-was-at-stage-three-2k0k</link>
      <guid>https://dev.to/bedvibe_studios/i-assumed-my-retriever-failed-at-stage-one-the-bigger-failure-was-at-stage-three-2k0k</guid>
      <description>&lt;p&gt;Hierarchical retrieval is supposed to help on long documents. Pick the right chapter, then search inside it. Narrow the haystack, then find the needle.&lt;/p&gt;

&lt;p&gt;On my long-book benchmark it came &lt;strong&gt;third of five&lt;/strong&gt;, behind a chapter-summary chain and flat chunk RAG, and only barely ahead of naively grabbing the end of the book.&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;context precision&lt;/th&gt;
&lt;th&gt;context recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;naive_first_context&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1475&lt;/td&gt;
&lt;td&gt;0.1458&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;naive_last_context&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4150&lt;/td&gt;
&lt;td&gt;0.3302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flat_chunk_rag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3375&lt;/td&gt;
&lt;td&gt;0.4302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chapter_summary_chain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.4771&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hierarchical_book_rag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3475&lt;/td&gt;
&lt;td&gt;0.3365&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My hypothesis was &lt;strong&gt;error compounding&lt;/strong&gt;: if stage one picks the wrong chapter, every later stage searches the wrong text. That turned out to be true and incomplete — which is worse than being wrong, because it would have sent me to fix the right thing and stop there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ablation
&lt;/h2&gt;

&lt;p&gt;Same corpus (240,767 words), same 80 gold questions, five variants, 400 scored rows. Two of the variants are &lt;em&gt;given&lt;/em&gt; the correct chapter — diagnostics, not deployable methods — purely to measure how much is lost before versus after chapter selection.&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;recall&lt;/th&gt;
&lt;th&gt;precision&lt;/th&gt;
&lt;th&gt;hit@1&lt;/th&gt;
&lt;th&gt;hit@3&lt;/th&gt;
&lt;th&gt;hit@5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hier_current&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3365&lt;/td&gt;
&lt;td&gt;0.3475&lt;/td&gt;
&lt;td&gt;0.2000&lt;/td&gt;
&lt;td&gt;0.3875&lt;/td&gt;
&lt;td&gt;0.4375&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hier_no_neighbors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.4771&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.4000&lt;/td&gt;
&lt;td&gt;0.2000&lt;/td&gt;
&lt;td&gt;0.3875&lt;/td&gt;
&lt;td&gt;0.4375&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chapter_summary_chain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4771&lt;/td&gt;
&lt;td&gt;0.4000&lt;/td&gt;
&lt;td&gt;0.2000&lt;/td&gt;
&lt;td&gt;0.3875&lt;/td&gt;
&lt;td&gt;0.4375&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hier_oracle_chapter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7844&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.6796&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hier_oracle_chapter_neighbors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.7688&lt;/td&gt;
&lt;td&gt;0.6925&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Deleting a feature closed the entire gap.&lt;/strong&gt; &lt;code&gt;hier_no_neighbors&lt;/code&gt; lands on 0.4771 / 0.4000 — the same numbers as the baseline, to four decimals. The method I thought was structurally worse wasn't worse. It was carrying a stage that was hurting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And look at hit@k.&lt;/strong&gt; Identical across the first three rows. Chapter selection did not change at all between &lt;code&gt;hier_current&lt;/code&gt; and &lt;code&gt;hier_no_neighbors&lt;/code&gt; — same code. Every point of recall that moved, moved downstream, in the stage that pads results with adjacent chunks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting failures directly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;failure type&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;neighbor_dilution&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wrong_chapter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;right_chapter_wrong_chunk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My hypothesis accounted for 27 questions. The stage I hadn't suspected accounted for 32. I would have fixed chapter routing, seen a real improvement, and never looked at expansion — because the improvement would have confirmed the theory I walked in with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neighbour expansion is not simply bad
&lt;/h2&gt;

&lt;p&gt;With the oracle chapter, adding neighbours moves recall 0.7844 → 0.7688 and precision 0.6796 → &lt;strong&gt;0.6925&lt;/strong&gt;. Recall down slightly, precision &lt;em&gt;up&lt;/em&gt;. When you're already in the right chapter, expansion is roughly a wash and can help precision.&lt;/p&gt;

&lt;p&gt;The damage happens when expansion runs on top of an uncertain chapter choice, crowding out the good chunks you did find with text that is only adjacent, not relevant. It is a stage whose value depends on the confidence of the stage above it — exactly the interaction a single end-to-end score cannot show you.&lt;/p&gt;

&lt;p&gt;So the finding is not "turn off neighbour expansion." It is &lt;strong&gt;treat it as a tunable stage rather than a default&lt;/strong&gt;, conditioned on router confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence that worked
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Disable each optional stage in turn.&lt;/strong&gt; Cheap, and it found the larger of my two problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insert an oracle at each boundary.&lt;/strong&gt; Not deployable, but it partitions the loss into before/after.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only then attribute the failure.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I had skipped to step three, I'd have published "hierarchical RAG underperforms chapter-summary retrieval on long narrative corpora" — well-supported by my headline numbers, and wrong about the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;One private narrative corpus, 80 gold questions written from the corpus rather than by independent annotators. Evidence-term overlap scoring, which is a lightweight audit signal and not full semantic correctness. &lt;strong&gt;No confidence intervals&lt;/strong&gt; — the package didn't compute them and I won't imply precision I didn't measure. The oracle variants read gold labels and are not production-realistic. This is not a universal rule against hierarchical retrieval.&lt;/p&gt;




&lt;p&gt;Paper: &lt;em&gt;Diagnosing Hierarchical Retrieval Failure in Long-Document RAG&lt;/em&gt;&lt;br&gt;
DOI: &lt;a href="https://doi.org/10.5281/zenodo.20692450" rel="noopener noreferrer"&gt;10.5281/zenodo.20692450&lt;/a&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>rag</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>One Integer Deleted the Stop Token From My Loss. The Curve Never Noticed.</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:37:39 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/one-integer-deleted-the-stop-token-from-my-loss-the-curve-never-noticed-2dll</link>
      <guid>https://dev.to/bedvibe_studios/one-integer-deleted-the-stop-token-from-my-loss-the-curve-never-noticed-2dll</guid>
      <description>&lt;p&gt;For a long time I had a model that trained cleanly and produced nothing usable. The loss fell. Gradients were finite. Nothing crashed. It simply never learned to stop — every generation ran to the length cap and got truncated.&lt;/p&gt;

&lt;p&gt;The cause was two lines of my own source that were individually correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collision
&lt;/h2&gt;

&lt;p&gt;In a neural codec language model the audio vocabulary has a fixed size, and the stop token is one extra class on top. So the output layer is one wider than the codebook:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nn.Linear(d_model, NUM_AUDIO_TOKENS + 1)   # 1025 classes: 0..1023 audio, 1024 = EOS
eos_id = NUM_AUDIO_TOKENS                  # 1024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Correct. EOS is the last class and the layer has room for it.&lt;/p&gt;

&lt;p&gt;Then, elsewhere, the loss:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;F.cross_entropy(logits, targets, ignore_index=NUM_AUDIO_TOKENS)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Also reasonable on its own. &lt;code&gt;ignore_index&lt;/code&gt; is how you skip padding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But the sentinel and the stop token are the same integer.&lt;/strong&gt; Every position whose target was "stop" was discarded before the loss was computed. Not down-weighted — removed. The model was never once shown an example of stopping, across every epoch it ever ran.&lt;/p&gt;

&lt;p&gt;PyTorch defaults &lt;code&gt;ignore_index&lt;/code&gt; to &lt;code&gt;-100&lt;/code&gt; precisely because it must be a value that can never be a real class. Replace it with a real vocabulary constant and that guarantee is gone, silently: shapes valid, loss finite, run healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The curves are identical
&lt;/h2&gt;

&lt;p&gt;Minimal reproduction, two arms differing only in the sentinel value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The broken arm finished at 0.0035. The fixed arm finished at 0.0034.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same curve to any human, any dashboard, any threshold you would write. One has a working objective and one has an objective with a hole in it, and the loss cannot distinguish them — because the loss is computed &lt;em&gt;over what survived the mask&lt;/em&gt;. A metric cannot report on examples it never received.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second half: lowest loss, worse model
&lt;/h2&gt;

&lt;p&gt;200 epochs at lr = 2e-5, evaluated every 50 on a fixed split held out by utterance (n = 32). &lt;em&gt;Rank&lt;/em&gt; is the stop token's position among 1025 classes at the true terminal frame.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;epoch&lt;/th&gt;
&lt;th&gt;training loss&lt;/th&gt;
&lt;th&gt;mean P(stop)&lt;/th&gt;
&lt;th&gt;argmax = stop&lt;/th&gt;
&lt;th&gt;self-terminated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;2.199&lt;/td&gt;
&lt;td&gt;0.4218&lt;/td&gt;
&lt;td&gt;16/32&lt;/td&gt;
&lt;td&gt;2/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.628&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.4655&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18/32&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.302&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2159&lt;/td&gt;
&lt;td&gt;8/32&lt;/td&gt;
&lt;td&gt;3/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1.429&lt;/td&gt;
&lt;td&gt;0.1818&lt;/td&gt;
&lt;td&gt;5/32&lt;/td&gt;
&lt;td&gt;3/8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Between epochs 100 and 150 the training loss &lt;strong&gt;improved by 20%&lt;/strong&gt; while mean P(stop) fell &lt;strong&gt;54%&lt;/strong&gt;, top-1 stop accuracy went 18/32 to 8/32, and autonomous termination halved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selecting by lowest training loss returns epoch 150. The model that terminates reliably is epoch 100.&lt;/strong&gt; The reversal was observed independently in a shorter run, which is why I am willing to state it.&lt;/p&gt;

&lt;p&gt;"Save the checkpoint with the lowest validation loss" is the default in more or less every training script in existence, including mine. On this run it was actively the wrong rule, and the number it optimised looked better the whole way down.&lt;/p&gt;

&lt;p&gt;For completeness, what fixing the collision bought on the real model, same held-out split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;checkpoint&lt;/th&gt;
&lt;th&gt;mean P(stop)&lt;/th&gt;
&lt;th&gt;argmax = stop&lt;/th&gt;
&lt;th&gt;rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;random initialisation&lt;/td&gt;
&lt;td&gt;0.001848&lt;/td&gt;
&lt;td&gt;0/32&lt;/td&gt;
&lt;td&gt;111.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after correction&lt;/td&gt;
&lt;td&gt;0.4655&lt;/td&gt;
&lt;td&gt;18/32&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;End-to-end synthesis then terminated on its own at frame 203 against a 350-frame ceiling. Before the fix that was impossible by two independent mechanisms: the training-time collision above, and an inference-time mask that set the logit of every index at or beyond the codebook size — including end-of-sequence — to negative infinity before sampling.&lt;/p&gt;

&lt;p&gt;One honest loose end: P(stop) plateaus in the range &lt;strong&gt;0.35–0.47&lt;/strong&gt; across two learning rates and a 5.9x increase in training data (224 to 1313 utterances, with speaker, language, emotion and the held-out split all held constant). So the plateau is not a data-quantity limit. I have no confirmed explanation for it. Terminal timing in speech is genuinely ambiguous, and hedging with the stop token ranked second of 1025 may simply be correct behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same integer, safe one stage over
&lt;/h2&gt;

&lt;p&gt;The detail I find most instructive: the identical line is harmless in the next stage of the same model.&lt;/p&gt;

&lt;p&gt;The autoregressive stage predicts the first codebook plus EOS — &lt;strong&gt;1025 classes&lt;/strong&gt;, so &lt;code&gt;1024&lt;/code&gt; is a real class and using it as a sentinel is fatal. The non-autoregressive stage predicts audio codes only — &lt;strong&gt;1024 classes&lt;/strong&gt;, so &lt;code&gt;1024&lt;/code&gt; is out of range and the exact same &lt;code&gt;ignore_index&lt;/code&gt; is correct.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;+ 1&lt;/code&gt; in a different file decides whether that line destroys your objective. Both stages read identically at the call site. That is not a mistake you catch by reading carefully; it is a mistake you catch by checking a relationship between two numbers that never appear together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which is what a linter is for
&lt;/h2&gt;

&lt;p&gt;This is now two rules in &lt;code&gt;trainproof&lt;/code&gt;, my linter for training runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel collision.&lt;/strong&gt; Compare the output layer's class count against &lt;code&gt;ignore_index&lt;/code&gt;. If the sentinel is a valid class, fail. If it sits exactly one past the end, say so explicitly — because the same integer is fatal one class earlier, and that distinction deserves to be visible rather than silently passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead class.&lt;/strong&gt; Accumulate which classes ever reach the loss as a positive target during the first epoch, then flag any class the output layer can emit but that never once appears as an answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My own two stages are the regression fixture — the fatal case and its safe twin, one integer apart. Not a synthetic example.&lt;/p&gt;

&lt;p&gt;The design decision worth stating: the dead-class rule only fires when coverage is already broad and few classes are missing. One unseen class out of 1025 is a structural exclusion. Nine hundred unseen is a small sample. Without that guard the check screams on every short run and gets switched off — which is how good checks die.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you take one thing from this
&lt;/h2&gt;

&lt;p&gt;I do not think this is rare. Any codebase where a padding sentinel, an end-of-sequence id and a vocabulary size are all defined as named constants in different files can produce it, and none of your instrumentation will complain.&lt;/p&gt;

&lt;p&gt;If you fine-tune anything with a custom &lt;code&gt;ignore_index&lt;/code&gt;, go and check it against your output layer's width right now. It takes thirty seconds and the failure mode is completely silent.&lt;/p&gt;




&lt;p&gt;Paper: &lt;em&gt;The Loss Curve Is Not a Sufficient Statistic — Silent Objective Failures from Sentinel-Class Collisions in Neural Codec Language Models&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;DOI: &lt;a href="https://doi.org/10.5281/zenodo.21864658" rel="noopener noreferrer"&gt;10.5281/zenodo.21864658&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pip install trainproof&lt;/code&gt; — &lt;a href="https://github.com/Mormolykos/trainproof" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>deeplearning</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Fourteen Speaker Encoders Heard the Same Voice. Their Error Rates Differed Five-Fold.</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:37:35 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/fourteen-speaker-encoders-heard-the-same-voice-their-error-rates-differed-five-fold-1pmn</link>
      <guid>https://dev.to/bedvibe_studios/fourteen-speaker-encoders-heard-the-same-voice-their-error-rates-differed-five-fold-1pmn</guid>
      <description>&lt;p&gt;A speaker encoder turns a few seconds of speech into a vector, and the distance between two vectors is treated as an answer to "are these the same person." That number gets used for authentication, for voice-cloning pipelines, and increasingly as the yardstick in papers asking whether a synthetic voice preserved someone's identity.&lt;/p&gt;

&lt;p&gt;The number is not neutral. It is a reading from an instrument, and I wanted to know how much the instrument was contributing.&lt;/p&gt;

&lt;p&gt;So I ran fourteen of them over an identical, frozen list of &lt;strong&gt;11,935 trials&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corpus is why the question is answerable
&lt;/h2&gt;

&lt;p&gt;Most emotional-speech corpora use different sentences for different emotions. A model can then learn angry &lt;em&gt;vocabulary&lt;/em&gt; instead of angry &lt;em&gt;delivery&lt;/em&gt;, and you cannot separate them afterwards.&lt;/p&gt;

&lt;p&gt;This one can't make that mistake. &lt;strong&gt;Eight speakers each recorded the same 1,360 sentences in six phonation states&lt;/strong&gt; — neutral, happy, angry, scared, shouting, whisper. Lexical content is fixed while phonation varies.&lt;/p&gt;

&lt;p&gt;Enrolment is neutral speech. The test is whether the encoder still recognises the person while they shout or whisper. The trial list was frozen in writing before a single model loaded, and every encoder scored the identical list — so every comparison is &lt;strong&gt;paired&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Equal error rate spans 0.047 to 0.233 across the panel&lt;/strong&gt; — same trials, same audio, nothing varying but the encoder. &lt;strong&gt;29 of 91&lt;/strong&gt; pairwise comparisons survive Holm–Bonferroni correction.&lt;/p&gt;

&lt;p&gt;If you are choosing an encoder for expressive audio, that five-fold spread is your decision. It is larger than most architectural differences people argue about.&lt;/p&gt;

&lt;h2&gt;
  
  
  CAMPPlus is last, without exception
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Highest error of the fourteen, all 13 of its comparisons surviving family-wise correction&lt;/strong&gt; — for every one of the eight speakers and every one of the six conditions. Margins run from &lt;strong&gt;+0.087 [+0.064, +0.127]&lt;/strong&gt; against ECAPA-TDNN to &lt;strong&gt;+0.185 [+0.150, +0.215]&lt;/strong&gt; against the lowest-error encoder.&lt;/p&gt;

&lt;p&gt;The mechanism is the useful part. Its &lt;em&gt;genuine&lt;/em&gt; scores are unremarkable; its &lt;strong&gt;impostor&lt;/strong&gt; scores are the problem. It places different people at cosine &lt;strong&gt;0.28–0.34&lt;/strong&gt; where ECAPA-TDNN and ReDimNet place them near &lt;strong&gt;0.034&lt;/strong&gt;. That is a compressed embedding space — different speakers land close together.&lt;/p&gt;

&lt;p&gt;It is visible at neutral speech too (0.031 EER where several ReDimNet checkpoints reach 0.000). Expressive speech doesn't cause it. Expressive speech makes it visible.&lt;/p&gt;

&lt;p&gt;A widely used open-source TTS system conditions on CAMPPlus. To be precise: that is a verified fact about its source code, &lt;strong&gt;not&lt;/strong&gt; a measurement of its audio. I measured encoders in isolation on human recordings. Whether that choice degrades output is a separate experiment I have not run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parameter count does not order the panel
&lt;/h2&gt;

&lt;p&gt;Spearman ρ(parameters, expressive EER) = &lt;strong&gt;+0.135&lt;/strong&gt; over fourteen encoders. The 20.8M model ranks 13th of 14. A 4.81M model ranks 1st. Published VoxCeleb1-O position doesn't transfer either — that leaderboard is computed on calm read speech, which is not the condition failing you.&lt;/p&gt;

&lt;p&gt;I retracted my claim about the winner, incidentally. It survives as an argmin (selected in 89.4% of speaker resamples, leading all eight leave-one-speaker-out refits) but after correction it is separated from only &lt;strong&gt;7 of 13&lt;/strong&gt;. The defensible wording is &lt;em&gt;lowest observed error&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A clean pre-registered negative
&lt;/h2&gt;

&lt;p&gt;Spectral denoising does not materially change the measurement: &lt;strong&gt;|ΔEER| ≤ 0.039 over 10,765 twinned trials&lt;/strong&gt;, and whispered speech — where removing aspiration noise was the stated worry — is among the &lt;em&gt;least&lt;/em&gt; affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten retracted claims, published on purpose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An 11.7 dB spectral effect, asserted with an instrument whose F0 artifact budget is 12.09 dB.&lt;/strong&gt; Sweeping F0 with the spectral envelope held fixed moved the descriptor further than the claimed effect. It was never a measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Shouting does not transfer."&lt;/strong&gt; Judged on RMS after peak-normalising every clip, so the figure was crest factor, not loudness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tie-break defect in the EER estimator.&lt;/strong&gt; &lt;code&gt;argmin&lt;/code&gt; returns the first minimiser rather than the balanced operating point — invisible at unit weights, common under the integer speaker multiplicities a bootstrap runs at. Fixing it moved one comparison across Holm, &lt;strong&gt;28 → 29 of 91&lt;/strong&gt;. The correction &lt;em&gt;favoured&lt;/em&gt; the paper, which is why it is published rather than quietly kept.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I have now paid for three times: &lt;strong&gt;validate the instrument before measuring with it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Statistics
&lt;/h2&gt;

&lt;p&gt;The trial list is a directed graph on 8 speakers — genuine trials are self-loops, impostor trials the 56 ordered edges. The independent unit is the speaker, on both sides. So the resample is a &lt;strong&gt;vertex bootstrap&lt;/strong&gt;: draw 8 vertices with replacement, take the induced sub-multigraph, weight the impostor edge by the product of multiplicities.&lt;/p&gt;

&lt;p&gt;An earlier scheme resampled enrolment speakers only, dropping a speaker as enrollee while leaving them in as impostor. Fixing it widened marginal intervals (width ratio median 1.11, max 1.58) and &lt;strong&gt;cost two already-written claims&lt;/strong&gt;. Paired differences barely moved, because both encoders score the same impostor clips and the dependence cancels in the difference.&lt;/p&gt;

&lt;p&gt;B = 20,000, seed fixed, families declared before testing, Holm–Bonferroni at α = 0.05.&lt;/p&gt;

&lt;h2&gt;
  
  
  The companion result, which is the cheap fix
&lt;/h2&gt;

&lt;p&gt;Enrolling on expressive material instead of neutral-only recovers the loss &lt;strong&gt;on all 14 encoders&lt;/strong&gt;, every one surviving Holm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mechanism is the opposite of the intuitive one.&lt;/strong&gt; Enrolling on a &lt;em&gt;single&lt;/em&gt; expressive state helps that state and slightly harms the others — transfer-matrix diagonal median &lt;strong&gt;−0.082&lt;/strong&gt;, off-diagonal median &lt;strong&gt;+0.010&lt;/strong&gt;. Guessing which state a user will be in, and guessing wrong, is worse than enrolling on calm speech.&lt;/p&gt;

&lt;p&gt;A pre-committed control ruled out the competing explanation: acoustic diversity alone delivers &lt;strong&gt;+0.002&lt;/strong&gt; where composition delivers &lt;strong&gt;−0.065&lt;/strong&gt;. &lt;strong&gt;It is coverage, not expressiveness.&lt;/strong&gt; Spread enrolment thinly across states.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not claim
&lt;/h2&gt;

&lt;p&gt;Nothing about any TTS system's output quality. Eight speakers, one studio, one recording chain. Enrolment and test share a session, so absolute error rates are optimistic and not comparable to VoxCeleb — only relative comparisons on identical trials are licensed. Architecture is fully confounded with training corpus; separating them requires training.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable part
&lt;/h2&gt;

&lt;p&gt;An established metric is not automatically a neutral one. An encoder, an evaluator, a classifier, an LLM judge — any of them can become part of the experiment rather than a window onto it.&lt;/p&gt;

&lt;p&gt;Before asking whether a system preserves identity under some transformation, ask whether your instrument has been validated under that transformation. Otherwise you attribute the encoder's behaviour to the speaker, and the number looks perfectly reasonable while you do it.&lt;/p&gt;




&lt;p&gt;Paper: &lt;em&gt;Speaker Encoders Disagree About Who You Are When You Shout&lt;/em&gt; — Zenodo, CC-BY-4.0.&lt;br&gt;
DOI: &lt;a href="https://doi.org/10.5281/zenodo.22158030" rel="noopener noreferrer"&gt;10.5281/zenodo.22158030&lt;/a&gt;&lt;br&gt;
Code, data and the full decision record: &lt;a href="https://github.com/Mormolykos/speaker-encoders-disagree" rel="noopener noreferrer"&gt;github.com/Mormolykos/speaker-encoders-disagree&lt;/a&gt;&lt;br&gt;
The eval library the analysis checks itself against: &lt;code&gt;pip install spkproof&lt;/code&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>ai</category>
    </item>
    <item>
      <title>Two of My Instruments Were Wrong, in Opposite Directions</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:30:49 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/two-of-my-instruments-were-wrong-in-opposite-directions-1jc8</link>
      <guid>https://dev.to/bedvibe_studios/two-of-my-instruments-were-wrong-in-opposite-directions-1jc8</guid>
      <description>&lt;p&gt;On 28 August 2026 I audited 38 live pages and fixed some ordinary defects. The two things worth writing down both came from my own tooling being broken. One script reported three pages I had just fixed as still broken, because it did not recognise a relative link. One date parser had thrown an exception on every crawl-statistics read since it was written, so the report that answers "is the crawler arriving at all" had never once returned a number.&lt;/p&gt;

&lt;p&gt;This is a note about measurement rather than about search engines. The setting is an indexing problem on one of my hosts, but the transferable part is that I spent a day acting on numbers produced by instruments I had never checked, and both were wrong — one in each direction. One made a working fix look broken. One made a working crawler invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crawling and indexing are not the same thing
&lt;/h2&gt;

&lt;p&gt;Everything below depends on this, and most writing on the subject collapses it into one word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crawling&lt;/strong&gt; is retrieval. A crawler requests a URL and receives bytes. That is all it means. A page can be crawled every day for a year and appear in no search result ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexing&lt;/strong&gt; is selection. Having retrieved the page, the engine decides whether to keep it in a form it will serve. That is a judgement, each engine makes it independently, and none of them will tell you the reasoning.&lt;/p&gt;

&lt;p&gt;So there are at least three states worth separating: &lt;em&gt;never fetched&lt;/em&gt;, &lt;em&gt;fetched and declined&lt;/em&gt;, and &lt;em&gt;fetched and kept&lt;/em&gt;. "My pages aren't indexed" describes the middle two identically, and their causes have nothing in common.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit, kept in proportion
&lt;/h2&gt;

&lt;p&gt;On &lt;strong&gt;28 August 2026&lt;/strong&gt; I fetched every URL in the sitemap — 38 pages on the affected host, 20 on my notebook site — over plain HTTP with no JavaScript executed, and produced a number per page rather than a verdict. That rule exists because an earlier audit returned "all clean" for months while the homepage was handing crawlers a fraction of its navigation in a form they could use.&lt;/p&gt;

&lt;p&gt;It found real defects. Five pages had &lt;strong&gt;zero followable links&lt;/strong&gt;: a crawler arriving there could go nowhere. The worst had two navigation controls, both &lt;code&gt;&amp;lt;button onclick&amp;gt;&lt;/code&gt; — usable with a mouse, invisible to a crawler. Three more bound their only navigation to a JavaScript click handler, which has the same effect.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;38 pages, fetched live&lt;/th&gt;
&lt;th&gt;Before 28 Aug&lt;/th&gt;
&lt;th&gt;After 28 Aug&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages with zero followable links&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navigation a crawler cannot follow&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages with an incorrect &lt;code&gt;h1&lt;/code&gt; count&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing robots directive&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical problems&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images with no &lt;code&gt;alt&lt;/code&gt; attribute (of 199)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-200 responses&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is housekeeping. It is worth doing and it is not a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument one: a false negative that looked exactly like a failed fix
&lt;/h2&gt;

&lt;p&gt;After deploying, I re-ran the audit against the live site. It reported that three of the pages I had just fixed still had zero followable links.&lt;/p&gt;

&lt;p&gt;They did not. I fetched one by hand and the anchor was plainly in the served HTML.&lt;/p&gt;

&lt;p&gt;The bug was mine. The script counted links by matching &lt;code&gt;href&lt;/code&gt; values that started with &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;http&lt;/code&gt;. The anchors I had added were relative — &lt;code&gt;href="./"&lt;/code&gt; — so they matched nothing and counted as zero.&lt;/p&gt;

&lt;p&gt;What makes this worth writing down is the shape of the failure. &lt;strong&gt;A measurement that silently under-reports is indistinguishable from the defect it is supposed to detect.&lt;/strong&gt; Every downstream step would have been reasonable and wrong: conclude the deploy failed, hunt for a caching problem that did not exist, possibly "fix" three pages that were already correct and introduce a real defect doing it. Nothing in the output looked like an error. It looked like bad news.&lt;/p&gt;

&lt;p&gt;The only thing that caught it was retrieving the page and reading the markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument two: a report that had never returned a number
&lt;/h2&gt;

&lt;p&gt;The second failure was older and much more expensive.&lt;/p&gt;

&lt;p&gt;Bing's webmaster API serialises dates in a Microsoft-era format — &lt;code&gt;/Date(1784190745000)/&lt;/code&gt;, epoch milliseconds wrapped in a string. My parser handled that correctly. But one endpoint, and as far as I can tell only one, returns a second variant carrying a UTC offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/Date(1786345200000-0700)/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My pattern captured "digits and minus signs". It swallowed the offset into the number, and the integer conversion raised an exception every time.&lt;/p&gt;

&lt;p&gt;Because that variant appears in exactly one report, the failure was invisible everywhere else. Every other call worked. And the one report it broke was &lt;strong&gt;crawl statistics&lt;/strong&gt; — the nearest available substitute for origin access logs, and the first thing to check when asking whether a crawler is showing up.&lt;/p&gt;

&lt;p&gt;It had never once returned a value. I had been reasoning about crawler behaviour for weeks with the most direct instrument for it throwing an exception on every call. The fix is one line; the test asserts that both offset signs resolve to the same instant, because the value before the offset is already UTC.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the repaired report showed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Pages crawled that day (Bing)&lt;/th&gt;
&lt;th&gt;Pages held in index (Bing)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-24&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-25&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-26&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-27&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Bing crawls this host every day, 15 to 36 pages, and holds 34 of roughly 38 in its index. Google holds none of them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is not a host a major crawler visits occasionally. It is one a major crawler works through continuously, and has done throughout the period I spent trying to work out why crawlers were not engaging with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting the evidence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verified directly from the live sites (2026-08-28):&lt;/strong&gt; every row of the 38-page audit, fetched over HTTP with no JavaScript, re-verified after deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured from Bing's API (2026-08-24 to 2026-08-28):&lt;/strong&gt; the daily crawl volume and index count. One engine's figures about its own behaviour — good evidence about that engine, no evidence at all about any other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Corrected tooling:&lt;/strong&gt; two bugs, both mine, both fixed on 2026-08-28, one now covered by a regression test. The numbers here are post-correction. The pre-correction ones were wrong in a way that read as a genuine finding, which is the entire point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observation:&lt;/strong&gt; two major engines, given the same host, the same bytes, the same permissive &lt;code&gt;robots.txt&lt;/code&gt; and the same sitemap, reached opposite conclusions. One indexes nearly everything. One indexes nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unresolved:&lt;/strong&gt; why. I do not know, and I am not going to name a mechanism to round the story off. Index selection is independent between engines, so an asymmetry is permitted and is not by itself evidence of a fault anywhere.&lt;/p&gt;

&lt;p&gt;What the asymmetry does do is retire a family of explanations. A page a large crawler retrieves daily and chooses to keep is demonstrably reachable, parseable, renderable and acceptable to a serious indexing pipeline.&lt;/p&gt;

&lt;p&gt;To be explicit, because the genre invites the opposite reading: &lt;strong&gt;nothing here fixed the zero.&lt;/strong&gt; It was zero before the audit and zero after. This is not a recovery story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prediction, recorded before the outcome
&lt;/h2&gt;

&lt;p&gt;The obvious next question is whether the fixes helped. I cannot answer that today — the changes went live on 28 August and there is no after to compare against.&lt;/p&gt;

&lt;p&gt;So instead of waiting and then claiming a prediction I never wrote down, here it is in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline, 2026-08-28.&lt;/strong&gt; Google: 0 pages indexed on the affected host. Bing: 34 held, 15–36 crawled per day. Audit defects all at zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I will re-measure on 2026-09-15&lt;/strong&gt;, with the same script and the same API calls, and publish the result whichever way it goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I expect: no material change in either engine.&lt;/strong&gt; The defects I fixed were real, but none plausibly explains a host-wide zero, and Bing was already crawling this host thoroughly while every one of them was still present — fairly direct evidence they were not what stood in the way. Earlier in this investigation I also ran the standard remedies against a held-out control group and measured no effect: &lt;a href="https://ai.bedvibe.studio/crawled-not-indexed/" rel="noopener noreferrer"&gt;treatment 32%, control 33%&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the numbers move, my model is wrong and that is the more interesting outcome. If they do not, I have a dated negative result instead of a vague impression.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check instruments against reality before trusting their output.&lt;/strong&gt; Two of mine were wrong on the same day, in opposite directions. Both would have survived indefinitely if I had only read what they printed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A silent nothing is worse than a loud failure.&lt;/strong&gt; Make a missing measurement say &lt;em&gt;missing&lt;/em&gt; — never zero, and never an empty report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate crawled from indexed in your own vocabulary first.&lt;/strong&gt; The fixes for "never fetched" and "fetched and declined" have almost nothing in common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count links on the page, not links to it.&lt;/strong&gt; A page with no outgoing anchors is a dead end however many things point at it, and a click handler is invisible to the thing you are trying to persuade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch it and read it.&lt;/strong&gt; Every real finding here came from the actual bytes. Every error came from believing a summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the prediction down first.&lt;/strong&gt; It costs one paragraph and it is the difference between a result and a story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest summary of the day: I improved a site that was already technically sound, repaired two instruments that had been quietly lying to me, and confirmed an asymmetry I can describe precisely and cannot explain at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/broken-instruments/" rel="noopener noreferrer"&gt;ai.bedvibe.studio/broken-instruments&lt;/a&gt;, where I will post the 15 September re-measurement against the prediction above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write up measurement and instrumentation work at &lt;a href="https://ai.bedvibe.studio/articles/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt; — including the &lt;a href="https://ai.bedvibe.studio/crawled-not-indexed/" rel="noopener noreferrer"&gt;control-group indexing experiment&lt;/a&gt; this one follows, and a &lt;a href="https://geo.bedvibe.studio/" rel="noopener noreferrer"&gt;crawler-behaviour observatory&lt;/a&gt; that logs which AI and search crawlers actually arrive. The studio itself is at &lt;a href="https://bedvibe.studio/" rel="noopener noreferrer"&gt;bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>testing</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Agent Answers From 0.6% of Its Corpus and Reports It Like a Full Read</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 08:18:39 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/my-agent-answers-from-06-of-its-corpus-and-reports-it-like-a-full-read-35e7</link>
      <guid>https://dev.to/bedvibe_studios/my-agent-answers-from-06-of-its-corpus-and-reports-it-like-a-full-read-35e7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/retrieval-coverage/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My portfolio RAG agent holds 1,003 indexed chunks. A question retrieves six and answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six of 1,003 is 0.598%.&lt;/strong&gt; Nothing in the response, the logs or the trace says the other 997 were never looked at. It reads exactly like an answer built from reading everything.&lt;/p&gt;

&lt;p&gt;That is fine for &lt;em&gt;"what does he say about X."&lt;/em&gt; It is &lt;strong&gt;not evidence for&lt;/strong&gt; &lt;em&gt;"does he mention X anywhere"&lt;/em&gt; — and there is nothing in a normal RAG stack that tells those two apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim retrieval cannot support
&lt;/h2&gt;

&lt;p&gt;A compliance corpus: 2,431 policy documents. Someone asks whether there is a remote-work reimbursement policy. Retrieval returns eight chunks. None mentions reimbursement. The agent answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There is no remote-work reimbursement policy in the corpus.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That claim requires knowledge of 2,431 documents. It was made from eight. The agent did not lie and it did not hallucinate — it correctly reported what it found, and the &lt;strong&gt;shape of the sentence&lt;/strong&gt; quietly upgraded a statement about eight documents into a statement about the corpus.&lt;/p&gt;

&lt;p&gt;Absence of evidence in a retrieved fragment is not evidence of absence in the whole. Everyone knows this. No retrieval stack I have used records enough to enforce it, because the denominator is not carried anywhere near the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But our faithfulness score is 1.0"
&lt;/h2&gt;

&lt;p&gt;This is the first objection and it deserves a straight answer: &lt;strong&gt;faithfulness cannot catch this, by construction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Faithfulness asks whether the answer is supported by the retrieved context — did the model invent anything beyond what it was given. The compliance answer above invents nothing. It scores a perfect faithfulness and may be false about 2,423 documents.&lt;/p&gt;

&lt;p&gt;Faithfulness scores &lt;em&gt;answer against context&lt;/em&gt;. Coverage scores &lt;em&gt;context against corpus&lt;/em&gt;. Different axes, and a system can be perfect on the first while completely silent on the second.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it needs&lt;/th&gt;
&lt;th&gt;Available at answer time?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Faithfulness&lt;/td&gt;
&lt;td&gt;answer + context&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context precision&lt;/td&gt;
&lt;td&gt;answer + context&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context &lt;strong&gt;recall&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;ground-truth annotations&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — offline evaluation only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Corpus coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;corpus size + retrieved count&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes — and it is not reported&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Context recall is the metric that would catch it, and it needs labelled ground truth, so it lives in your evaluation harness and not in production. Corpus coverage needs two integers you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control is the shape of the sentence, not a percentage
&lt;/h2&gt;

&lt;p&gt;This took longest to see, and it is why there is no threshold anywhere in the implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.598% is not a bad number. It is a bad number for one class of sentence.&lt;/strong&gt; It is entirely adequate for &lt;em&gt;"he mentions Rust"&lt;/em&gt; — you need the one chunk you are quoting and nothing else. It cannot support &lt;em&gt;"he never mentions Rust."&lt;/em&gt; Same retrieval, same six chunks, opposite verdicts, because the claim changed.&lt;/p&gt;

&lt;p&gt;A single coverage threshold cannot serve both. Set it low and it licenses the second sentence; set it high and it forbids the first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Coverage required&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"The policy says X"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Any — you need only the units you cite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"There is no policy about X"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Exhaustive&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"All policies require X"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Exhaustive&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"The most recent policy is…"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Exhaustive&lt;/strong&gt; — the unread remainder may hold the true maximum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;"There are three mentions"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Exhaustive&lt;/strong&gt; — a count over a sample is an estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row needing exhaustive coverage has the same reason: it asserts something about the units that were &lt;em&gt;not&lt;/em&gt; read. 999,999 of 1,000,000 is 99.9999% and still cannot establish absence — a threshold there would be a lie with a decimal point on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failures wearing one label
&lt;/h2&gt;

&lt;p&gt;The second thing the missing denominator costs you is diagnosis. When a RAG answer is wrong, the post-mortem usually terminates at &lt;em&gt;the model hallucinated&lt;/em&gt;. That sentence hides at least three separate engineering problems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;th&gt;What to fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The relevant chunk was never retrieved&lt;/td&gt;
&lt;td&gt;The retriever, embedding or query — &lt;em&gt;not&lt;/em&gt; the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It was retrieved, then dropped during context assembly&lt;/td&gt;
&lt;td&gt;Context construction — the model never saw it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It reached the model, which reasoned past it&lt;/td&gt;
&lt;td&gt;The prompt or the model — the only reasoning failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three different people, three different days, one identical symptom. And the distinction is trivially recordable — &lt;code&gt;retrieved&lt;/code&gt;, &lt;code&gt;in_context&lt;/code&gt;, and whether the answer was right — but almost nobody logs the middle number, so the first two are permanently indistinguishable after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;An MCP server for &lt;a href="https://github.com/Mormolykos/notchecked" rel="noopener noreferrer"&gt;notchecked&lt;/a&gt;, the coverage-accounting schema I wrote after hitting the silent-skip failure in four domains. The library types the gaps a &lt;em&gt;program&lt;/em&gt; leaves. The server does it for an agent, which is where the failure moved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coverage_retrieval(
  target      = "portfolio corpus",
  query       = "Rust experience",
  corpus_size = 1003,
  retrieved   = 6,
  in_context  = 6,
  claim_type  = "absence",
)

→ scope:      6 of 1003 corpus units (0.598%)
  exhaustive: false
  claim_supported: false

  REFUSE_THIS_CLAIM: An ABSENCE claim asserts something about every unit
  you did NOT read, and 997 were never read. Downgrade the answer to what
  you found, or inspect the whole corpus.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;retrieved = 0&lt;/code&gt; is not a thin answer, it is a retrieval failure, and it records as one. Everything retrieved and then dropped is a context failure and records as that instead. Neither is a gap in the agent's reasoning, and calling them one sends someone to debug a prompt for a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this found in its own implementation
&lt;/h2&gt;

&lt;p&gt;I wrote a suite that replays six investigations of my own that produced wrong claims — a page judged from 3,000 of its 10,828 words, a search that had stripped the HTML so anything named only in an &lt;code&gt;href&lt;/code&gt; was invisible, three different counts from three broken filesystem walks.&lt;/p&gt;

&lt;p&gt;On its first run it failed, and it failed on my code rather than on the cases. &lt;code&gt;exhaustive&lt;/code&gt; was a bare assertion: an agent could pass &lt;code&gt;exhaustive: true&lt;/code&gt; alongside &lt;code&gt;scope: "3,000 of 10,828 words"&lt;/code&gt; and the absence warning was dropped. &lt;strong&gt;The tool committed the exact failure it exists to prevent, one layer above the schema it protects.&lt;/strong&gt; Where the scope carries "N of M", the contradiction is machine-visible and is now refused.&lt;/p&gt;

&lt;p&gt;That is the third time this idea has caught its own implementation. I have stopped finding it funny.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;If your retriever reports &lt;code&gt;corpus_size=100000, retrieved=20, in_context=12, cited=3&lt;/code&gt;, this records those numbers and what they can support. &lt;strong&gt;It does not know whether the retriever chose the right twenty.&lt;/strong&gt; It is not a retriever, a vector database, a reranker or a context assembler, and it is not competing with the one you have.&lt;/p&gt;

&lt;p&gt;Three limits are recorded as &lt;em&gt;passing tests&lt;/em&gt; rather than left out of the README: an exhaustive search of the &lt;strong&gt;wrong instrument&lt;/strong&gt; is still exhaustive; the target list is &lt;strong&gt;self-declared&lt;/strong&gt;, so nothing can know what the caller failed to think of; the retrieval counts are &lt;strong&gt;self-reported&lt;/strong&gt;, with only internal consistency enforced.&lt;/p&gt;

&lt;p&gt;What it removes is the &lt;em&gt;silence&lt;/em&gt;. That is a smaller claim than "this makes agents honest," and it is the one the evidence supports.&lt;/p&gt;

&lt;p&gt;I have measured this in &lt;strong&gt;one&lt;/strong&gt; live system, my own. I have not established what other RAG deployments report, and I am not claiming it from a sample of one.&lt;/p&gt;




&lt;p&gt;&lt;code&gt;pip install notchecked&lt;/code&gt; · &lt;a href="https://github.com/Mormolykos/notchecked" rel="noopener noreferrer"&gt;github.com/Mormolykos/notchecked&lt;/a&gt; — MIT, zero runtime dependencies, MCP over stdio written from the JSON-RPC wire format.&lt;/p&gt;

&lt;p&gt;The eight coverage states were reviewed publicly by &lt;strong&gt;Boris Teplitsky&lt;/strong&gt;, an IBM Certified Expert IT Architect who hit the same shape in infrastructure compliance and gave three corrections that changed the schema. They are frozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run RAG in production and you &lt;em&gt;do&lt;/em&gt; record retrieval coverage alongside answers, I would like to know&lt;/strong&gt; — that would make this a solved problem I had not found the solution to, which is a better outcome than being right.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>I Shipped a Feature in May. Nobody Could Reach It Until Yesterday.</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:33:52 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/i-shipped-a-feature-in-may-nobody-could-reach-it-until-yesterday-36d2</link>
      <guid>https://dev.to/bedvibe_studios/i-shipped-a-feature-in-may-nobody-could-reach-it-until-yesterday-36d2</guid>
      <description>&lt;p&gt;I shipped a feature in May. Cross-page session memory for a retrieval agent — follow a link, the conversation comes with you. It was deployed, tested, and running in production.&lt;/p&gt;

&lt;p&gt;Yesterday I found out no visitor had ever been able to reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that was actually broken
&lt;/h2&gt;

&lt;p&gt;The obvious problem was that the chat widget existed on exactly one page out of thirty-nine. That is bad, but it is the kind of bad you notice.&lt;/p&gt;

&lt;p&gt;The real problem was underneath. The widget never sent a &lt;code&gt;session_id&lt;/code&gt; field. Or a &lt;code&gt;scope&lt;/code&gt; field. The server had both, documented, tested, deployed — and the browser had simply never been wired to send either one.&lt;/p&gt;

&lt;p&gt;So the feature was not "confined to one page." It was &lt;strong&gt;never invoked from anywhere.&lt;/strong&gt; A &lt;code&gt;sessions_active&lt;/code&gt; counter that had read &lt;code&gt;0&lt;/code&gt; since May, on an endpoint that was working perfectly.&lt;/p&gt;

&lt;p&gt;Both halves passed their own tests. The server tests posted a session id, because the test author knew the field existed. The widget tests asserted the chat rendered and sent a message. Nothing tested the seam, and the seam was the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that class of bug looks like
&lt;/h2&gt;

&lt;p&gt;The failure has a shape worth naming: &lt;strong&gt;two components, each correct, and an integration nobody owns.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It does not throw. It does not log. The health endpoint is green. In my case the endpoint even reported the exact number that would have given it away — &lt;code&gt;sessions_active: 0&lt;/code&gt; — sitting in a JSON response I read regularly and never questioned, because zero active sessions on a low-traffic site is completely plausible.&lt;/p&gt;

&lt;p&gt;That is the tell, and it generalises past my code: &lt;strong&gt;a metric whose broken value is indistinguishable from its healthy value tells you nothing.&lt;/strong&gt; Zero sessions means "nobody is using it" and "it is impossible to use it," and the number is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the fix, rather than asserting it
&lt;/h2&gt;

&lt;p&gt;Once the browser sent a session id, I did not want a green test. I wanted the thing a visitor does.&lt;/p&gt;

&lt;p&gt;So: send one question with a session id from the datasets page. Then send a &lt;em&gt;second&lt;/em&gt; question from the portfolio page with the client history deliberately empty — which is what a real navigation does to a JavaScript variable — and see whether the answer still knows what we were talking about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page 1  /datasets/    "What speech datasets are there?"
page 2  /portfolio/   "Who recorded it?"     history: []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second answer came back about the speech datasets. The server had carried it. That is a test I trust, because the thing I removed — the client's memory — is exactly the thing the feature claims to replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I nearly shipped while fixing it
&lt;/h2&gt;

&lt;p&gt;Rolling the widget out, I decided each page should tell the agent which section of the corpus it was on, so the datasets page ranks dataset material first.&lt;/p&gt;

&lt;p&gt;Then I tested the datasets page with a piece of internal vocabulary — a codename from our own research notes — and it answered "the documents do not contain information about that." I read that as a coverage failure and widened the page's search to the whole corpus.&lt;/p&gt;

&lt;p&gt;That was wrong, and the correction came from the person who owns the product rather than from me. Those codenames are internal research nomenclature for how a training corpus was built. They are not products. Nothing under those names is for sale. They appear zero times on the sales page and only in an engineering report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The narrow scope was not a limitation. It was the mechanism keeping internal research vocabulary out of a commercial answer.&lt;/strong&gt; By widening it I had connected a buyer's licensing question to the research corpus, in the name of fixing a defect that was the system working correctly.&lt;/p&gt;

&lt;p&gt;Reverted. The lesson is cheap to state and I clearly needed to learn it expensively: &lt;strong&gt;do not test a sales surface with internal vocabulary, and do not read a narrow result as a broken one until you know what the narrowness is for.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;Ship a feature and then, before believing it works, ask what a visitor would have to do to reach it — out loud, as a sequence of clicks. Mine had no answer. There was no page to navigate &lt;em&gt;from&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And look at your health metrics for one specific property: is there any value this number could report that means "broken" but reads as "quiet"? Those are the numbers that let a feature sit dead in production for three months while everything stays green.&lt;/p&gt;




&lt;p&gt;The agent this is about is live: the architecture and its 32-case evaluation gate are documented at &lt;a href="https://tts.bedvibe.studio/portfolio/agent-architecture/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio/agent-architecture&lt;/a&gt;, the eval results at &lt;a href="https://tts.bedvibe.studio/portfolio/agent-evals/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio/agent-evals&lt;/a&gt;, and the widget itself is on every page of &lt;a href="https://tts.bedvibe.studio/" rel="noopener noreferrer"&gt;tts.bedvibe.studio&lt;/a&gt; now — including, finally, more than one.&lt;/p&gt;

&lt;p&gt;If you have found a feature of your own that was deployed and unreachable, I would like to hear how you noticed. In my case it was luck, and I would prefer a method.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>My Validation Layer Was Correctly Deleting 16% of My Good Data</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:12:31 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/my-validation-layer-was-correctly-deleting-16-of-my-good-data-1fpj</link>
      <guid>https://dev.to/bedvibe_studios/my-validation-layer-was-correctly-deleting-16-of-my-good-data-1fpj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/observation-time/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a real-time tracker in Rust — about two thousand lines — that reads a live ADS-B feed, keeps a Kalman-filtered track per aircraft, and screens every pair for closest approach against separation minima. Roughly 150 aircraft, a full cycle in under a millisecond.&lt;/p&gt;

&lt;p&gt;It ran clean. Tests passed, the picture looked right, the numbers were plausible.&lt;/p&gt;

&lt;p&gt;It was refusing about &lt;strong&gt;one measurement in nine&lt;/strong&gt;, and the only reason I ever found out is that the rejections went to a counter instead of a log line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate has a sub-second tolerance for clock error
&lt;/h2&gt;

&lt;p&gt;The tracker runs an innovation gate: when a position arrives, the filter predicts where the aircraft should be, and if the measurement is too far from that prediction it is rejected as physically impossible rather than believed.&lt;/p&gt;

&lt;p&gt;Once a track converges the innovation standard deviation settles around 36 m, so a five-sigma gate sits at roughly 180 m. An airliner at 250 m/s covers 180 m in &lt;strong&gt;0.7 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the gate's entire tolerance for a wrong timestamp is under one second. Any pipeline that mis-times its measurements by more than that will have them rejected — correctly, and invisibly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feed reports its own staleness. The pipeline dropped it.
&lt;/h2&gt;

&lt;p&gt;Every ADS-B record carries a field saying how old that position already was when the response was generated. In the original build it was parsed into the contact struct and never read again — the only other place that field appeared in the entire codebase was as &lt;code&gt;0.0&lt;/code&gt; in test fixtures. Every measurement was therefore stamped with the tracker's own cycle clock, as though it had been observed at the instant it landed.&lt;/p&gt;

&lt;p&gt;This is the common case, not an exotic one. &lt;strong&gt;A field that is decoded and then unused looks identical to a field that is decoded and used&lt;/strong&gt;, right up until you go looking for its second reference.&lt;/p&gt;

&lt;p&gt;Here is what that field actually contains, sampled across two consecutive polls of the live feed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reported age of position    median   0.31 s
                            p90      3.97 s
                            max     48.53 s

change per aircraft
between consecutive polls   -15.76 s  to  +3.00 s

re-served identical
positions                   17 of 135 contacts  (12.6%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  It is the jitter, not the lag
&lt;/h2&gt;

&lt;p&gt;The intuitive diagnosis is that the lag itself is the problem. It is not, and the distinction turns out to be the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A constant lag is invisible to a constant-velocity filter.&lt;/strong&gt; If every measurement is uniformly two seconds old, the filter simply tracks a target that is uniformly two seconds behind. The innovations stay small. Nothing is rejected. The picture is late, but it is self-consistent.&lt;/p&gt;

&lt;p&gt;That has a sharp consequence for anyone writing a regression test: a fixture built with a constant age will not reproduce the fault no matter how large the age. The test passes and the bug survives. The fixture has to carry the &lt;em&gt;variation&lt;/em&gt;, or it is testing nothing.&lt;/p&gt;

&lt;p&gt;Look at that middle row again. Between two polls two seconds apart, one aircraft's reported age fell by nearly sixteen seconds — a genuinely new observation arriving after a long gap. Stamped on arrival, both the stale one and the fresh one are marked "now", so the filter sees an aircraft that has apparently teleported. A steadily flying aeroplane appears to lurch, and a correctly functioning gate refuses to believe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fix makes it worse
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading, and the reason the change is architectural rather than a one-line patch.&lt;/p&gt;

&lt;p&gt;The obvious repair: stamp each measurement with &lt;code&gt;arrival − age&lt;/code&gt;, feed that to the filter, and guard against a measurement arriving from before the filter's current state, because you cannot predict backwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;observed_time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="py"&gt;.epoch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That guard is fatal. Every cycle, the display loop advanced &lt;em&gt;every&lt;/em&gt; track to the current wall-clock time so the picture and the collision screen would agree. So by the time a two-second-old measurement arrived, the track's clock already read later than the measurement, and the guard would have discarded it as stale. &lt;strong&gt;A patch aimed at an 11% rejection rate would have rejected considerably more — and from the outside it would have looked exactly like the fix working.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The general form: &lt;strong&gt;a Kalman filter's state is valid for exactly one instant.&lt;/strong&gt; Once anything other than a measurement is allowed to advance that instant, every measurement is applied to a state from a different moment than the one it describes. A rendering loop is not usually thought of as mutating the estimator, which is precisely why this survives review.&lt;/p&gt;

&lt;p&gt;The pre-fix code was not wrong about this. It was &lt;em&gt;consistently&lt;/em&gt; wrong — the display and the measurements were stamped with the same fictional clock, so they agreed with each other. That is why correcting one half in isolation breaks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that actually fixes it
&lt;/h2&gt;

&lt;p&gt;One sentence: &lt;strong&gt;the filter's validity time advances only when a measurement arrives. Nothing else may move it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The picture and the conjunction screen do not predict tracks forward — they take a view, which extrapolates a copy and leaves the filter parked at the moment it was last given evidence for. A test asserts that rendering the screen thirty times does not move a single filter.&lt;/p&gt;

&lt;p&gt;Two properties follow, and both are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The absolute observation time is never reconstructed at all.&lt;/strong&gt; The step between two observations is computed entirely from differences — elapsed time between two arrivals the process witnessed, plus the two ages the sensor reported. Nothing subtracts a duration from a monotonic clock, so there is no underflow path. That closes a real crash: &lt;code&gt;Instant - Duration&lt;/code&gt; panics in Rust if the result would precede the clock's origin, and on Windows that origin is boot time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication becomes free.&lt;/strong&gt; A re-served snapshot has both its arrival and its reported age advance by the same amount, so it computes a step of exactly zero and is rejected by the same comparison that catches out-of-order data. One comparison covers both cases, which is a good sign the invariant is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing an A/B the upstream cannot distort
&lt;/h2&gt;

&lt;p&gt;Measuring this against a live third-party feed is harder than it looks, and two designs had to be discarded before one held.&lt;/p&gt;

&lt;p&gt;Running both binaries simultaneously fails: the corrected build recorded 32 polls and 32 HTTP errors — zero data — while the old one ran fine. That is not a regression, it is the upstream rate-limiting per IP. Running them sequentially fails differently: the results invert, because the API throttles progressively and whichever build runs first gets the fresher quota. Time-to-first-byte on three consecutive manual requests climbed 0.43 s, 0.91 s, 4.22 s.&lt;/p&gt;

&lt;p&gt;Both would have produced a clean-looking number and a wrong conclusion. What caught them was running the corrected build alone, which worked — proving the failure was the environment, not the code under test.&lt;/p&gt;

&lt;p&gt;The design that survives: alternating order, 45-second windows, 75-second cooldowns, comparing gated plots as a &lt;em&gt;fraction&lt;/em&gt; of observations so traffic volume cancels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;window   build       observations   gated   rate
  1      corrected       2,137          3    0.14%
  2      pre-fix         2,752        308   11.19%
  3      corrected       2,208         81    3.67%
  4      pre-fix         2,755        578   20.98%

pooled   pre-fix       886 / 5,507         16.1%
         corrected      84 / 4,345          1.9%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both corrected windows sit below both pre-fix windows with no overlap. The corrected build varies — 0.14% against 3.67% — and I would not claim a precise point estimate from four windows. &lt;strong&gt;The separation is the defensible result, not the number.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two unrelated faults the counters surfaced
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It was silently failing most of its polls.&lt;/strong&gt; 888 observations at roughly 135 per response is about seven successful polls in sixty seconds, not thirty. Two-thirds of its requests were failing and the system reported nothing, because there were no sensor-health counters at all — a failed poll produces no data, which looks exactly like quiet airspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It never aged out a single track.&lt;/strong&gt; Zero drops in both windows. Of course not: if every measurement is stamped with arrival time, every track's last-seen time is always now, so nothing is ever stale. A track whose aircraft stopped reporting forty seconds ago sat on the display looking as current as everything else.&lt;/p&gt;

&lt;p&gt;Those counters were added to report on something else entirely — a limit on response body size. They immediately found a different fault. That is the lesson I would take from this project ahead of the timestamp one: &lt;strong&gt;observability added for one problem finds problems you were not looking for, and a system with no failure counters cannot tell you the difference between "nothing is happening" and "nothing is working".&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your own pipeline
&lt;/h2&gt;

&lt;p&gt;If you ingest anything from a source that reports its own staleness — market data, IoT sensors, GPS, log shipping, any polled API with a timestamp in the payload — check whether your pipeline records when the record &lt;em&gt;arrived&lt;/em&gt; instead of when it was &lt;em&gt;observed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The failure mode is nasty specifically because it is quiet. Nothing crashes. No error is logged. Your validation layer does its job perfectly and deletes your good data, and if the rejections are counted anywhere at all you will read the number as evidence that the validation is working.&lt;/p&gt;

&lt;p&gt;And a constant lag hides it completely. It is the jitter that bites — which means the systems most likely to have this bug are the ones whose feeds are &lt;em&gt;usually&lt;/em&gt; fast.&lt;/p&gt;




&lt;p&gt;MIT, 55 tests, clippy clean at &lt;code&gt;-D warnings&lt;/code&gt;: &lt;a href="https://github.com/Mormolykos/aether" rel="noopener noreferrer"&gt;github.com/Mormolykos/aether&lt;/a&gt;. Surveillance and state estimation only — no targeting, engagement or weapon functionality of any kind. The README carries the full measured results and a limitations section considerably longer than this post.&lt;/p&gt;

&lt;p&gt;More measured write-ups of the same kind, including the retrieval work and the long-document RAG ablation, are at &lt;a href="https://tts.bedvibe.studio/portfolio/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>architecture</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your TTS Model Sounds Great — Until It Says "GPUB"</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:10:05 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/your-tts-model-sounds-great-until-it-says-gpub-1d77</link>
      <guid>https://dev.to/bedvibe_studios/your-tts-model-sounds-great-until-it-says-gpub-1d77</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/ttsproof/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a text-to-speech product and kept getting burned by the same thing. On normal sentences the model sounded great. Then it would hit a number, a date, an acronym or a name, and quietly mangle it.&lt;/p&gt;

&lt;p&gt;Worse, the metric everyone reaches for — Word Error Rate — was lying to me in &lt;strong&gt;both&lt;/strong&gt; directions. It flagged perfectly good audio as broken because the script said &lt;code&gt;3:30 PM&lt;/code&gt; and the transcript said "three thirty pee em." And it &lt;em&gt;missed&lt;/em&gt; real failures on short tokens, where the speech recogniser is as unreliable as the TTS.&lt;/p&gt;

&lt;p&gt;So I wrote the QA framework I wished I had, packaged it as &lt;strong&gt;ttsproof&lt;/strong&gt;, and then ran it as a blind study against a production TTS service so the results would be more than an opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two failures WER cannot see
&lt;/h2&gt;

&lt;p&gt;A TTS pipeline breaks in two different ways, and a single WER number blurs both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structural defects.&lt;/strong&gt; The clip is empty, truncated, three times too long, stuck in a repeated-chunk loop, clipping, or has a click at the tail. These have nothing to do with pronunciation — you can catch them with no model at all, straight from the waveform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pronunciation and content errors&lt;/strong&gt; on the hard cases: numbers, decimals, dates, clock times, acronyms, single letters, URLs, names.&lt;/p&gt;

&lt;p&gt;ttsproof splits them apart and handles each one honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structural checks, no model needed&lt;/strong&gt; — empty or truncated audio, duration explosions, long internal silences, clipping, loop detection, end-of-clip artifacts. numpy and soundfile, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equivalence-aware WER/CER&lt;/strong&gt; — the expected text and the ASR transcript are &lt;em&gt;both&lt;/em&gt; canonicalised to spoken form before scoring, so &lt;code&gt;3:30 PM&lt;/code&gt; against "three thirty" stops counting as an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ASR-uncertainty quarantine&lt;/strong&gt; — when the audio is structurally clean but the recogniser disagrees on a very short utterance, the sample is set aside for a human instead of being auto-failed. At that length the ASR is as likely to be wrong as the TTS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The study: 390 samples, and a blind human check
&lt;/h2&gt;

&lt;p&gt;I evaluated the method against a production neural TTS service — &lt;strong&gt;130 edge cases × 3 voices = 390 samples&lt;/strong&gt; — and published it as a citable technical report (&lt;a href="https://doi.org/10.5281/zenodo.20757553" rel="noopener noreferrer"&gt;DOI 10.5281/zenodo.20757553&lt;/a&gt;, CC-BY-4.0).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero structural audio-integrity defects&lt;/strong&gt; across all 390 clips. That matters: it means every failure that did exist was pronunciation, exactly the kind WER mislabels.&lt;/li&gt;
&lt;li&gt;Exact-match rate &lt;strong&gt;0.769&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Then the honest part — a &lt;strong&gt;blind human review&lt;/strong&gt; of the ASR-uncertain quarantine zone. Of 42 uncertain clips, &lt;strong&gt;23 (55%) were ASR false negatives&lt;/strong&gt; (the TTS said it right and the recogniser misheard) and &lt;strong&gt;19 (45%) were genuine TTS mispronunciations.&lt;/strong&gt; Fifteen control clips came back 15/15 correct, so the rater was reliable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That 45/55 split is the entire argument for having a quarantine verdict at all. Auto-passing that zone ships 19 real mispronunciations. Naive ASR-WER auto-failing it wrongly kills 23 correct clips. Neither is acceptable, so ttsproof refuses to guess there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the real failures looked like
&lt;/h2&gt;

&lt;p&gt;All 19 genuine failures were short isolated letters and acronyms, and the pattern is oddly specific:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A-vowel substitution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NATO&lt;/code&gt; → "NITO", &lt;code&gt;USA&lt;/code&gt; → "USI", &lt;code&gt;CIA&lt;/code&gt; → "CII"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trailing appended phoneme&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GPU&lt;/code&gt; → "GPUB", &lt;code&gt;EU&lt;/code&gt; → "EUU"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Early truncation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;R&lt;/code&gt; chopped short&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doubling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X&lt;/code&gt; said twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other substitution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CEO&lt;/code&gt; → "CEE", &lt;code&gt;Z&lt;/code&gt; → "SZ"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The structural detectors did &lt;strong&gt;not&lt;/strong&gt; fire on any of these. "GPUB" is intelligible speech, not a click. Structural checks and ASR quarantine are complementary; neither alone catches everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark any engine in one command
&lt;/h2&gt;

&lt;p&gt;Beyond the study, ttsproof ships a corpus of &lt;strong&gt;817 curated edge cases across 39 categories&lt;/strong&gt; — numbers, currencies, dates, ISO timestamps, phone numbers, URLs, file paths, pronunciation-torture words (Worcestershire, synecdoche), proper names (Reykjavík, Nguyễn), Greek, Norwegian and more. The corpus is versioned independently of the software, so published scores stay comparable across tool updates.&lt;/p&gt;

&lt;p&gt;It is engine-agnostic — point it at any TTS via a command template, or at a folder of audio you already generated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ttsproof benchmark &lt;span class="nt"&gt;--cmd&lt;/span&gt; &lt;span class="s2"&gt;"mytts --text {text} --wav {out}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a category scoreboard, a self-contained &lt;code&gt;report.html&lt;/code&gt; with waveforms, an audio player and what the ASR actually heard, and a CI regression gate. Closed-source engines work too through a SpeechSDK wrapper — an integration a user suggested after the first release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&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;ttsproof            &lt;span class="c"&gt;# structural checks + metrics + corpus&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"ttsproof[asr]"&lt;/span&gt;     &lt;span class="c"&gt;# + faster-whisper for pronunciation gating&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/Mormolykos/ttsproof" rel="noopener noreferrer"&gt;github.com/Mormolykos/ttsproof&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;The study: &lt;a href="https://doi.org/10.5281/zenodo.20757553" rel="noopener noreferrer"&gt;doi.org/10.5281/zenodo.20757553&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It has already had its first outside contribution, a community fix for a real number-formatting bug, which is exactly what I hoped for. If your TTS breaks on something, open an issue with the case — the corpus grows from real failures.&lt;/p&gt;

&lt;p&gt;The text-to-speech platform this came out of is live at &lt;a href="https://tts.bedvibe.studio/" rel="noopener noreferrer"&gt;tts.bedvibe.studio&lt;/a&gt; — ttsproof exists because I needed to QA that, and the &lt;a href="https://tts.bedvibe.studio/portfolio/bedvibe-tts/" rel="noopener noreferrer"&gt;model's engineering write-up&lt;/a&gt; covers what it is built on.&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why Corrupted Training Data Doesn't Show Up as High Loss</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:06:06 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/why-corrupted-training-data-doesnt-show-up-as-high-loss-1ibp</link>
      <guid>https://dev.to/bedvibe_studios/why-corrupted-training-data-doesnt-show-up-as-high-loss-1ibp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/corrupted-training-data/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is an assumption almost every practitioner carries without examining it: &lt;strong&gt;if your dataset has bad samples in it, the loss will tell you.&lt;/strong&gt; Corrupted rows spike. Broken files stick out. Sort by per-sample loss, look at the top of the list, and there is your garbage.&lt;/p&gt;

&lt;p&gt;I believed it too. Two separate failures in my own work say it is wrong, and they fail in the same direction — quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reproducible one: a dataset that cannot be learned
&lt;/h2&gt;

&lt;p&gt;While validating &lt;a href="https://github.com/Mormolykos/trainproof" rel="noopener noreferrer"&gt;trainproof&lt;/a&gt; I ran a controlled fault-injection study: one base setup, a Qwen2.5-3B QLoRA, run six ways, three seeds each, eighteen runs total. Every log ships in the repo so the verdicts can be checked rather than believed.&lt;/p&gt;

&lt;p&gt;One configuration shuffled the dataset's labels into pure noise. The labels no longer corresponded to the inputs at all. This is not a hard dataset or a noisy dataset. It is a dataset that &lt;strong&gt;cannot be learned&lt;/strong&gt;, because there is no relationship left in it to learn.&lt;/p&gt;

&lt;p&gt;That run &lt;strong&gt;reduced its loss by 62%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On its own curve it was textbook-healthy — a clean downward slope, no spike, no plateau, nothing a human or a rule would flag. It was learning nothing useful. It was memorising the statistics of noise, which any sufficiently large network will happily do. From a single run's loss curve it is indistinguishable from a real one.&lt;/p&gt;

&lt;p&gt;That is where the assumption broke for me. Not "loss is a weak signal for this." &lt;em&gt;Loss is not a signal for this at all, in isolation.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The production one, and what I can and cannot prove about it
&lt;/h2&gt;

&lt;p&gt;The second failure came from real work rather than an experiment, and it is the one I think about more.&lt;/p&gt;

&lt;p&gt;Building a text-to-speech corpus of roughly 110,000 recordings, a small number of the files were pure loud white noise. Not corrupted in the file-format sense — they opened fine, played fine, had valid headers and valid duration. They simply contained no speech. Just noise, at volume.&lt;/p&gt;

&lt;p&gt;They did not surface as high-loss outliers.&lt;/p&gt;

&lt;p&gt;Being precise about the epistemic status of this one matters: &lt;strong&gt;those training logs no longer exist.&lt;/strong&gt; I cannot show the numbers and I am not going to reconstruct them from memory. It is a production observation, not a measurement. Treat it as the anecdote that sent me looking, and treat the fault-injection study above as the part carrying evidence.&lt;/p&gt;

&lt;p&gt;But the two line up, and that is the reason to write it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens: noise has learnable statistics
&lt;/h2&gt;

&lt;p&gt;The mechanism is the same in both cases, and the surprise goes away once you see it.&lt;/p&gt;

&lt;p&gt;We treat "noise" as a synonym for "unpredictable" and then quietly assume a model will fail loudly on it. White noise is not unpredictable in the way that matters to a loss function. It is &lt;em&gt;stationary&lt;/em&gt; and &lt;em&gt;uniform&lt;/em&gt;. Its distribution is simple and consistent. A network fits that distribution quickly and cheaply — and fitting it registers as loss going down.&lt;/p&gt;

&lt;p&gt;Shuffled labels are the same story from the other side. The mapping is destroyed, so nothing generalisable remains, but the network can still memorise the marginal statistics of the targets. Loss falls. Nothing spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In both cases the model learned something. It just was not the thing you wanted.&lt;/strong&gt; The loss function has no opinion about which of the two it did.&lt;/p&gt;

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

&lt;p&gt;Two things follow and neither is exotic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compare against a baseline; do not judge in isolation.&lt;/strong&gt; The shuffled-labels run becomes obvious the moment you put it beside a known-good run and look at the &lt;em&gt;relative&lt;/em&gt; floor each one reaches — the corrupted run's floor sits in a different regime entirely. One run alone lies; two runs side by side tell the truth. That is why trainproof has a &lt;code&gt;compare&lt;/code&gt; mode at all: not as a convenience, but because single-run rules provably cannot catch this class of failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the data as data, before training.&lt;/strong&gt; This is the part I got wrong for longer than I would like. I was inferring dataset quality from the training curve, which is an indirect measurement of an indirect measurement. For audio the checks are cheap and direct: spectral flatness, dynamic range, silence ratio, speech-band energy. A pure white-noise file is trivially separable from speech by any of them — &lt;em&gt;if you look at the audio.&lt;/em&gt; It is not separable by looking at the loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;This generalises past my two cases, and it is worth stating plainly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A loss curve measures whether the model is fitting something. It does not measure whether that something is your task.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any corruption that replaces your signal with a distribution the model can fit will pass every loss-shaped check you own. Truncated samples, silence, duplicated rows, label noise, wrong-language text, empty targets — none of these are guaranteed to spike. Some of them are guaranteed &lt;em&gt;not&lt;/em&gt; to.&lt;/p&gt;

&lt;p&gt;The failures that burn the most GPU hours are not the ones that crash. They are the ones that look exactly like success.&lt;/p&gt;




&lt;p&gt;The fault-injection logs are all in the &lt;a href="https://github.com/Mormolykos/trainproof" rel="noopener noreferrer"&gt;trainproof repo&lt;/a&gt;, including the shuffled-labels run and a 9.8-hour Coqui XTTS fine-tune that diverged on its own with nobody touching it.&lt;/p&gt;

&lt;p&gt;If you have hit a failure mode a deterministic check would have caught, tell me on the repo — it goes in, with credit.&lt;/p&gt;

&lt;p&gt;The 110,000-recording speech corpus in the second case belongs to a 730M-parameter TTS model I trained from scratch; how it was built is documented at &lt;a href="https://tts.bedvibe.studio/portfolio/bedvibe-tts/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio/bedvibe-tts&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>ai</category>
      <category>datascience</category>
    </item>
    <item>
      <title>The Same Bug, Four Times, Three of Them Mine</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 02:20:15 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/the-same-bug-four-times-three-of-them-mine-4b78</link>
      <guid>https://dev.to/bedvibe_studios/the-same-bug-four-times-three-of-them-mine-4b78</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/notchecked/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most validation tooling has two states: it passed, or it failed. Everything that was not actually evaluated has to be forced into one of them — and it is wrong in both directions.&lt;/p&gt;

&lt;p&gt;I did not work that out from first principles. I worked it out by shipping the same defect four times.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: a run that learned nothing, reported as healthy
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/Mormolykos/trainproof" rel="noopener noreferrer"&gt;trainproof&lt;/a&gt;, a training run whose loss was exactly &lt;code&gt;0.0&lt;/code&gt; on every step returned &lt;strong&gt;PASS&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every loss-shape check in that tool is guarded against dividing by zero. A curve that is identically zero trips every guard, so all of them skipped — silently. And then the report listed those same skipped checks as having &lt;em&gt;run&lt;/em&gt;. A run that learned nothing passed, accompanied by a list of the checks that had cleared it.&lt;/p&gt;

&lt;p&gt;That is the whole problem in one artifact. The tool had no way to say &lt;em&gt;I could not judge this&lt;/em&gt;, so the absence of a judgement rendered as a favourable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: the same tool, one loop earlier
&lt;/h2&gt;

&lt;p&gt;The fix for that was a report field listing which checks ran and which did not. Good. Then a nastier version turned up somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trainproof doctor&lt;/code&gt; walks a directory twice — once to discover candidate logs, once to judge them. The judging pass reported anything it could not read. The discovery pass had &lt;code&gt;except Exception: pass&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a file that raised &lt;em&gt;while being found&lt;/em&gt; never became a candidate, and never appeared in the report at all. Plainly visible on disk. Absent from the output. Indistinguishable from a file that passed.&lt;/p&gt;

&lt;p&gt;Same failure as the first, one loop earlier than I had been looking. That is when I stopped treating it as a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three: not my system
&lt;/h2&gt;

&lt;p&gt;I wrote the first two up publicly. Someone working in infrastructure compliance replied with the identical shape from a domain I know nothing about.&lt;/p&gt;

&lt;p&gt;A compliance framework document is mostly prose. Most of it describes things no generated artifact can satisfy or violate — staff training, review procedures, who signs what. Only a fraction maps to something a machine can check.&lt;/p&gt;

&lt;p&gt;The trap is reporting against the framework's name. Do that and everything unevaluated looks identical to everything that passed, and the ninety per cent that was never in scope disappears from the output entirely.&lt;/p&gt;

&lt;p&gt;That exchange is why this became a library rather than another &lt;code&gt;trainproof&lt;/code&gt; feature. One person hitting a shape twice is a habit. Two people hitting it in unrelated domains is a primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four: found while I was busy being pleased with myself
&lt;/h2&gt;

&lt;p&gt;Then I ran an evaluation harness of my own over a retrieval experiment and found three instances in a single afternoon.&lt;/p&gt;

&lt;p&gt;It recorded model refusals under a failure type asserting an ordering the model had never given. It scored ten refusals as &lt;strong&gt;correct&lt;/strong&gt;, because the expected phrase appeared inside the sentence explaining what could not be determined. And it missed eight correct answers because its negative pattern required a comma.&lt;/p&gt;

&lt;p&gt;One absent value. Wrong in both directions. Inside the instrument I was using to judge my own hypothesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The states
&lt;/h2&gt;

&lt;p&gt;Three instead of two — checked, could-not-check, never-in-scope — and eight once you ask two more questions of each gap: &lt;strong&gt;who can fix it&lt;/strong&gt;, and &lt;strong&gt;can it ever change&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHECKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;a determination was made; the verdict is separate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT_CHECKED / DATA_DEGENERATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the data&lt;/td&gt;
&lt;td&gt;the signal is present and unusable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT_CHECKED / CHECKER_FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your tooling&lt;/td&gt;
&lt;td&gt;the checker raised, timed out, or could not observe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT_CHECKED / WAIVED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a named person&lt;/td&gt;
&lt;td&gt;in scope, deliberately not evaluated, accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT_CHECKED / PREREQUISITE_FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;another target&lt;/td&gt;
&lt;td&gt;something upstream failed first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OUT_OF_SCOPE / CALLER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the caller&lt;/td&gt;
&lt;td&gt;not requested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OUT_OF_SCOPE / DATA_TRANSIENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the deployment&lt;/td&gt;
&lt;td&gt;does not apply &lt;em&gt;yet&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OUT_OF_SCOPE / DATA_PERMANENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nobody&lt;/td&gt;
&lt;td&gt;no artifact of this kind can ever evidence it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Coverage is not verdict.&lt;/strong&gt; &lt;code&gt;CHECKED&lt;/code&gt; is not a result — it says a determination was made, not what it was. The verdict vocabulary belongs to your domain (pass/warn/fail, compliant/non-compliant) and hangs off &lt;code&gt;CHECKED&lt;/code&gt; rather than sitting beside the not-checked states. Collapsing those two axes is failure one above. The constructor enforces the separation: a gap cannot carry a verdict, and a checked record cannot carry a skip reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review changed
&lt;/h2&gt;

&lt;p&gt;Boris Teplitsky, from the compliance side, put three corrections into the schema that I would not have found:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permanence is relative to a target, never absolute.&lt;/strong&gt; "Nobody, never" is not a property of a control — it is a property of pairing that control with a kind of artifact. A Kubernetes control is permanently out of scope only while the target has no Kubernetes; change the target and it becomes a row. So &lt;code&gt;OUT_OF_SCOPE / DATA_PERMANENT&lt;/code&gt; now &lt;em&gt;requires&lt;/em&gt; a &lt;code&gt;permanent_wrt&lt;/code&gt; field naming the reference. Unqualified, two reports on the same framework disagree and both are correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rows for the checkable subset, one count for the rest.&lt;/strong&gt; A framework document is hundreds of pages of which a few paragraphs concern anything an artifact can evidence. One row each makes the report noise. So the excluded corpus can be a single counted rule — and the count cannot be stated without the rule that produced it, and it enters the denominator, so excluding 412 of 415 reports 99% rather than a flattering silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The taxonomy starts after the unit exists.&lt;/strong&gt; In a linter, a row is a check somebody wrote. In compliance, turning prose into checkable requirements is the hard part and it is a judgment call. Nothing in the library governs that, and it should not read as if the rows arrive by themselves.&lt;/p&gt;

&lt;p&gt;That last one is scope, not code, and it was the correction I was most wrong about.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one the change found in my own reporter
&lt;/h2&gt;

&lt;p&gt;Running the README example through the new bulk path printed &lt;code&gt;413 out of scope (100% of all targets)&lt;/code&gt; while one target had in fact been checked — &lt;code&gt;.0%&lt;/code&gt; rounded 99.5 up. A reader takes "100% out of scope" as "nothing was measured."&lt;/p&gt;

&lt;p&gt;That is this library's own failure mode, committed by its own renderer, one layer above the schema it protects. Fixed both directions: 100% only when everything is excluded, 0% only when nothing is.&lt;/p&gt;

&lt;p&gt;MIT, no dependencies, 74 tests: &lt;a href="https://github.com/Mormolykos/notchecked" rel="noopener noreferrer"&gt;github.com/Mormolykos/notchecked&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have hit this shape in a fifth domain, I would like to hear it — the case list is a test file now, so a new case goes in with credit.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Hundred Per Cent Success Rate and One Usable Answer</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Tue, 25 Aug 2026 01:56:48 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/a-hundred-per-cent-success-rate-and-one-usable-answer-3aj6</link>
      <guid>https://dev.to/bedvibe_studios/a-hundred-per-cent-success-rate-and-one-usable-answer-3aj6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://ai.bedvibe.studio/success-rate/" rel="noopener noreferrer"&gt;ai.bedvibe.studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a gateway that sits in front of several inference backends — authentication, per-key spend quota, token metering, retries, failover, streaming. The ordinary shape. Then, before trusting any of it, I broke it twice on purpose and measured what the broken version reported about itself.&lt;/p&gt;

&lt;p&gt;Both breakages produced clean logs. That is the part worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fault: a gateway that cannot say no
&lt;/h2&gt;

&lt;p&gt;I removed admission control — no bound on how many requests may be in flight at once — and sent thirty-two callers at the gateway simultaneously. Each caller was willing to wait &lt;strong&gt;700 milliseconds&lt;/strong&gt; and would walk away after that.&lt;/p&gt;

&lt;p&gt;Every single request returned &lt;strong&gt;HTTP 200&lt;/strong&gt;. Thirty-two successes out of thirty-two. A dashboard reading that gateway would show a flawless service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thirty-one of those answers arrived after the caller had stopped waiting.&lt;/strong&gt; One was usable.&lt;/p&gt;

&lt;p&gt;The queue had not disappeared when I removed the bound. It had moved. Off the gateway, where I could see it and refuse it, and into the backend, where it was invisible and where everybody's clock was already running. The gateway kept faithfully reporting the outcome of work that no longer had an audience.&lt;/p&gt;

&lt;p&gt;With a bound and a fast &lt;code&gt;429&lt;/code&gt; instead: eight answered, twenty-four refused within milliseconds, and &lt;strong&gt;six usable answers instead of one&lt;/strong&gt;.&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;Unbounded&lt;/th&gt;
&lt;th&gt;Bounded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 200 returned&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Success rate&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Answers that arrived in time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six times the useful output, while the headline metric fell by seventy-five points. If you are optimising the success rate, you will optimise your way back into the first column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bound came from
&lt;/h2&gt;

&lt;p&gt;I did not pick eight because it felt right. I measured the backend until it stopped getting faster:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrent requests&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;189 tokens/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;433 tokens/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;474 tokens/sec — 9% more, for 3.3x the latency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The knee is at eight. Past it you are not buying throughput, you are buying queue. So the admission limit is eight, and it is a measurement rather than a preference — which also means it has an expiry date: change the model or the hardware and the number has to be taken again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second fault: failover that works in the log and fails for the caller
&lt;/h2&gt;

&lt;p&gt;Everybody writes failover for a backend that dies. Dying is easy — the connection refuses, you try the next one. So I made a backend go &lt;em&gt;slow&lt;/em&gt; instead: three seconds to respond, against a caller willing to wait one and a half.&lt;/p&gt;

&lt;p&gt;The gateway logged &lt;strong&gt;eight successes&lt;/strong&gt;. Every one of them landed at three seconds, after the caller was gone. &lt;strong&gt;The healthy backend was never called once.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cause was that my retry budget was a count of attempts plus a per-backend timeout. Neither of those knows whether anybody is still waiting. A retry policy expressed in attempts is a policy about the server's patience, and the server is not the one who is waiting.&lt;/p&gt;

&lt;p&gt;So the deadline now belongs to the request, and travels with it. While another backend is still available, the current one may spend at most sixty per cent of whatever time remains. You have to &lt;em&gt;reserve&lt;/em&gt; time for the thing you are failing over to, or the failover is decoration that runs after the deadline it was supposed to protect.&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;Attempt-counted&lt;/th&gt;
&lt;th&gt;Deadline-owned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Logged successes&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Arrived in time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;3001 ms&lt;/td&gt;
&lt;td&gt;1046 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the first row. The log is identical in both columns. Every observability surface I had said the failover was working, for as long as I only asked it whether requests succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I did not plan
&lt;/h2&gt;

&lt;p&gt;The two faults above were deliberate. These three found me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first working call billed 32 tokens and returned an empty string.&lt;/strong&gt; The local model is a reasoning model, and the server returns its reasoning in a different field from its answer. Read only the answer field and you show the user nothing while charging them for a full generation. Nothing errors, and the token accounting is correct — that is what makes it nasty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A concurrency bug that hid inside a green test suite.&lt;/strong&gt; I created the lock that bounds concurrency in the constructor. Python binds those to whichever event loop touches them first, so a second loop makes it throw — on admission, before any model is called, while the health endpoint still says &lt;code&gt;ok&lt;/code&gt;. It surfaced as a &lt;em&gt;warning&lt;/em&gt; on a worker thread and the suite reported &lt;strong&gt;79 passed&lt;/strong&gt;. Then my fix had the same bug in a new costume: I keyed it by the loop's memory address, and Python reuses the address of a closed loop. Both are fixed, and the suite now turns a swallowed background exception into a failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I guessed the wrong hotspot and the trace corrected me.&lt;/strong&gt; I was certain the overhead was the quota check — three database reads on every single request, right there in the hot path. It was &lt;strong&gt;0.146 ms&lt;/strong&gt;, about four per cent. Three quarters of the overhead was writing one metering row, because the database was flushing to disk before answering. One setting: &lt;strong&gt;2.999 ms down to 0.230 ms&lt;/strong&gt;, and the gateway's total overhead from 3.47 ms to 0.60 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I refused to publish
&lt;/h2&gt;

&lt;p&gt;Measured end to end, through the whole stack, the gateway added &lt;strong&gt;+1.26 ms against a noise floor of ±5.38 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not a measurement. It is a number smaller than the uncertainty around it, and quoting it would be quoting noise with a decimal point on it. The benchmark now says so out loud instead of printing the figure.&lt;/p&gt;

&lt;p&gt;It is also the entire argument for instrumenting inside the system. You cannot find a one-millisecond effect by subtracting two noisy end-to-end numbers. You find it with a span around the thing you suspect — which is how the metering flush turned up, and how I learned my confident guess about the quota check was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I was asked to build and didn't
&lt;/h2&gt;

&lt;p&gt;The brief said to put vLLM on this machine. I looked at the machine first.&lt;/p&gt;

&lt;p&gt;vLLM has no Windows build, so reaching it means a WSL2 install of roughly ten gigabytes of CUDA — on a box where system RAM, not VRAM, is the bottleneck. And the GPU is not idle: it is shared with a text-to-speech service that sits behind a live API taking real traffic, holding 7.4 of its 16 GB. vLLM's default configuration reserves ninety per cent of the card. The good outcome is that it refuses to start. The bad outcome is that I take down something that earns money, to demonstrate a tool.&lt;/p&gt;

&lt;p&gt;So I said so, in an architecture decision record, along with what the decision costs: &lt;strong&gt;continuous batching is what vLLM is actually for, and I did not measure it.&lt;/strong&gt; Measuring something else and calling it vLLM would be a lie with benchmarks attached.&lt;/p&gt;

&lt;p&gt;What I built instead is the layer above the engine, and that is the part that transfers. Routing, quota, metering, admission, deadlines and failover know nothing about which engine is underneath. The proof is structural rather than rhetorical: the two backends in it speak completely different protocols and each one is a single file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Success rate is the metric that hides an outage.&lt;/strong&gt; Both faults produced a clean log. One produced a perfect log. The number I care about now is how many answers arrived while somebody was still there to read them, and every dashboard I build gets that number next to the success rate, not instead of it.&lt;/p&gt;

&lt;p&gt;The second thing is smaller and I keep relearning it: a fault that returns an error is a fault you will fix this afternoon. A fault that returns a plausible success is one you ship. Both of the faults here were of the second kind, and I only found them because I planted them and then went looking for the specific evidence that they had happened — not because anything alerted.&lt;/p&gt;

&lt;p&gt;Ninety-four tests, seven seconds, no model, no GPU, no API key and no network. Zero API calls were spent on any of it.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/Mormolykos/basalt" rel="noopener noreferrer"&gt;github.com/Mormolykos/basalt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have a load-shedding or failover story where the log looked fine, I would like to hear it — particularly the ones where the metric that lied was one you had deliberately chosen.&lt;/p&gt;

&lt;p&gt;The retrieval agent this gateway was built to sit in front of is live, with its architecture and evaluation gate written up at &lt;a href="https://tts.bedvibe.studio/portfolio/agent-architecture/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio/agent-architecture&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>architecture</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ten Tests, Written Before the Code. The One That Failed Taught Me the Most</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Thu, 20 Aug 2026 07:07:32 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/ten-tests-written-before-the-code-the-one-that-failed-taught-me-the-most-35ng</link>
      <guid>https://dev.to/bedvibe_studios/ten-tests-written-before-the-code-the-one-that-failed-taught-me-the-most-35ng</guid>
      <description>&lt;p&gt;There is a number in solid mechanics that is exactly 3.&lt;/p&gt;

&lt;p&gt;Take an infinite plate with a circular hole, pull it in one direction, and the stress right at the edge of the hole is exactly three times the stress far away. Kirsch worked it out analytically in 1898. It is not approximately 3. It is 3.&lt;/p&gt;

&lt;p&gt;I wrote a finite element solver from scratch and never told it that number. It came back with &lt;strong&gt;3.00002&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "from scratch" means here
&lt;/h2&gt;

&lt;p&gt;No FEA library. No scipy. The Delaunay mesher, the isoparametric elements, the global assembly and the conjugate-gradient solver are all my own code.&lt;/p&gt;

&lt;p&gt;That constraint was the point. Calling a library and getting 3 proves the library works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Configuration                                  K_t        error
Annulus, P2 curved, finest mesh (21,220 dof)   2.99981    0.0063 %
Annulus, extrapolated to h = 0                 3.00002    0.0005 %
Finite plate, extrapolated                     2.99970    0.0101 %
Conservative Richardson variant                2.99937    0.0211 %
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hitting the number is the weak evidence
&lt;/h2&gt;

&lt;p&gt;This is the part I want to argue for, because it applies well beyond numerical code.&lt;/p&gt;

&lt;p&gt;Landing on 3 could be luck. A bug that happens to cancel, a mesh that happens to flatter you, a fudge factor someone tuned until the output looked right. One number matching one expected value is the weakest test you can run — and it is the test most of us stop at.&lt;/p&gt;

&lt;p&gt;The strong evidence is the &lt;em&gt;rate&lt;/em&gt;. Finite element theory predicts exactly how fast the error must shrink as the mesh gets finer, and the rate is different for each element type. You cannot fake that by accident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Element        L2 rate  predicted   H1 rate  predicted   energy  predicted
P1 (linear)      2.026      2         1.019      1        2.000      2
P2 (quadratic)   3.249      3         2.013      2        3.988      4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six numbers, six predictions, all recovered. That is what says the implementation is right rather than coincidentally close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the tests before the code
&lt;/h2&gt;

&lt;p&gt;I registered ten acceptance gates in a literature audit &lt;strong&gt;before any code existed&lt;/strong&gt;. Quadrature exactness, patch tests, mesh validity, checking the iterative solver against a direct one, scale invariance, convergence orders for both element types.&lt;/p&gt;

&lt;p&gt;The reason is not discipline for its own sake. It is that I know myself. If I write the criteria after seeing the output, I will unconsciously write criteria the output passes. Registering them first means the standard cannot move.&lt;/p&gt;

&lt;p&gt;Nine gates passed. One did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate I failed, and it is published
&lt;/h2&gt;

&lt;p&gt;Gate I concerned a variational crime — an approximation in how the curved boundary is handled. It failed. I tested two hypotheses for why and both were refuted.&lt;/p&gt;

&lt;p&gt;What is published is an arithmetic account consistent with every number I measured, plus a plain statement that the discrepancy &lt;strong&gt;cannot be settled from this experiment and is not settled&lt;/strong&gt;. The literature figure I had written the gate against turned out to come from a search summary rather than a paper I had actually opened. That is in the record too.&lt;/p&gt;

&lt;p&gt;I could have deleted the gate. Nobody would have known it existed, because I wrote it myself, before the code, in a document nobody had read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that passed for the wrong reason
&lt;/h2&gt;

&lt;p&gt;This one I am more pleased with than the failure.&lt;/p&gt;

&lt;p&gt;Gate H passed. Then I went back and criticised it: the acceptance band I had written was the wrong &lt;em&gt;shape&lt;/em&gt; for a one-sided theorem. It should have been a one-sided bound, and I had written a two-sided one. The result was fine, but the test would have passed things it should have caught.&lt;/p&gt;

&lt;p&gt;No reviewer forced that. A passing test that passes for the wrong reason is a bug in your test suite that no failure will ever reveal to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the central claim checkable instead of asserted
&lt;/h2&gt;

&lt;p&gt;The whole thing rests on "the target was never an input." That is easy to say and easy to lie about.&lt;/p&gt;

&lt;p&gt;So the README documents it as something you can verify: grep the source for the target value, and the only hits you find are ones that never feed the computation. You do not have to believe me. You can run &lt;code&gt;grep&lt;/code&gt; in about four seconds.&lt;/p&gt;

&lt;p&gt;The full run reproduces with &lt;code&gt;python fem_003.py&lt;/code&gt; in roughly fifty seconds, with a &lt;code&gt;--smoke&lt;/code&gt; mode for a quick shake-out, and it ships its own evidence — gate report, convergence CSVs, hoop-stress CSV, mesh and convergence plots.&lt;/p&gt;

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

&lt;p&gt;This is reproduction and validation, not discovery. Kirsch published the analytic solution in 1898 and the convergence theory is in every textbook. Nothing here is new.&lt;/p&gt;

&lt;p&gt;The contribution is building the thing and demonstrating that it lands on a number it was never given, with the rates it was supposed to have, against criteria written before the first line of code.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: &lt;strong&gt;write your acceptance criteria before you write the thing, publish the ones that fail, and go back and check whether the ones that passed deserved to.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full write-up: &lt;a href="https://ai.bedvibe.studio/fem-kirsch/" rel="noopener noreferrer"&gt;Ten tests, written before the code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Code, data and the full gate report: &lt;a href="https://doi.org/10.5281/zenodo.21892064" rel="noopener noreferrer"&gt;10.5281/zenodo.21892064&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>programming</category>
      <category>science</category>
    </item>
  </channel>
</rss>
