<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>ML Feature Store Versioning: Reproducible Quants</title>
      <dc:creator>shakti tiwari </dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:06:43 +0000</pubDate>
      <link>https://dev.to/shaktitiwari/ml-feature-store-versioning-reproducible-quants-4ob4</link>
      <guid>https://dev.to/shaktitiwari/ml-feature-store-versioning-reproducible-quants-4ob4</guid>
      <description>&lt;h1&gt;
  
  
  ML Feature Store Versioning: Reproducible Quants
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;By Shakti Tiwari · Educational only · Not investment advice&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article explains ml feature store versioning: reproducible quants from first principles. No live market numbers are quoted; the structure is what lasts.&lt;/p&gt;

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

&lt;p&gt;ML Feature Store Versioning: Reproducible Quants is one of those subjects that sounds simple until you implement it, at which point the hidden complexity appears. The first version works on a laptop with a tiny file; the second version breaks at 3am when the WebSocket drops, the replay file is half-written, and you cannot tell which ticks you already stored. This article is a structural walkthrough: the concepts, the math where it helps, the code shape where it helps, and the failure modes that quietly cost money or correctness. No live market numbers are quoted because a number without a dated source is decoration, not education. The structure here does not expire, and unlike a specific price level, you can reuse it on the next dataset without re-deriving anything. If you only remember one sentence from this page, make it this: the boring parts are the product, and the interesting parts are a small fraction of what separates a demo from a system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core concept
&lt;/h2&gt;

&lt;p&gt;At its heart, ml feature store versioning: reproducible quants is about being honest with your own assumptions. The trap is not that the idea is wrong; it is that a half-implemented version looks right in a demo and breaks in production. We separate the idea from the implementation so you can tell which one you actually have. A clean concept on paper can still produce a broken system if the boundary between 'what I meant' and 'what the code does' is never made explicit. Write the concept as a contract: given X observable at time t, the system produces Y, and any deviation is a bug, not a feature. A contract you can state in one sentence is also one you can test in one assertion, and that testability is the entire difference between an architecture and a wish. The cheaper you make the test, the more often you will run it, and the fewer surprises reach production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism in three parts
&lt;/h2&gt;

&lt;p&gt;The mechanism has three parts: what you observe, what you decide, and what you pay. Most tutorials show the first two and silently drop the third. The third is where real edges live or die. We model it explicitly: observation stamped at a bar, decision using only past data, cost taken from the realized fill not the midpoint. If you skip the stamping, you get look-ahead bias. If you skip the past-only rule, you get leakage. If you skip the realized cost, you get a strategy that looks profitable until a real order touches the book. Each omission is small in code and large in consequence. The reason tutorials drop the third part is that it is annoying to model: you need a realistic fill model, a fee schedule, and a tax rule, none of which appear in a clean formula. But the formula is the easy 10%; the fill model is the real 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  A structural example
&lt;/h2&gt;

&lt;p&gt;Consider a minimal version. You collect state, compute a signal, act only if a risk budget allows, and log the outcome including the trades you discarded. The discipline is in the ordering: state from the past, signal from that state, cost from the real fill. Reorder any of those and the result becomes a story you tell yourself rather than a measurement. The example is deliberately boring because the point is the skeleton, not the flourish. A skeleton you can defend beats a polished curve built on a lie. When you later replace the minimal pieces with real infrastructure — a real feed, a real executor, a real risk engine — the skeleton stays; only the muscles change. That stability is the value of getting the order right once, up front, before any of the exciting parts are allowed to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency and replay
&lt;/h2&gt;

&lt;p&gt;Idempotency means running the same ingestion twice produces the same stored result, not duplicates. This matters the moment a connection resets and you replay a file: without a unique key per tick, you double-count volume, corrupt aggregates, and silently bias every feature built on top. The fix is mechanical: hash or sequence each record, upsert on conflict, never append blindly. Replay then becomes safe instead of dangerous, and recovery from a crash is a re-run, not an investigation. Idempotency is not a performance optimization; it is a correctness guarantee. Once you have it, you can stop fearing restarts, and you can stop writing the fragile 'resume from line N' logic that breaks the moment a partial line is written. The key is doing the dedupe at write time, where the data arrives, not at read time, where the damage is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;It breaks at the seams: when the universe changes and you forgot to track it, when volatility regime shifts and your average hid the crash, when a parameter was tuned on the same data you tested on. Each break is a specific code or process error, not a mystery. Naming it is most of the fix. The pattern across all three is the same — a piece of state that should have been explicit was left implicit, and implicit state is where bugs hide. The reason these break at the seams and not in the middle is that the seam is the boundary between two systems you control at different times: the data you collected yesterday and the model you train today. If the boundary is not a first-class object in your code, it will be a first-class source of error in your P&amp;amp;L, and you will not see it until it is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to make it robust
&lt;/h2&gt;

&lt;p&gt;Robustness is boring: point-in-time data, walk-forward splits with a frozen holdout, explicit cost including tax on the correct leg, per-regime reporting, and a log of failed variants. None of these are optional if you intend to trust the number. They are the difference between a backtest and a bedtime story. Apply the same standard to the data layer: schema checked on write, duplicates rejected on key, checksums verified on replay. Robustness is not a feature you add at the end; it is a property you preserve at every step. Each shortcut you take 'just to ship' is a loan against a future debugging session, and the interest is paid in the currency you care about most — trust in your own numbers. The discipline is to treat the loan as real even when the deadline feels imaginary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common misconception
&lt;/h2&gt;

&lt;p&gt;A common misconception about ml feature store versioning: reproducible quants is that more data or a fancier model fixes a broken loop. It does not. A leaky pipeline with ten years of data is still leaky; a deep model on mispriced fills still loses money. Fix the loop first; the model is the last thing you improve, not the first. The urge to reach for a bigger hammer is natural, because the hammer is visible and the loop is invisible. But the bottleneck is almost always upstream of the model, in the plumbing that decides what the model is allowed to see. Every hour spent on a better architecture while the ingestion is still leaking is an hour spent polishing the wrong thing. The honest move is unglamorous: close the leak, re-run, and only then ask whether the model needs to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection to the pipeline
&lt;/h2&gt;

&lt;p&gt;This fits the companion stack: idempotent tick ingestion keeps the feature store reproducible, leakage-free XGBoost features keep the model honest, and a governed backtest keeps the loop from lying to you. The articles build on each other; read them in order if you are assembling a real system. Skipping the ingestion discipline to get faster to the model is the most expensive shortcut in quant, because every later step inherits the corruption. The pipeline is a chain, and a chain is only as strong as its weakest link; the ingestion link is also the one closest to the raw truth, so corrupting it poisons everything downstream while leaving the model code looking pristine. That is why the boring layer deserves the most scrutiny, not the least.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good enough looks like
&lt;/h2&gt;

&lt;p&gt;Good enough is not perfect; it is a system whose known limitations are written down. State the regimes you did not cover, the costs you approximated, the parameters you tuned. A result with honest scars beats a flawless one that hides them. That is the entire point of governed publishing: ship the analysis, keep the caveats attached, and let the reader see the seams instead of a polished surface. Good enough is also reproducible: someone else, with your notes, can rebuild the same number. If only you can reproduce it, it is not an analysis — it is a coincidence you happened to witness. Write the limitations as if the reader is a skeptic version of your future self, because that skeptic is exactly who will eventually read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal checklist
&lt;/h2&gt;

&lt;p&gt;Before you trust a pipeline built on this idea, answer: is each record uniquely keyed? Does replay produce identical state? Is observation stamped strictly in the past? Are costs modeled on the real fill? Are regimes reported separately? If any answer is no, the pipeline is a draft. The checklist is short because the failures are few and recurring; the cost of ignoring them is not. A checklist you cannot answer in under a minute is one you will skip under pressure, so keep it to five questions and make each one a yes-or-no. The goal is not completeness; it is a gate you can actually use at 3am when something broke and you need to know whether the data is trustworthy before you trust the alert it just fired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of skipping it
&lt;/h2&gt;

&lt;p&gt;Skipping the discipline around ml feature store versioning: reproducible quants does not fail loudly. It fails as a slow drift: numbers that looked stable start disagreeing with the book, a replay produces a different P&amp;amp;L than the first run, and nobody can reproduce last month's report. By the time it is noticed, the corruption has propagated into every feature and every model trained on top. The recovery cost is then weeks, not minutes. Doing it right once is cheaper than explaining it forever. The drift is invisible because each individual discrepancy is small enough to blame on noise, and noise is always available as an excuse. The skill is to treat a small discrepancy as a signal, not a nuisance, because the small ones are how the large ones announce themselves quietly, weeks in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on tooling
&lt;/h2&gt;

&lt;p&gt;You do not need a heavy framework. A flat file with a unique key, an upsert on conflict, and a checksum per batch is enough for most retail-scale tick stores. The framework temptation is real, because frameworks feel like progress and a flat file feels like a stopgap. But the value is in the contract (unique key, past-only, verified replay), not in the library. A simple script that honors the contract beats a complex platform that violates it. When you outgrow the flat file, you will outgrow it in a way the contract already anticipated, and the migration will be a swap of storage, not a rewrite of assumptions. That is the whole point of respecting the constraint over chasing the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;ml feature store versioning: reproducible quants is not a feature you add; it is a constraint you respect. Stamp observation in the past, key every record, model the real cost, report by regime, and keep the discarded variants. Do those five and the system is defensible. Skip any and you have a story. The market does not care which one you believe; your P&amp;amp;L will. The constraint is tedious precisely because it is always correct and never exciting, and excitement is what your attention naturally follows. Training yourself to protect the tedious parts is the unglamorous core of building systems that survive contact with real data, real outages, and real money. Everything else in this article is a footnote to that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/interactive-tools-for-nifty-options-traders-free-no-signup-4373001"&gt;Interactive Tools for Nifty Options Traders&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/ai-trading-myth-buster-5-things-ml-will-not-do-for-you-4372884"&gt;AI Trading Myth-Buster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/backtesting-pitfalls-in-options-7-ways-you-lie-to-yourself-4oak"&gt;Backtesting Pitfalls in Options&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Shakti Tiwari writes about systematic options trading and ML. Follow on &lt;a href="https://x.com/shaktitiwari" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/shakti-tiwari-a3b22a38b" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/shaktitiwari715-ai" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://dev.to/shaktitiwari"&gt;DEV&lt;/a&gt;. #ShaktiTiwariOnAI #NiftyOptions #QuantML #OptionsTrading #SystematicTrading&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>trading</category>
      <category>options</category>
    </item>
    <item>
      <title>Extracting Recording Information From a Scanned Deed</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:06:27 +0000</pubDate>
      <link>https://dev.to/multigrid/extracting-recording-information-from-a-scanned-deed-2lnc</link>
      <guid>https://dev.to/multigrid/extracting-recording-information-from-a-scanned-deed-2lnc</guid>
      <description>&lt;p&gt;The recording reference is how every other document in a title chain points at this one. It is a citation, not a database key, and the difference shows up the moment somebody tries to look one up using your normalised version of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is on the stamp
&lt;/h2&gt;

&lt;p&gt;The clerk’s stamp is applied at the moment of recording and carries more than a number. Typically it holds the county and state, the date &lt;em&gt;and time&lt;/em&gt; of recording, a reference — book and page, an instrument number, or both — the recording officer’s title, and often the fees collected and any documentary transfer tax. Several of those fields matter to somebody downstream.&lt;/p&gt;

&lt;p&gt;The time is the least obvious and among the most important. Recording statutes decide priority between competing interests, and under the race and race-notice regimes used by most states, priority turns on who recorded first. Two instruments recorded on the same day are separated only by the time on the stamp and the sequence in the recorder’s own numbering. An extraction that stores a date and discards a time has thrown away the tie-breaker.&lt;/p&gt;

&lt;p&gt;The fee and tax figures are tempting as a route to sale price, because documentary transfer taxes are usually levied as a rate on consideration. Be careful: the rate is set by statute and varies by state and often by city, some conveyances are exempt and stamped as such, and some deeds recite a nominal consideration with the real price elsewhere. Extract the tax amount as an amount, not as a derived price, and leave the arithmetic to somebody who knows which rate applied on that date in that jurisdiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Book and page is not unique
&lt;/h2&gt;

&lt;p&gt;The oldest convention is a physical one. Instruments were transcribed into bound volumes; the citation is the volume and the page it starts on. Different jurisdictions call the volume different things — Deed Book, Official Records Book, Volume, and in Maryland and parts of New York the older &lt;em&gt;Liber&lt;/em&gt; and &lt;em&gt;Folio&lt;/em&gt; — and some counties, having gone through microfilm, cite a reel and image or frame number instead.&lt;/p&gt;

&lt;p&gt;The point that breaks pipelines is that these are separate series. A county may have run a Deed Book series, a Mortgage Book series and a Miscellaneous series at the same time, each with its own volume 1. So “Book 1245, Page 331” identifies a document only in combination with the county, the state and the series. A schema of two integers is not an identifier; it is two integers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recording_reference_raw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Recorded 04/09/2019 at 2:17 PM&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;OFFICIAL RECORDS BK 1245 PG 331-334&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;FRANKLIN COUNTY, OHIO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"county"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Franklin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OH"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"series_as_written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFFICIAL RECORDS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"book"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1245"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"331"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"334"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recorded_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2019-04-09"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recorded_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14:17"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the page range. Long instruments span pages, and the citation may be written as a range, as a start page only, or as a start page plus a page count. All three occur, and a schema with a single integer page silently truncates the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument numbers and the cutover
&lt;/h2&gt;

&lt;p&gt;As recording offices moved to electronic systems, most adopted a sequential instrument number — also called a document number, file number or reception number — usually with a year prefix, such as &lt;code&gt;2019-0043221&lt;/code&gt; or &lt;code&gt;201900043221&lt;/code&gt;. It is assigned in order of receipt, so it encodes the same priority information the time stamp does.&lt;/p&gt;

&lt;p&gt;Every county made that change on its own date, and around that date the records are mixed. Documents recorded before the cutover have a book and page and no instrument number; documents after have an instrument number and, in many counties, a book and page as well, because the office kept assigning them for continuity. So the correct schema has both references as optional and requires at least one, not a single reference field with a type discriminator that has to be guessed — the general case of &lt;a href="https://multigrid.ai/learn/extraction-schema-design-unseen-variants" rel="noopener noreferrer"&gt;designing a schema for variants you have not seen&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The formatting traps are mundane and expensive. Leading zeros are significant: &lt;code&gt;2019-0043221&lt;/code&gt; stored as the integer 43221 no longer matches the recorder’s search index. Hyphens appear or do not, sometimes within the same county. Prefixes are used for document class in some offices. And the year prefix is a two-digit year often enough that a naive parse of &lt;code&gt;19-0043221&lt;/code&gt; as a year is wrong in both directions.&lt;/p&gt;

&lt;p&gt;There is no national registry of these formats. Every recording office publishes its own conventions and its own search interface, and the only authority for what a reference in a given county looks like is that county’s recorder or clerk. Treat any per-county pattern in your pipeline as configuration to be verified against that office, never as something inferred from a sample of documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the raw string
&lt;/h2&gt;

&lt;p&gt;Everything above is an argument for the same design: the raw stamp text is the field of record and the normalised fields are derived from it, stored beside it, and allowed to be null.&lt;/p&gt;

&lt;p&gt;There are three reasons this is not merely cautious. First, the stamp text is what a person quotes when they cite the instrument, and reassembling “Official Records Book 1245, Page 331” from parsed fields requires knowing the county’s house style, which you do not. Second, when the parse is wrong, the raw string is the only way to find out; a corrected parser can be re-run over stored raw text and cannot be re-run over discarded raw text. Third, stamps contain things your schema does not have fields for — a clerk’s initials, an auditor’s transfer endorsement, a plat reference — and those turn out to matter about once a year.&lt;/p&gt;

&lt;p&gt;Stamps are also physically awkward. They are frequently rotated, because the clerk stamped the page sideways in the margin; they are frequently the darkest thing on a light scan, or the lightest thing on a dark one; and they overlap printed text, which is the problem discussed from the other side in &lt;a href="https://multigrid.ai/learn/deed-grantor-grantee-extraction" rel="noopener noreferrer"&gt;extracting grantor and grantee names&lt;/a&gt;. Detect and deskew the stamp region separately from the body text rather than running one recognition pass over the whole page; the general handling is in &lt;a href="https://multigrid.ai/learn/ocr-pipeline" rel="noopener noreferrer"&gt;the OCR pipeline page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-recorded and corrective instruments
&lt;/h2&gt;

&lt;p&gt;The case that most often produces a wrong answer with high confidence is a re-recorded instrument. When a deed is recorded with a defect — a missing notarial seal, a typo in the legal description — it is commonly re-recorded, sometimes with a legend across the first page reading “RE-RECORDED TO CORRECT THE LEGAL DESCRIPTION”. That document now bears two recording stamps, usually months apart, and both are real. Each carries its own date, so both go through the same &lt;a href="https://multigrid.ai/learn/date-field-validation-rule" rel="noopener noreferrer"&gt;date-field validation&lt;/a&gt; as any other extracted date.&lt;/p&gt;

&lt;p&gt;A pipeline that extracts “the” recording reference from such a page will pick one, and which one it picks depends on stamp position rather than on meaning. The correct output is a list of recording events with an order, plus the re-recording legend if present. The earlier stamp is the original recording; the later one is when the corrected instrument entered the record. Which of them controls for any particular purpose is a question for a title examiner, and the extraction’s job is to make sure the examiner can see that there were two.&lt;/p&gt;

&lt;p&gt;Related but distinct is a separate &lt;em&gt;corrective deed&lt;/em&gt; or scrivener’s affidavit: a new instrument with its own recording reference that recites the reference of the deed it corrects. That recited reference is a fourth kind of string on the page — a citation to another document, not to this one — and merging it into the recording-information field is a common and confusing error. Extract references to other instruments into their own array with a relationship label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-grantor-grantee-extraction" rel="noopener noreferrer"&gt;Extracting Grantor and Grantee Names From a Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-legal-description-extraction" rel="noopener noreferrer"&gt;Extracting the Legal Description From a Property Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/extraction-schema-design-unseen-variants" rel="noopener noreferrer"&gt;Designing an Extraction Schema Before You've Seen Every Document Variant&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Extracting the Legal Description From a Property Deed</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:06:11 +0000</pubDate>
      <link>https://dev.to/multigrid/extracting-the-legal-description-from-a-property-deed-278l</link>
      <guid>https://dev.to/multigrid/extracting-the-legal-description-from-a-property-deed-278l</guid>
      <description>&lt;p&gt;A legal description is not a description of the property. It is the instrument by which the property is identified, and two documents that describe the same parcel in different words are two different legal descriptions. That is why the extraction target is a block of text, not a set of coordinates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three systems, one field
&lt;/h2&gt;

&lt;p&gt;Almost every American legal description is written in one of three conventions, and knowing which one you are looking at tells you what the rest of the string has to contain.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Metes and bounds.&lt;/strong&gt; A perimeter walked as a sequence of calls, each a bearing and a distance from a starting monument: “thence North 12 degrees 34 minutes 20 seconds East, 150.00 feet to an iron pin”. Bearings are almost always quadrant bearings — a north or south reference, an angle under 90 degrees, and an east or west direction — rather than azimuths. The description begins at a &lt;em&gt;point of beginning&lt;/em&gt;, which is frequently reached from a separate &lt;em&gt;point of commencement&lt;/em&gt;, and it must return to the point of beginning. A description that does not close is a description with a missing or garbled call.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Lot and block.&lt;/strong&gt; The parcel is a numbered lot on a recorded subdivision plat: a lot, a block, the subdivision name, and — the part that is often lost — the plat’s own recording reference and county. “Lot 7, Block 3” without the plat book and page and the county names nothing; the same lot and block exist in every county in the state.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Public Land Survey System.&lt;/strong&gt; Aliquot parts of a section: “the Northeast Quarter of the Southwest Quarter of Section 14, Township 3 North, Range 5 West”, followed by the principal meridian, which is the part naive extractions drop. A township is nominally six miles square and holds 36 sections numbered in a serpentine order, and because the survey has to reconcile a rectangular grid with a curved earth, sections along the north and west edges of a township are irregular. Those irregular pieces are described as numbered &lt;em&gt;government lots&lt;/em&gt;, not as quarters, which is why a PLSS description sometimes contains a lot number and is still not lot-and-block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single deed can carry more than one. A parcel assembled from an old farm and an adjoining platted lot will be described in PLSS for the first tract and lot-and-block for the second, joined by “AND” or numbered “Tract I / Tract II”. A schema with one string field per deed loses the second tract; a schema with an array of tracts does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verbatim block is the deliverable
&lt;/h2&gt;

&lt;p&gt;The temptation is to parse the calls into numbers on the way in: a list of bearings and distances, or a polygon. Resist it as the primary output. The legal description is operative text. If it is later quoted in a title commitment, a corrective deed or a complaint, the string that gets quoted has to be the string that is on the recorded page, punctuation and all. A pipeline that stores a reconstruction — even a faithful one — has stored a paraphrase of a legal instrument.&lt;/p&gt;

&lt;p&gt;So the first extracted field is the description as written, preserved with its line breaks, its capitalisation and its abbreviations, and stored alongside the page image and the character offsets it came from. That gives a reviewer something to compare against, which is the only meaningful check available: there is no checksum on a metes and bounds call, no authority to look it up in, and no way to tell a correct bearing from a plausible wrong one without reading the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description_verbatim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BEGINNING at an iron pin at the Southeast corner ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description_system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"metes_and_bounds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_page_range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_bbox_by_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;540&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;540&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;210&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"derived"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"calls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bearing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"N 12-34-20 E"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distance_ft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"monument"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iron pin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"derivation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model-parsed, not authoritative"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What a parsed description is actually for
&lt;/h2&gt;

&lt;p&gt;The parsed form is still worth producing, but for a different job than the verbatim block. Its value is that it fails. A call list that does not close back to the point of beginning is evidence of a transcription error, and it is one of the very few automatic checks available on this document. Sum the calls as vectors; if the closure error is large relative to the perimeter, something in the string is wrong — a digit dropped from a distance, a bearing whose quadrant letter was misread, a whole call lost at a page break.&lt;/p&gt;

&lt;p&gt;Treat that as a flag, not a correction. Real recorded descriptions fail to close for reasons that have nothing to do with your OCR: old surveys, curve calls described by arc length and radius that a naive summation treats as straight lines, and calls that run “along the centerline of the creek” where the boundary is a natural monument with no bearing at all. Surveyors resolve conflicts between calls by a documented order of priority in which natural monuments outrank artificial monuments, which outrank courses and distances, which outrank stated acreage. That ordering is a surveying and legal doctrine, not an extraction rule, and a pipeline that starts applying it has stopped extracting and started opining.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where transcription breaks
&lt;/h2&gt;

&lt;p&gt;The failures here are unusually concentrated in a few characters, all of which appear in every metes and bounds description and nowhere else in the document.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  The degree sign, read as a zero, a lowercase &lt;code&gt;o&lt;/code&gt;, a superscript or nothing at all. “N 12 34 20 E” with the symbols gone is unparseable and, worse, sometimes parses wrongly.&lt;/li&gt;
&lt;li&gt;  Minute and second marks — a prime and a double prime — read as an apostrophe and a quotation mark, then normalised into curly quotes by a downstream text cleaner. That normalisation is invisible in a diff viewer and destroys the field.&lt;/li&gt;
&lt;li&gt;  Feet expressed with the same prime character, so &lt;code&gt;150.00'&lt;/code&gt; and 150 minutes are one glyph apart in context.&lt;/li&gt;
&lt;li&gt;  Fraction glyphs in PLSS descriptions: a one-quarter character, the digits &lt;code&gt;1/4&lt;/code&gt;, and the spelled word “Quarter” all occur, and typewritten deeds sometimes use &lt;code&gt;NE1/4SW1/4&lt;/code&gt; with no separators at all.&lt;/li&gt;
&lt;li&gt;  Descriptions that continue onto a second page or onto an attached “Exhibit A”, which is frequently a different scan quality, a different typeface and occasionally a different orientation. The exhibit, not the body, is usually the operative text where both exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these are properties of the scan rather than of the model, so they are addressed upstream: see &lt;a href="https://multigrid.ai/learn/ocr-pipeline" rel="noopener noreferrer"&gt;the OCR pipeline page&lt;/a&gt; for the recognition side and &lt;a href="https://multigrid.ai/learn/pdf-parsing" rel="noopener noreferrer"&gt;PDF parsing&lt;/a&gt; for how text layers and image layers disagree on documents like these.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clause everyone drops
&lt;/h2&gt;

&lt;p&gt;The single most consequential omission in this field is an exception. A description that ends “LESS AND EXCEPT that portion conveyed to the County for road right-of-way by instrument recorded in Book 1102, Page 88” describes a smaller parcel than the same description without those words. So does “SAVE AND EXCEPT”, “EXCEPTING THEREFROM”, and a reservation of mineral rights. Conversely “TOGETHER WITH” adds something — typically an appurtenant easement — and is equally load bearing.&lt;/p&gt;

&lt;p&gt;These clauses sit after the closing call, which is exactly where a model that has been told to extract “the legal description” decides it has finished. Give the extraction an explicit end condition instead: the description runs to the end of the descriptive text, including every exception, reservation and appurtenance, and stops at the next operative clause of the deed — the habendum, the warranty covenants, or the “subject to” list. Then add a separate boolean for whether any exception language was found, so that a missing exception on a parcel that has one shows up as a disagreement between two fields rather than as silence. Pair it with a &lt;a href="https://multigrid.ai/learn/per-field-confidence-scoring" rel="noopener noreferrer"&gt;per-field confidence signal&lt;/a&gt; and &lt;a href="https://multigrid.ai/learn/confidence-threshold-review-routing" rel="noopener noreferrer"&gt;route anything ambiguous&lt;/a&gt; to a human who can look at the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-grantor-grantee-extraction" rel="noopener noreferrer"&gt;Extracting Grantor and Grantee Names From a Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-recording-info-extraction" rel="noopener noreferrer"&gt;Extracting Recording Information From a Scanned Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/per-field-confidence-scoring" rel="noopener noreferrer"&gt;Scoring Confidence Per Field Instead of Per Document&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Volatility Surface Basics: Term Structure Explained</title>
      <dc:creator>shakti tiwari </dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:06:05 +0000</pubDate>
      <link>https://dev.to/shaktitiwari/volatility-surface-basics-term-structure-explained-4gp1</link>
      <guid>https://dev.to/shaktitiwari/volatility-surface-basics-term-structure-explained-4gp1</guid>
      <description>&lt;h1&gt;
  
  
  Volatility Surface Basics: Term Structure Explained
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;By Shakti Tiwari · Educational only · Not investment advice&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article explains volatility surface basics: term structure explained from first principles. No live market numbers are quoted; the structure is what lasts.&lt;/p&gt;

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

&lt;p&gt;Volatility Surface Basics: Term Structure Explained is one of those subjects that sounds simple until you implement it, at which point the hidden complexity appears. The first version works on a laptop with a tiny file; the second version breaks at 3am when the WebSocket drops, the replay file is half-written, and you cannot tell which ticks you already stored. This article is a structural walkthrough: the concepts, the math where it helps, the code shape where it helps, and the failure modes that quietly cost money or correctness. No live market numbers are quoted because a number without a dated source is decoration, not education. The structure here does not expire, and unlike a specific price level, you can reuse it on the next dataset without re-deriving anything. If you only remember one sentence from this page, make it this: the boring parts are the product, and the interesting parts are a small fraction of what separates a demo from a system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core concept
&lt;/h2&gt;

&lt;p&gt;At its heart, volatility surface basics: term structure explained is about being honest with your own assumptions. The trap is not that the idea is wrong; it is that a half-implemented version looks right in a demo and breaks in production. We separate the idea from the implementation so you can tell which one you actually have. A clean concept on paper can still produce a broken system if the boundary between 'what I meant' and 'what the code does' is never made explicit. Write the concept as a contract: given X observable at time t, the system produces Y, and any deviation is a bug, not a feature. A contract you can state in one sentence is also one you can test in one assertion, and that testability is the entire difference between an architecture and a wish. The cheaper you make the test, the more often you will run it, and the fewer surprises reach production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism in three parts
&lt;/h2&gt;

&lt;p&gt;The mechanism has three parts: what you observe, what you decide, and what you pay. Most tutorials show the first two and silently drop the third. The third is where real edges live or die. We model it explicitly: observation stamped at a bar, decision using only past data, cost taken from the realized fill not the midpoint. If you skip the stamping, you get look-ahead bias. If you skip the past-only rule, you get leakage. If you skip the realized cost, you get a strategy that looks profitable until a real order touches the book. Each omission is small in code and large in consequence. The reason tutorials drop the third part is that it is annoying to model: you need a realistic fill model, a fee schedule, and a tax rule, none of which appear in a clean formula. But the formula is the easy 10%; the fill model is the real 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  A structural example
&lt;/h2&gt;

&lt;p&gt;Consider a minimal version. You collect state, compute a signal, act only if a risk budget allows, and log the outcome including the trades you discarded. The discipline is in the ordering: state from the past, signal from that state, cost from the real fill. Reorder any of those and the result becomes a story you tell yourself rather than a measurement. The example is deliberately boring because the point is the skeleton, not the flourish. A skeleton you can defend beats a polished curve built on a lie. When you later replace the minimal pieces with real infrastructure — a real feed, a real executor, a real risk engine — the skeleton stays; only the muscles change. That stability is the value of getting the order right once, up front, before any of the exciting parts are allowed to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency and replay
&lt;/h2&gt;

&lt;p&gt;Idempotency means running the same ingestion twice produces the same stored result, not duplicates. This matters the moment a connection resets and you replay a file: without a unique key per tick, you double-count volume, corrupt aggregates, and silently bias every feature built on top. The fix is mechanical: hash or sequence each record, upsert on conflict, never append blindly. Replay then becomes safe instead of dangerous, and recovery from a crash is a re-run, not an investigation. Idempotency is not a performance optimization; it is a correctness guarantee. Once you have it, you can stop fearing restarts, and you can stop writing the fragile 'resume from line N' logic that breaks the moment a partial line is written. The key is doing the dedupe at write time, where the data arrives, not at read time, where the damage is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;It breaks at the seams: when the universe changes and you forgot to track it, when volatility regime shifts and your average hid the crash, when a parameter was tuned on the same data you tested on. Each break is a specific code or process error, not a mystery. Naming it is most of the fix. The pattern across all three is the same — a piece of state that should have been explicit was left implicit, and implicit state is where bugs hide. The reason these break at the seams and not in the middle is that the seam is the boundary between two systems you control at different times: the data you collected yesterday and the model you train today. If the boundary is not a first-class object in your code, it will be a first-class source of error in your P&amp;amp;L, and you will not see it until it is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to make it robust
&lt;/h2&gt;

&lt;p&gt;Robustness is boring: point-in-time data, walk-forward splits with a frozen holdout, explicit cost including tax on the correct leg, per-regime reporting, and a log of failed variants. None of these are optional if you intend to trust the number. They are the difference between a backtest and a bedtime story. Apply the same standard to the data layer: schema checked on write, duplicates rejected on key, checksums verified on replay. Robustness is not a feature you add at the end; it is a property you preserve at every step. Each shortcut you take 'just to ship' is a loan against a future debugging session, and the interest is paid in the currency you care about most — trust in your own numbers. The discipline is to treat the loan as real even when the deadline feels imaginary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common misconception
&lt;/h2&gt;

&lt;p&gt;A common misconception about volatility surface basics: term structure explained is that more data or a fancier model fixes a broken loop. It does not. A leaky pipeline with ten years of data is still leaky; a deep model on mispriced fills still loses money. Fix the loop first; the model is the last thing you improve, not the first. The urge to reach for a bigger hammer is natural, because the hammer is visible and the loop is invisible. But the bottleneck is almost always upstream of the model, in the plumbing that decides what the model is allowed to see. Every hour spent on a better architecture while the ingestion is still leaking is an hour spent polishing the wrong thing. The honest move is unglamorous: close the leak, re-run, and only then ask whether the model needs to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection to the pipeline
&lt;/h2&gt;

&lt;p&gt;This fits the companion stack: idempotent tick ingestion keeps the feature store reproducible, leakage-free XGBoost features keep the model honest, and a governed backtest keeps the loop from lying to you. The articles build on each other; read them in order if you are assembling a real system. Skipping the ingestion discipline to get faster to the model is the most expensive shortcut in quant, because every later step inherits the corruption. The pipeline is a chain, and a chain is only as strong as its weakest link; the ingestion link is also the one closest to the raw truth, so corrupting it poisons everything downstream while leaving the model code looking pristine. That is why the boring layer deserves the most scrutiny, not the least.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good enough looks like
&lt;/h2&gt;

&lt;p&gt;Good enough is not perfect; it is a system whose known limitations are written down. State the regimes you did not cover, the costs you approximated, the parameters you tuned. A result with honest scars beats a flawless one that hides them. That is the entire point of governed publishing: ship the analysis, keep the caveats attached, and let the reader see the seams instead of a polished surface. Good enough is also reproducible: someone else, with your notes, can rebuild the same number. If only you can reproduce it, it is not an analysis — it is a coincidence you happened to witness. Write the limitations as if the reader is a skeptic version of your future self, because that skeptic is exactly who will eventually read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal checklist
&lt;/h2&gt;

&lt;p&gt;Before you trust a pipeline built on this idea, answer: is each record uniquely keyed? Does replay produce identical state? Is observation stamped strictly in the past? Are costs modeled on the real fill? Are regimes reported separately? If any answer is no, the pipeline is a draft. The checklist is short because the failures are few and recurring; the cost of ignoring them is not. A checklist you cannot answer in under a minute is one you will skip under pressure, so keep it to five questions and make each one a yes-or-no. The goal is not completeness; it is a gate you can actually use at 3am when something broke and you need to know whether the data is trustworthy before you trust the alert it just fired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of skipping it
&lt;/h2&gt;

&lt;p&gt;Skipping the discipline around volatility surface basics: term structure explained does not fail loudly. It fails as a slow drift: numbers that looked stable start disagreeing with the book, a replay produces a different P&amp;amp;L than the first run, and nobody can reproduce last month's report. By the time it is noticed, the corruption has propagated into every feature and every model trained on top. The recovery cost is then weeks, not minutes. Doing it right once is cheaper than explaining it forever. The drift is invisible because each individual discrepancy is small enough to blame on noise, and noise is always available as an excuse. The skill is to treat a small discrepancy as a signal, not a nuisance, because the small ones are how the large ones announce themselves quietly, weeks in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on tooling
&lt;/h2&gt;

&lt;p&gt;You do not need a heavy framework. A flat file with a unique key, an upsert on conflict, and a checksum per batch is enough for most retail-scale tick stores. The framework temptation is real, because frameworks feel like progress and a flat file feels like a stopgap. But the value is in the contract (unique key, past-only, verified replay), not in the library. A simple script that honors the contract beats a complex platform that violates it. When you outgrow the flat file, you will outgrow it in a way the contract already anticipated, and the migration will be a swap of storage, not a rewrite of assumptions. That is the whole point of respecting the constraint over chasing the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;volatility surface basics: term structure explained is not a feature you add; it is a constraint you respect. Stamp observation in the past, key every record, model the real cost, report by regime, and keep the discarded variants. Do those five and the system is defensible. Skip any and you have a story. The market does not care which one you believe; your P&amp;amp;L will. The constraint is tedious precisely because it is always correct and never exciting, and excitement is what your attention naturally follows. Training yourself to protect the tedious parts is the unglamorous core of building systems that survive contact with real data, real outages, and real money. Everything else in this article is a footnote to that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/interactive-tools-for-nifty-options-traders-free-no-signup-4373001"&gt;Interactive Tools for Nifty Options Traders&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/ai-trading-myth-buster-5-things-ml-will-not-do-for-you-4372884"&gt;AI Trading Myth-Buster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/backtesting-pitfalls-in-options-7-ways-you-lie-to-yourself-4oak"&gt;Backtesting Pitfalls in Options&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Shakti Tiwari writes about systematic options trading and ML. Follow on &lt;a href="https://x.com/shaktitiwari" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/shakti-tiwari-a3b22a38b" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/shaktitiwari715-ai" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://dev.to/shaktitiwari"&gt;DEV&lt;/a&gt;. #ShaktiTiwariOnAI #NiftyOptions #QuantML #OptionsTrading #SystematicTrading&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>trading</category>
      <category>options</category>
    </item>
    <item>
      <title>Extracting Grantor and Grantee Names From a Deed</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:05:56 +0000</pubDate>
      <link>https://dev.to/multigrid/extracting-grantor-and-grantee-names-from-a-deed-1npe</link>
      <guid>https://dev.to/multigrid/extracting-grantor-and-grantee-names-from-a-deed-1npe</guid>
      <description>&lt;p&gt;Grantor and grantee are not two name fields. They are one relationship with a direction, and the direction is what the county recorder indexes on. Get it backwards and every chain of title built on your data runs the wrong way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direction is the whole field
&lt;/h2&gt;

&lt;p&gt;The grantor conveys; the grantee receives. Recorders have maintained two indexes on that distinction for as long as there have been recording offices: a grantor index, usually called the direct index, and a grantee index, usually called the indirect or reverse index. Title work runs backwards through the grantee index — find the instrument in which the current owner was the grantee, note who the grantor was, then find the instrument in which &lt;em&gt;that&lt;/em&gt; party was the grantee, and so on back through the chain.&lt;/p&gt;

&lt;p&gt;This is why a swapped pair is worse than a missing pair. A missing pair is a gap somebody notices. A swapped pair produces a chain that links up cleanly and describes a sequence of conveyances that never happened. Nothing downstream can detect it, because both names are real and both appear on the document.&lt;/p&gt;

&lt;p&gt;The same relationship appears under other names on other instruments and the roles do not map one-to-one. A mortgage has a mortgagor (the borrower, who grants the security interest) and a mortgagee. A deed of trust has three parties: a trustor or grantor, a trustee who holds bare legal title, and a beneficiary who is the lender. If your schema has only &lt;code&gt;grantor&lt;/code&gt; and &lt;code&gt;grantee&lt;/code&gt;, a deed of trust does not fit in it, and forcing the beneficiary into the grantee slot loses the trustee entirely. Type the instrument first, then extract the party roles that instrument type actually has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anchor on the granting clause
&lt;/h2&gt;

&lt;p&gt;Deeds are formulaic, and the formula is what to anchor on. The granting clause is a single sentence built around an operative verb phrase, and the parties sit on either side of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;THIS INDENTURE, made this 3rd day of April, 2019, between
ALEX RIVERA and JORDAN RIVERA, husband and wife, of Franklin County,
party of the first part, and
MERIDIAN HOLDINGS LLC, a Delaware limited liability company,
party of the second part;

WITNESSETH, that the said party of the first part, for and in
consideration of Ten Dollars ($10.00) and other good and valuable
consideration, does hereby GRANT, BARGAIN, SELL AND CONVEY unto the
said party of the second part, its successors and assigns forever,
the following described real estate ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The party before the verb grants; the party after “unto” receives. That holds across the vocabulary variants: a warranty deed says “grant, bargain, sell and convey”, a quitclaim says “remise, release and forever quitclaim”, a special warranty deed narrows the covenant but keeps the same shape. Older instruments use “party of the first part” and “party of the second part” as a layer of indirection, and the names are defined in a preamble above; a model that reads only the granting clause of such a deed will extract two phrases rather than two names, which is at least an honest failure.&lt;/p&gt;

&lt;p&gt;Do not anchor on position on the page. The block at the top left is often “Prepared by” and the block beside it “When recorded return to” — a law firm and a title company respectively, and neither is a party. Extractions that key on “the first name on the document” return the drafting attorney with high confidence, which is the worst combination of properties an extracted field can have. Instructing the model to quote the span it used, as in &lt;a href="https://multigrid.ai/learn/extraction-prompts" rel="noopener noreferrer"&gt;extraction prompts&lt;/a&gt;, makes that failure visible in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Names carry capacity
&lt;/h2&gt;

&lt;p&gt;The party field is rarely just a name. What follows it is frequently part of the identification and cannot be dropped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Vesting language for the grantee.&lt;/strong&gt; “as joint tenants with right of survivorship”, “as tenants in common”, “as tenants by the entirety”, “a married person as sole and separate property”. This determines what happens on death and is a distinct field, not a suffix on the name string.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Representative capacity.&lt;/strong&gt; “PAT MORGAN, as Trustee of the Morgan Family Revocable Trust dated 12 June 2014” is the trust taking title, not the individual. Store the trust name and the trust date as their own fields; the trust date distinguishes two trusts with the same family name. The same applies to “as Personal Representative of the Estate of” and “as Attorney-in-Fact for”.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Entity form.&lt;/strong&gt; “a Delaware limited liability company” is jurisdictional information that matters when two entities share a name across states.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Latin abbreviations in older deeds.&lt;/strong&gt; &lt;code&gt;et ux.&lt;/code&gt; means “and wife”, &lt;code&gt;et vir&lt;/code&gt; “and husband”, &lt;code&gt;et al.&lt;/code&gt; “and others”. These are not names and they are not noise: they signal a party who exists and is not printed. Extracting “ALEX RIVERA et ux.” as a complete grantor list is wrong, and the correct output records that an unnamed additional grantor is present.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When the stamp lands on the name
&lt;/h2&gt;

&lt;p&gt;The recording stamp is applied by the clerk after execution, so it physically sits on top of whatever was already printed there. On a deed whose margins are narrow, that is frequently the first line or two of the preamble — which is where the grantor’s name is. Scanned from microfilm, the result is a name with a rectangle of purple ink through it, or a name with two characters replaced by a county seal.&lt;/p&gt;

&lt;p&gt;The failure mode to design against is not the model refusing. It is the model completing. A language model asked for a grantor name from a page reading “ALE_ RI_ERA” will very often return “ALEX RIVERA” — a plausible completion of a common name, indistinguishable in the output from a clean read. Names are precisely the field where inference is illegitimate: unlike a date or a total, a name has no arithmetic and no context that constrains it, and a plausible wrong name is a real person.&lt;/p&gt;

&lt;p&gt;So the response is structural. Ask for the characters actually legible and a separate occlusion flag, rather than for a name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grantor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name_as_written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ALE? RI?ERA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legible"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occlusion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recording stamp overlaps characters 4 and 7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;132&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;388&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;156&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A field that a model cannot read should &lt;a href="https://multigrid.ai/learn/illegible-field-handling" rel="noopener noreferrer"&gt;arrive as an unread field with a reason&lt;/a&gt;, never as a guess. The point of the bounding box is that &lt;a href="https://multigrid.ai/learn/review-queue-source-highlighting" rel="noopener noreferrer"&gt;a reviewer opens the page at the right place&lt;/a&gt; instead of hunting for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The acknowledgment is a second reading
&lt;/h2&gt;

&lt;p&gt;There is one genuinely useful redundancy on a deed, and it is underused. Every recorded deed carries a notarial acknowledgment near the signatures, and the acknowledgment restates the grantor’s name in full: “On this 3rd day of April, 2019, before me personally appeared ALEX RIVERA and JORDAN RIVERA, known to me to be the persons described in and who executed the foregoing instrument”. It is a second, physically separate rendering of the same name, at the bottom of the page rather than the top, and therefore almost never occluded by the same stamp.&lt;/p&gt;

&lt;p&gt;Extract it as its own field and compare. Agreement raises confidence on both. Disagreement is informative in itself — it is frequently a middle initial or a suffix present in one place and not the other, occasionally a genuinely different party where a deed was signed by an attorney-in-fact, and sometimes a scrivener’s error that is exactly what a title examiner is looking for. Note that the acknowledgment covers only the signing parties, so it corroborates the grantor and says nothing about the grantee, who does not sign an ordinary deed at all. The grantee therefore has no second source and stays a single-read field.&lt;/p&gt;

&lt;p&gt;Party names on identity-adjacent documents are one of the places where providers’ safety behaviour differs: the same page that one vision model transcribes will occasionally draw a refusal from another, and a refusal in the middle of a county-scale backlog is an operational problem rather than a content one. If you route through a gateway like Multigrid, the fallback is a routing rule rather than a second integration — one key, one request shape, and the retry on a different provider shows up in the same per-request cost log as the original.&lt;/p&gt;

&lt;p&gt;The recording stamp is also the source of the citation for the instrument itself, which is a separate extraction with separate failure modes — see &lt;a href="https://multigrid.ai/learn/deed-recording-info-extraction" rel="noopener noreferrer"&gt;extracting recording information&lt;/a&gt;, and &lt;a href="https://multigrid.ai/learn/deed-legal-description-extraction" rel="noopener noreferrer"&gt;the legal description page&lt;/a&gt; for the other half of what makes a deed a deed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-legal-description-extraction" rel="noopener noreferrer"&gt;Extracting the Legal Description From a Property Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/deed-recording-info-extraction" rel="noopener noreferrer"&gt;Extracting Recording Information From a Scanned Deed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/illegible-field-handling" rel="noopener noreferrer"&gt;Handling a Field That Is Present but Illegible&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Deduplicating Flaky Failures Across a Large Prompt Test Suite</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:05:40 +0000</pubDate>
      <link>https://dev.to/multigrid/deduplicating-flaky-failures-across-a-large-prompt-test-suite-4idg</link>
      <guid>https://dev.to/multigrid/deduplicating-flaky-failures-across-a-large-prompt-test-suite-4idg</guid>
      <description>&lt;p&gt;When a suite that calls a model goes red, it usually goes red in clusters: one rate limit, one bad deploy, one model alias moving, and forty tests fail at once. Reading that as forty problems is the default behaviour of every test report, and it is wrong in a way that costs hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grouping by test name fails
&lt;/h2&gt;

&lt;p&gt;A test report is organised by the thing the runner knows: file, class, test name. That organisation assumes failures are independent, which is a decent assumption for unit tests and a bad one for tests that all go through the same HTTP client to the same provider. When the shared dependency wobbles, every test that touches it fails, and the report presents them as unrelated because their names are unrelated.&lt;/p&gt;

&lt;p&gt;The consequence is not just wasted reading. It distorts every downstream number: your flake counts spike for forty tests that are individually fine, your triage backlog fills with duplicates, and a genuine single-test regression that happened during the same run is buried in the noise. Grouping by cause is what makes the run legible — one incident, one row, forty affected tests, and any test that failed for a different reason standing out immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes into a signature
&lt;/h2&gt;

&lt;p&gt;A signature is a short string computed from a failure such that two failures with the same root cause produce the same string, and two with different causes do not. The fields worth including, in priority order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The HTTP status, if there was one.&lt;/strong&gt; A 429 and a 500 are different incidents even if the resulting exception text is identical. Capture this in the test harness — it is not in the traceback.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The provider error type or code.&lt;/strong&gt; &lt;code&gt;rate_limit_exceeded&lt;/code&gt;, &lt;code&gt;overloaded_error&lt;/code&gt;, &lt;code&gt;context_length_exceeded&lt;/code&gt; and their equivalents are the most discriminating field available, and they are stable strings.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The exception class name.&lt;/strong&gt; &lt;code&gt;AssertionError&lt;/code&gt; versus &lt;code&gt;ValidationError&lt;/code&gt; versus a transport timeout is the first split most reports never make.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The assertion site.&lt;/strong&gt; The last stack frame inside your own code — file and function, not line number, because line numbers move with every edit and would fragment the group across commits.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The served model id.&lt;/strong&gt; The same assertion failing on two different models is two problems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What must &lt;em&gt;not&lt;/em&gt; go in: the test name, the full message, the request id, or any timestamp. Include the test name and every group has exactly one member, which is where you started.&lt;/p&gt;

&lt;p&gt;The trade-off to keep in view is that a signature can be too coarse as easily as too fine. Hashing only the exception class collapses every &lt;code&gt;AssertionError&lt;/code&gt; in the suite into one group, which is a single useless row. The test of a good signature is behavioural rather than aesthetic: when you look at the largest group, every member should have the same fix. If two members would be fixed by different people, add a field; if two groups would be fixed by the same change, remove one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The normalisation rules
&lt;/h2&gt;

&lt;p&gt;The message is where the useful discrimination lives and also where all the entropy is, so it needs normalising before it is hashed. Six substitutions do most of the work, and the order matters — do the specific ones before the general ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;SUBS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[0-9a-f]{4}-[0-9a-f]{12}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;uuid&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(req|chatcmpl|msg)_[A-Za-z0-9]{6,}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;ts&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x[0-9a-f]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;addr&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;[^&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]{40,}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;long&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# inlined prompts and completions
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b\d+\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;n&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;          &lt;span class="c1"&gt;# do this LAST
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replacement&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUBS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of those deserve a warning. Replacing every integer with &lt;code&gt;&amp;lt;n&amp;gt;&lt;/code&gt; collapses “expected 1 tool call, got 0” and “expected 1 tool call, got 3” into one group, which is usually what you want and occasionally hides a real distinction; run it last so the more specific patterns claim their matches first. And truncating to the first line drops the model’s output from the message, which is the entire point — a completion embedded in an assertion message is unique every time and will defeat any grouping if you leave it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  A script over your JUnit XML
&lt;/h2&gt;

&lt;p&gt;Every runner worth using emits JUnit XML: pytest with &lt;code&gt;--junitxml&lt;/code&gt;, Vitest with &lt;code&gt;--reporter=junit --outputFile&lt;/code&gt;, Playwright with its &lt;code&gt;junit&lt;/code&gt; reporter. That file is the input, and the standard library can parse it — no dependency required.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Make every job emit JUnit XML to a known path and upload it as an artifact. Do this even for jobs that pass; the pass rows are the denominator of every rate you will later want.&lt;/li&gt;
&lt;li&gt; Parse each &lt;code&gt;&amp;lt;testcase&amp;gt;&lt;/code&gt; and read its &lt;code&gt;&amp;lt;failure&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;error&amp;gt;&lt;/code&gt; child. Where your runner emits the rerun dialect, also read &lt;code&gt;&amp;lt;flakyFailure&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;rerunFailure&amp;gt;&lt;/code&gt;: those are the attempts that were retried, and they are the flake signal.&lt;/li&gt;
&lt;li&gt; Compute the signature and group. Print the groups largest first.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;etree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ElementTree&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="n"&gt;FAILURE_TAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flakyFailure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerunFailure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;

&lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;testcase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FAILURE_TAGS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;        &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;        ... and &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; more&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script deliberately reads several files at once, because the unit you want to group over is a CI run, not a job. A suite split across eight parallel shards writes eight XML files, and a rate limit that hit all eight is one incident; grouping per file would report it as eight. Pass the whole artifact directory.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;classname&lt;/code&gt; is in the signature but the test &lt;code&gt;name&lt;/code&gt; is not. Including the class keeps failures in genuinely unrelated modules apart while still collapsing the twenty tests inside one module; drop it entirely if your suite groups everything into one class, since it then contributes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the groups
&lt;/h2&gt;

&lt;p&gt;The output changes what a red build asks of you. A single group with forty members and a &lt;code&gt;rate_limit_exceeded&lt;/code&gt; code is one action: reduce the suite’s concurrency, which &lt;a href="https://multigrid.ai/learn/prompt-test-passes-locally-fails-ci" rel="noopener noreferrer"&gt;the locally-green, CI-red page&lt;/a&gt; covers. A group of one, with an &lt;code&gt;AssertionError&lt;/code&gt; at a specific function in your own code, is the thing you actually have to read.&lt;/p&gt;

&lt;p&gt;Persist the signature alongside every attempt rather than computing it only in the moment. Once it is a column, the questions that were hard become trivial: which signature is new this week, which one accounts for most of the suite’s red time, whether the group that spiked on Tuesday has appeared before. That is one more field in the table &lt;a href="https://multigrid.ai/learn/flaky-test-dashboard-prompts" rel="noopener noreferrer"&gt;a flake dashboard&lt;/a&gt; already needs, and it is the field that makes the dashboard worth building.&lt;/p&gt;

&lt;p&gt;The rerun-related JUnit elements are a Surefire-derived extension, not part of any single standard, and support varies by runner and by CI product. Check what your runner actually emits before relying on &lt;code&gt;&amp;lt;flakyFailure&amp;gt;&lt;/code&gt; being present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/flaky-test-dashboard-prompts" rel="noopener noreferrer"&gt;A Dashboard for Tracking Flaky Prompt Tests Over Time&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/flakiness-score-prompt-tests" rel="noopener noreferrer"&gt;A Flakiness Score for Ranking Which Prompt Tests to Fix First&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>tutorial</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Theta Harvesting: Collecting Decay Without Getting Burned</title>
      <dc:creator>shakti tiwari </dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:05:26 +0000</pubDate>
      <link>https://dev.to/shaktitiwari/theta-harvesting-collecting-decay-without-getting-burned-3ac5</link>
      <guid>https://dev.to/shaktitiwari/theta-harvesting-collecting-decay-without-getting-burned-3ac5</guid>
      <description>&lt;h1&gt;
  
  
  Theta Harvesting: Collecting Decay Without Getting Burned
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;By Shakti Tiwari · Educational only · Not investment advice&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article explains theta harvesting: collecting decay without getting burned from first principles. No live market numbers are quoted; the structure is what lasts.&lt;/p&gt;

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

&lt;p&gt;Theta Harvesting: Collecting Decay Without Getting Burned is one of those subjects that sounds simple until you implement it, at which point the hidden complexity appears. The first version works on a laptop with a tiny file; the second version breaks at 3am when the WebSocket drops, the replay file is half-written, and you cannot tell which ticks you already stored. This article is a structural walkthrough: the concepts, the math where it helps, the code shape where it helps, and the failure modes that quietly cost money or correctness. No live market numbers are quoted because a number without a dated source is decoration, not education. The structure here does not expire, and unlike a specific price level, you can reuse it on the next dataset without re-deriving anything. If you only remember one sentence from this page, make it this: the boring parts are the product, and the interesting parts are a small fraction of what separates a demo from a system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core concept
&lt;/h2&gt;

&lt;p&gt;At its heart, theta harvesting: collecting decay without getting burned is about being honest with your own assumptions. The trap is not that the idea is wrong; it is that a half-implemented version looks right in a demo and breaks in production. We separate the idea from the implementation so you can tell which one you actually have. A clean concept on paper can still produce a broken system if the boundary between 'what I meant' and 'what the code does' is never made explicit. Write the concept as a contract: given X observable at time t, the system produces Y, and any deviation is a bug, not a feature. A contract you can state in one sentence is also one you can test in one assertion, and that testability is the entire difference between an architecture and a wish. The cheaper you make the test, the more often you will run it, and the fewer surprises reach production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism in three parts
&lt;/h2&gt;

&lt;p&gt;The mechanism has three parts: what you observe, what you decide, and what you pay. Most tutorials show the first two and silently drop the third. The third is where real edges live or die. We model it explicitly: observation stamped at a bar, decision using only past data, cost taken from the realized fill not the midpoint. If you skip the stamping, you get look-ahead bias. If you skip the past-only rule, you get leakage. If you skip the realized cost, you get a strategy that looks profitable until a real order touches the book. Each omission is small in code and large in consequence. The reason tutorials drop the third part is that it is annoying to model: you need a realistic fill model, a fee schedule, and a tax rule, none of which appear in a clean formula. But the formula is the easy 10%; the fill model is the real 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  A structural example
&lt;/h2&gt;

&lt;p&gt;Consider a minimal version. You collect state, compute a signal, act only if a risk budget allows, and log the outcome including the trades you discarded. The discipline is in the ordering: state from the past, signal from that state, cost from the real fill. Reorder any of those and the result becomes a story you tell yourself rather than a measurement. The example is deliberately boring because the point is the skeleton, not the flourish. A skeleton you can defend beats a polished curve built on a lie. When you later replace the minimal pieces with real infrastructure — a real feed, a real executor, a real risk engine — the skeleton stays; only the muscles change. That stability is the value of getting the order right once, up front, before any of the exciting parts are allowed to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency and replay
&lt;/h2&gt;

&lt;p&gt;Idempotency means running the same ingestion twice produces the same stored result, not duplicates. This matters the moment a connection resets and you replay a file: without a unique key per tick, you double-count volume, corrupt aggregates, and silently bias every feature built on top. The fix is mechanical: hash or sequence each record, upsert on conflict, never append blindly. Replay then becomes safe instead of dangerous, and recovery from a crash is a re-run, not an investigation. Idempotency is not a performance optimization; it is a correctness guarantee. Once you have it, you can stop fearing restarts, and you can stop writing the fragile 'resume from line N' logic that breaks the moment a partial line is written. The key is doing the dedupe at write time, where the data arrives, not at read time, where the damage is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;It breaks at the seams: when the universe changes and you forgot to track it, when volatility regime shifts and your average hid the crash, when a parameter was tuned on the same data you tested on. Each break is a specific code or process error, not a mystery. Naming it is most of the fix. The pattern across all three is the same — a piece of state that should have been explicit was left implicit, and implicit state is where bugs hide. The reason these break at the seams and not in the middle is that the seam is the boundary between two systems you control at different times: the data you collected yesterday and the model you train today. If the boundary is not a first-class object in your code, it will be a first-class source of error in your P&amp;amp;L, and you will not see it until it is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to make it robust
&lt;/h2&gt;

&lt;p&gt;Robustness is boring: point-in-time data, walk-forward splits with a frozen holdout, explicit cost including tax on the correct leg, per-regime reporting, and a log of failed variants. None of these are optional if you intend to trust the number. They are the difference between a backtest and a bedtime story. Apply the same standard to the data layer: schema checked on write, duplicates rejected on key, checksums verified on replay. Robustness is not a feature you add at the end; it is a property you preserve at every step. Each shortcut you take 'just to ship' is a loan against a future debugging session, and the interest is paid in the currency you care about most — trust in your own numbers. The discipline is to treat the loan as real even when the deadline feels imaginary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common misconception
&lt;/h2&gt;

&lt;p&gt;A common misconception about theta harvesting: collecting decay without getting burned is that more data or a fancier model fixes a broken loop. It does not. A leaky pipeline with ten years of data is still leaky; a deep model on mispriced fills still loses money. Fix the loop first; the model is the last thing you improve, not the first. The urge to reach for a bigger hammer is natural, because the hammer is visible and the loop is invisible. But the bottleneck is almost always upstream of the model, in the plumbing that decides what the model is allowed to see. Every hour spent on a better architecture while the ingestion is still leaking is an hour spent polishing the wrong thing. The honest move is unglamorous: close the leak, re-run, and only then ask whether the model needs to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection to the pipeline
&lt;/h2&gt;

&lt;p&gt;This fits the companion stack: idempotent tick ingestion keeps the feature store reproducible, leakage-free XGBoost features keep the model honest, and a governed backtest keeps the loop from lying to you. The articles build on each other; read them in order if you are assembling a real system. Skipping the ingestion discipline to get faster to the model is the most expensive shortcut in quant, because every later step inherits the corruption. The pipeline is a chain, and a chain is only as strong as its weakest link; the ingestion link is also the one closest to the raw truth, so corrupting it poisons everything downstream while leaving the model code looking pristine. That is why the boring layer deserves the most scrutiny, not the least.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good enough looks like
&lt;/h2&gt;

&lt;p&gt;Good enough is not perfect; it is a system whose known limitations are written down. State the regimes you did not cover, the costs you approximated, the parameters you tuned. A result with honest scars beats a flawless one that hides them. That is the entire point of governed publishing: ship the analysis, keep the caveats attached, and let the reader see the seams instead of a polished surface. Good enough is also reproducible: someone else, with your notes, can rebuild the same number. If only you can reproduce it, it is not an analysis — it is a coincidence you happened to witness. Write the limitations as if the reader is a skeptic version of your future self, because that skeptic is exactly who will eventually read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal checklist
&lt;/h2&gt;

&lt;p&gt;Before you trust a pipeline built on this idea, answer: is each record uniquely keyed? Does replay produce identical state? Is observation stamped strictly in the past? Are costs modeled on the real fill? Are regimes reported separately? If any answer is no, the pipeline is a draft. The checklist is short because the failures are few and recurring; the cost of ignoring them is not. A checklist you cannot answer in under a minute is one you will skip under pressure, so keep it to five questions and make each one a yes-or-no. The goal is not completeness; it is a gate you can actually use at 3am when something broke and you need to know whether the data is trustworthy before you trust the alert it just fired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of skipping it
&lt;/h2&gt;

&lt;p&gt;Skipping the discipline around theta harvesting: collecting decay without getting burned does not fail loudly. It fails as a slow drift: numbers that looked stable start disagreeing with the book, a replay produces a different P&amp;amp;L than the first run, and nobody can reproduce last month's report. By the time it is noticed, the corruption has propagated into every feature and every model trained on top. The recovery cost is then weeks, not minutes. Doing it right once is cheaper than explaining it forever. The drift is invisible because each individual discrepancy is small enough to blame on noise, and noise is always available as an excuse. The skill is to treat a small discrepancy as a signal, not a nuisance, because the small ones are how the large ones announce themselves quietly, weeks in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on tooling
&lt;/h2&gt;

&lt;p&gt;You do not need a heavy framework. A flat file with a unique key, an upsert on conflict, and a checksum per batch is enough for most retail-scale tick stores. The framework temptation is real, because frameworks feel like progress and a flat file feels like a stopgap. But the value is in the contract (unique key, past-only, verified replay), not in the library. A simple script that honors the contract beats a complex platform that violates it. When you outgrow the flat file, you will outgrow it in a way the contract already anticipated, and the migration will be a swap of storage, not a rewrite of assumptions. That is the whole point of respecting the constraint over chasing the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;theta harvesting: collecting decay without getting burned is not a feature you add; it is a constraint you respect. Stamp observation in the past, key every record, model the real cost, report by regime, and keep the discarded variants. Do those five and the system is defensible. Skip any and you have a story. The market does not care which one you believe; your P&amp;amp;L will. The constraint is tedious precisely because it is always correct and never exciting, and excitement is what your attention naturally follows. Training yourself to protect the tedious parts is the unglamorous core of building systems that survive contact with real data, real outages, and real money. Everything else in this article is a footnote to that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/interactive-tools-for-nifty-options-traders-free-no-signup-4373001"&gt;Interactive Tools for Nifty Options Traders&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/ai-trading-myth-buster-5-things-ml-will-not-do-for-you-4372884"&gt;AI Trading Myth-Buster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shaktitiwari/backtesting-pitfalls-in-options-7-ways-you-lie-to-yourself-4oak"&gt;Backtesting Pitfalls in Options&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Shakti Tiwari writes about systematic options trading and ML. Follow on &lt;a href="https://x.com/shaktitiwari" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/shakti-tiwari-a3b22a38b" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/shaktitiwari715-ai" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://dev.to/shaktitiwari"&gt;DEV&lt;/a&gt;. #ShaktiTiwariOnAI #NiftyOptions #QuantML #OptionsTrading #SystematicTrading&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>trading</category>
      <category>options</category>
    </item>
    <item>
      <title>Getting the Decimal Comma or Point Right by Locale in AI Output</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:05:24 +0000</pubDate>
      <link>https://dev.to/multigrid/getting-the-decimal-comma-or-point-right-by-locale-in-ai-output-5o</link>
      <guid>https://dev.to/multigrid/getting-the-decimal-comma-or-point-right-by-locale-in-ai-output-5o</guid>
      <description>&lt;p&gt;A model writing German produces &lt;code&gt;1,234,56&lt;/code&gt;. It has correctly learned that German uses a decimal comma and has not learned that German therefore uses something else for the thousands, so it applied one rule and left the other alone. The result is not a foreign-looking number. It is not a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half-right bug
&lt;/h2&gt;

&lt;p&gt;The two separators are a matched pair. A locale that uses a comma to mark the decimal cannot also use a comma to group thousands, because the string would be unparseable — which is exactly what happens when a generator applies only one half of the pair.&lt;/p&gt;

&lt;p&gt;It happens because the decimal comma is the salient, frequently-stated fact (“Germans use a comma for decimals”) while the thousands separator is the consequence nobody writes down. A model following an instruction to use European number format has strong evidence for the first and weak evidence for the second, and applies what it has.&lt;/p&gt;

&lt;p&gt;Three variants of the resulting output are worth recognising:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Both commas:&lt;/strong&gt; &lt;code&gt;1,234,56&lt;/code&gt;. Unparseable. This is the most common and, mercifully, the most visible.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Decimal swapped, grouping dropped:&lt;/strong&gt; &lt;code&gt;1234,56&lt;/code&gt;. Correct as a number and unidiomatic above four digits; harmless in small amounts, noticeably wrong in a financial table.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Fully inverted but inconsistently applied:&lt;/strong&gt; &lt;code&gt;1.234,56&lt;/code&gt; in one paragraph and &lt;code&gt;1,234.56&lt;/code&gt; two paragraphs later, in the same document. This is the worst one, because each individual number is well-formed and only the document as a whole is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The conventions, worked
&lt;/h2&gt;

&lt;p&gt;One thousand two hundred and thirty-four and fifty-six hundredths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en-US   1,234.56    group comma,      decimal point
en-GB   1,234.56    group comma,      decimal point
de-DE   1.234,56    group point,      decimal comma
es-ES   1.234,56    group point,      decimal comma
it-IT   1.234,56    group point,      decimal comma
pt-BR   1.234,56    group point,      decimal comma
fr-FR   1 234,56    group narrow no-break space, decimal comma
ru-RU   1 234,56    group no-break space, decimal comma
pl-PL   1 234,56    group no-break space, decimal comma
sv-SE   1 234,56    group no-break space, decimal comma
de-CH   1'234.56    group apostrophe, decimal point
en-IN   1,234.56    group comma but Indian grouping above five digits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The French, Russian, Polish and Swedish rows are the ones that break code, and the reason is that the separator is not a plain space. CLDR uses a no-break space so the number cannot be split across a line, and for French the group separator is U+202F NARROW NO-BREAK SPACE specifically. Any string comparison, regular expression or trimming step written against U+0020 will not match it, and any test written by typing a space into an editor will fail against correct output.&lt;/p&gt;

&lt;p&gt;The Swiss row is a third distinct pattern that undermines any two-camp model of the world: a German-speaking locale with a decimal &lt;em&gt;point&lt;/em&gt; and an apostrophe for grouping. And the Indian row points at the separate problem that grouping is not always in threes, which is &lt;a href="https://multigrid.ai/learn/indian-lakh-crore-number-format" rel="noopener noreferrer"&gt;the lakh-crore system&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the standards bodies say
&lt;/h2&gt;

&lt;p&gt;There is a formal recommendation, and it is worth knowing because it appears in scientific and technical documents and looks like an error to people who have not met it. The International Bureau of Weights and Measures, in the &lt;a href="https://www.bipm.org/en/publications/si-brochure" rel="noopener noreferrer"&gt;SI Brochure&lt;/a&gt;, states that either a comma or a point may be used as the decimal marker, and that digits should be separated into groups of three by a thin space and never by a comma or a point — precisely so that the grouping character cannot be confused with the decimal marker in either convention.&lt;/p&gt;

&lt;p&gt;That is why a physics paper writes &lt;code&gt;1 234.56&lt;/code&gt; where a newspaper would write &lt;code&gt;1,234.56&lt;/code&gt;. If you are generating technical or scientific copy, the space-grouped form is the correct one and a reviewer who removes the spaces is introducing an error. If you are generating anything else, follow CLDR’s locale data instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing is where it becomes dangerous
&lt;/h2&gt;

&lt;p&gt;Formatting wrong is embarrassing. Parsing wrong loses money. Consider a model extracting an amount from a European invoice and handing the string to code that calls a naive numeric parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.234,56&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 1.234   — stops at the comma&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.234,56&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// NaN&lt;/span&gt;
&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.234&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// 1.234   — was one thousand two hundred and thirty-four&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third line is the one that gets into production. &lt;code&gt;1.234&lt;/code&gt; is a perfectly valid float, so nothing throws, nothing warns, and an amount of 1,234 euro is recorded as 1.234 euro — off by a factor of a thousand, in a field that no validation will question because it is a plausible number. The same string in the other direction turns 1.234 into 1234.&lt;/p&gt;

&lt;p&gt;The defence is to never let a locale-formatted numeric string reach a parser. Have the extraction produce a normalised value with an explicit, single convention — a JSON number, or a string in &lt;code&gt;1234.56&lt;/code&gt; form with no grouping at all — and have the model report the source formatting separately if you need it. Where you genuinely must parse a localised string, use a locale-aware parser and pass the locale explicitly; do not infer it from the string, because &lt;code&gt;1.234&lt;/code&gt; is ambiguous and always will be.&lt;/p&gt;

&lt;p&gt;The interchange format where this bites hardest is CSV, and the reason is worth spelling out because it produces a file that opens correctly on one desk and not on another. A locale that uses a decimal comma cannot use a comma to separate CSV fields, so the convention in those locales is a semicolon — and spreadsheet software follows the operating system list separator when it decides how to split a &lt;code&gt;.csv&lt;/code&gt; file. The same file therefore parses into columns on a US machine and into a single column on a German one, or worse, splits a European number across two columns. If you are generating tabular exports, either emit a real spreadsheet format, or emit unambiguous machine-format numbers with a comma delimiter and quote every field, and state the convention in a header row rather than leaving the receiving application to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Instruct the model to emit numbers with a decimal point and no grouping separator at all: &lt;code&gt;1234.56&lt;/code&gt;. This is a machine format, not a display format, and it should never reach a reader.&lt;/li&gt;
&lt;li&gt; Validate that instruction with a pattern — &lt;code&gt;^-?\d+(\.\d+)?$&lt;/code&gt; — rather than trusting it. A number with a comma in it is a rejected response, not a value to clean up.&lt;/li&gt;
&lt;li&gt; Format at the point of display with an ICU-backed formatter and an explicit locale, so the grouping and decimal characters come from CLDR data rather than from a rule anyone remembered.&lt;/li&gt;
&lt;li&gt; Test with a value above 999 and below 10,000. Anything smaller has no grouping separator and will pass under any convention, which is how this bug reaches production.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "1.234,56"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fr-FR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "1 234,56"  (U+202F)&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-CH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "1'234.56"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the currency symbol is involved as well, its position and spacing are a separate piece of locale data and are covered in &lt;a href="https://multigrid.ai/learn/currency-symbol-placement-locale" rel="noopener noreferrer"&gt;where the currency symbol goes&lt;/a&gt;. They resolve from the same formatter call, which is the argument for making that call once rather than assembling a monetary string by concatenation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/currency-symbol-placement-locale" rel="noopener noreferrer"&gt;Where the Currency Symbol Goes in Different Locales&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/indian-lakh-crore-number-format" rel="noopener noreferrer"&gt;Formatting Numbers in the Indian Lakh-Crore System&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>debugging</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Riverpod to BlocSignal: Incremental Migration and Zero-Codegen Signals for Flutter</title>
      <dc:creator>Randal L. Schwartz</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:05:09 +0000</pubDate>
      <link>https://dev.to/gde/riverpod-to-blocsignal-incremental-migration-and-zero-codegen-signals-for-flutter-1gdp</link>
      <guid>https://dev.to/gde/riverpod-to-blocsignal-incremental-migration-and-zero-codegen-signals-for-flutter-1gdp</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: Combining Riverpod Safety with Zero Codegen
&lt;/h2&gt;

&lt;p&gt;If you've been building Flutter apps with &lt;strong&gt;Riverpod&lt;/strong&gt;, you already know the joy of compile-time provider safety, auto-disposal, and synchronous notification propagation via &lt;code&gt;ProviderListenable&lt;/code&gt;. Riverpod fundamentally raised the bar for Flutter state management.&lt;/p&gt;

&lt;p&gt;However, as projects grow, many Riverpod developers run into familiar friction points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code Generation Overhead&lt;/strong&gt;: Depending heavily on &lt;code&gt;riverpod_generator&lt;/code&gt; and waiting on &lt;code&gt;build_runner&lt;/code&gt; watches during rapid UI iteration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex Provider Trees&lt;/strong&gt;: Managing nested &lt;code&gt;ProviderScope&lt;/code&gt; overrides and &lt;code&gt;.family&lt;/code&gt; cache eviction policies when scaling large teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All-or-Nothing Migration Concerns&lt;/strong&gt;: Wanting to trial new reactive primitives or event-driven BLoC architectures without rewriting an entire codebase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What if you could combine the compile-time safety and synchronous reactivity you love in Riverpod with &lt;strong&gt;zero code generation&lt;/strong&gt;, &lt;strong&gt;fine-grained signal graph reactivity&lt;/strong&gt;, and the ability to &lt;strong&gt;trial or migrate incrementally screen-by-screen&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;BlocSignal&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;bloc_signals_riverpod&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Ergonomics: Riverpod &lt;code&gt;Notifier&lt;/code&gt; vs. &lt;code&gt;CubitSignal&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Let's compare the classic &lt;strong&gt;Todos&lt;/strong&gt; application—one of the benchmark examples in the official Riverpod monorepo—ported directly to &lt;code&gt;BlocSignal&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Riverpod Approach (Requires &lt;code&gt;@riverpod&lt;/code&gt; &amp;amp; &lt;code&gt;build_runner&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;In modern Riverpod, creating a todo list with reactive filtering typically involves a generated &lt;code&gt;Notifier&lt;/code&gt; and separate provider getters or &lt;code&gt;ref.watch&lt;/code&gt; selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@riverpod&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoList&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;_$TodoList&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;addTodo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;completed:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Derived filter provider&lt;/span&gt;
&lt;span class="nd"&gt;@riverpod&lt;/span&gt;
&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filteredTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ref&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todoListProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todoFilterProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The BlocSignal Approach (100% Pure Dart 3, No Codegen)
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;BlocSignal&lt;/code&gt;, your state container is a standard handwritten Dart class (&lt;code&gt;CubitSignal&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt;&lt;/code&gt;). Reactive derivations like &lt;code&gt;filteredTodos&lt;/code&gt; and &lt;code&gt;uncompletedCount&lt;/code&gt; are declared inline using &lt;strong&gt;signals &lt;code&gt;computed()&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals/bloc_signals.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/foundation.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:signals_core/signals_core.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CubitSignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Pass `equals: listEquals` to enforce value-based list equality for state de-duplication!&lt;/span&gt;
  &lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initialTodos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[]])&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;initialState:&lt;/span&gt; &lt;span class="n"&gt;initialTodos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;equals:&lt;/span&gt; &lt;span class="n"&gt;listEquals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Reactive filter signal&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Synchronously derived computed signals&lt;/span&gt;
    &lt;span class="n"&gt;filteredTodos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;uncompletedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ReadonlySignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filteredTodos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ReadonlySignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;uncompletedCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;addTodo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;([..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stateValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stateValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;completed:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;setFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TodoFilter&lt;/span&gt; &lt;span class="n"&gt;newFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newFilter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;filteredTodos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;uncompletedCount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What Changed?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;build_runner&lt;/code&gt;&lt;/strong&gt;: No &lt;code&gt;.g.dart&lt;/code&gt; generated files, no background watchers, no build step delays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-In Custom Equality (&lt;code&gt;equals: listEquals&lt;/code&gt;)&lt;/strong&gt;: Because Dart &lt;code&gt;List&lt;/code&gt; instances don't override &lt;code&gt;==&lt;/code&gt; by default, passing &lt;code&gt;equals: listEquals&lt;/code&gt; configures the underlying signal graph to de-duplicate state emissions based on list content equality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-Grained Signal Graph&lt;/strong&gt;: Updates to &lt;code&gt;filter.value&lt;/code&gt; or calling &lt;code&gt;emit(...)&lt;/code&gt; re-evaluate &lt;code&gt;computed()&lt;/code&gt; derivations synchronously and notify only dependent widgets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit Container Lifecycles&lt;/strong&gt;: Disposing the cubit disposes its internal signals automatically.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Zero Risk: Incremental Trial &amp;amp; Bidirectional Interop
&lt;/h2&gt;

&lt;p&gt;You do &lt;strong&gt;not&lt;/strong&gt; need to rewrite your application to try &lt;code&gt;BlocSignal&lt;/code&gt;. With the &lt;code&gt;bloc_signals_riverpod&lt;/code&gt; package, you can seamlessly bridge the two frameworks in both directions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bloc_signals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.0.0&lt;/span&gt;
  &lt;span class="na"&gt;bloc_signals_flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.0.0&lt;/span&gt;
  &lt;span class="na"&gt;bloc_signals_riverpod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option A: Expose a &lt;code&gt;BlocSignal&lt;/code&gt; / &lt;code&gt;CubitSignal&lt;/code&gt; to Existing Riverpod Widgets
&lt;/h3&gt;

&lt;p&gt;Want to write a new feature or state controller with &lt;code&gt;BlocSignal&lt;/code&gt;, but keep your existing Riverpod UI layer (&lt;code&gt;ConsumerWidget&lt;/code&gt;, &lt;code&gt;WidgetRef&lt;/code&gt;)? &lt;/p&gt;

&lt;p&gt;Simply call &lt;code&gt;.toProvider()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals_riverpod/bloc_signals_riverpod.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Create your new BlocSignal or CubitSignal&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todosCubit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Convert it directly into a Riverpod NotifierProvider!&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todosProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;todosCubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Now consume it anywhere in existing Riverpod widgets:&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LegacyRiverpodWidget&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ConsumerWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LegacyRiverpodWidget&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WidgetRef&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Rebuilds reactively whenever todosCubit emits!&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todosProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ListView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;itemCount:&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;itemBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: Adapt an Existing Riverpod Provider into &lt;code&gt;BlocSignal&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Have a legacy Riverpod provider that you need to read from a new &lt;code&gt;BlocSignalBuilder&lt;/code&gt; widget?&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;.toBlocSignal(ref)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals_riverpod/bloc_signals_riverpod.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NewFeatureWidget&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ConsumerWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NewFeatureWidget&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WidgetRef&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Adapt any Riverpod ProviderListenable into a BlocSignal container!&lt;/span&gt;
    &lt;span class="c1"&gt;// Automatically binds ref.onDispose to close the container when disposed.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todosBloc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;legacyRiverpodProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toBlocSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BlocSignalBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlocSignalBase&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;bloc:&lt;/span&gt; &lt;span class="n"&gt;todosBloc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ListView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;itemCount:&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;itemBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Converting &lt;code&gt;AsyncValue&lt;/code&gt; &amp;lt;-&amp;gt; &lt;code&gt;AsyncState&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;bloc_signals_riverpod&lt;/code&gt; also provides extension methods to map seamlessly between Riverpod's &lt;code&gt;AsyncValue&lt;/code&gt; and Signals' &lt;code&gt;AsyncState&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Riverpod AsyncValue to Signals AsyncState&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;asyncState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;riverpodAsyncValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toAsyncState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Signals AsyncState to Riverpod AsyncValue&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;asyncValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signalsAsyncState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toAsyncValue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Explore the Official Riverpod Ports on blocsignal.dev
&lt;/h2&gt;

&lt;p&gt;To prove the DX gains and side-by-side equivalence, we've ported canonical state management examples directly from the official &lt;a href="https://github.com/rrousselGit/riverpod/tree/master/examples" rel="noopener noreferrer"&gt;&lt;code&gt;rrousselGit/riverpod&lt;/code&gt;&lt;/a&gt; monorepo into the &lt;code&gt;BlocSignal&lt;/code&gt; open-source repository:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;📝 &lt;strong&gt;Riverpod Todos&lt;/strong&gt; (&lt;a href="https://github.com/RandalSchwartz/BlocSignal/tree/main/examples/riverpod_todos" rel="noopener noreferrer"&gt;&lt;code&gt;examples/riverpod_todos&lt;/code&gt;&lt;/a&gt;):

&lt;ul&gt;
&lt;li&gt;Replaces &lt;code&gt;@riverpod&lt;/code&gt; codegen and &lt;code&gt;Notifier&lt;/code&gt; with &lt;code&gt;CubitSignal&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt;&lt;/code&gt; and synchronous &lt;code&gt;computed()&lt;/code&gt; signals for reactive filter tabs and stats.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;🔍 &lt;strong&gt;Pub.dev Package Search&lt;/strong&gt; (&lt;a href="https://github.com/RandalSchwartz/BlocSignal/tree/main/examples/riverpod_pub" rel="noopener noreferrer"&gt;&lt;code&gt;examples/riverpod_pub&lt;/code&gt;&lt;/a&gt;):

&lt;ul&gt;
&lt;li&gt;Replaces Riverpod &lt;code&gt;AsyncNotifier&lt;/code&gt; with &lt;code&gt;BlocSignal&lt;/code&gt; and a streamless &lt;code&gt;restartable()&lt;/code&gt; event transformer that automatically cancels in-flight API requests on keypresses without Rx streams.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;🦸 &lt;strong&gt;Marvel Character Browser&lt;/strong&gt; (&lt;a href="https://github.com/RandalSchwartz/BlocSignal/tree/main/examples/riverpod_marvel" rel="noopener noreferrer"&gt;&lt;code&gt;examples/riverpod_marvel&lt;/code&gt;&lt;/a&gt;):

&lt;ul&gt;
&lt;li&gt;Demonstrates API pagination, character search, and widget tree scoping via &lt;code&gt;BlocSignalProvider.value&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Explore all &lt;strong&gt;20 side-by-side benchmark ports&lt;/strong&gt; across BLoC, Signals, and Riverpod live at &lt;strong&gt;&lt;a href="https://blocsignal.dev/#ported-examples" rel="noopener noreferrer"&gt;blocsignal.dev/#ported-examples&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h2&gt;
  
  
  Built-In AI Agent Skills for Automated Migration
&lt;/h2&gt;

&lt;p&gt;If you use AI coding assistants like &lt;strong&gt;Antigravity&lt;/strong&gt;, &lt;strong&gt;Gemini&lt;/strong&gt;, &lt;strong&gt;Cursor&lt;/strong&gt;, or &lt;strong&gt;GitHub Copilot&lt;/strong&gt;, &lt;code&gt;BlocSignal&lt;/code&gt; publishes a dedicated Agent Plugin skill bundle (&lt;code&gt;riverpod_migration.md&lt;/code&gt;). &lt;/p&gt;

&lt;p&gt;When your AI assistant inspects a project with &lt;code&gt;BlocSignal&lt;/code&gt; skills enabled, it automatically understands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to map &lt;code&gt;StateNotifierProvider&lt;/code&gt; / &lt;code&gt;NotifierProvider&lt;/code&gt; to &lt;code&gt;CubitSignal&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;How to preserve auto-disposal and cancellation contracts;&lt;/li&gt;
&lt;li&gt;How to refactor &lt;code&gt;ConsumerWidget&lt;/code&gt; rebuild boundaries to &lt;code&gt;BlocSignalBuilder&lt;/code&gt; or &lt;code&gt;SignalBuilder&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;How to apply &lt;code&gt;bloc_signals_riverpod&lt;/code&gt; interop adapters during multi-phase refactoring.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;You don't need to throw away your existing architecture to enjoy the speed, simplicity, and zero-codegen elegance of reactive signals. &lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;&lt;code&gt;bloc_signals_riverpod&lt;/code&gt;&lt;/strong&gt;, you can trial &lt;code&gt;BlocSignal&lt;/code&gt; on a single screen today, bridge your existing Riverpod providers seamlessly, and upgrade your developer experience at your own pace.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Website &amp;amp; Comparison Benchmarks&lt;/strong&gt;: &lt;a href="https://blocsignal.dev" rel="noopener noreferrer"&gt;blocsignal.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Pub.dev Packages&lt;/strong&gt;: &lt;a href="https://pub.dev/packages/bloc_signals" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals&lt;/code&gt;&lt;/a&gt; | &lt;a href="https://pub.dev/packages/bloc_signals_flutter" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals_flutter&lt;/code&gt;&lt;/a&gt; | &lt;a href="https://pub.dev/packages/bloc_signals_riverpod" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals_riverpod&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐙 &lt;strong&gt;GitHub Repository&lt;/strong&gt;: &lt;a href="https://github.com/RandalSchwartz/BlocSignal" rel="noopener noreferrer"&gt;RandalSchwartz/BlocSignal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>riverpod</category>
      <category>statemanagement</category>
    </item>
    <item>
      <title>What to Expect From Pure CPU-Only Local Inference</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 22:56:25 +0000</pubDate>
      <link>https://dev.to/multigrid/what-to-expect-from-pure-cpu-only-local-inference-487o</link>
      <guid>https://dev.to/multigrid/what-to-expect-from-pure-cpu-only-local-inference-487o</guid>
      <description>&lt;p&gt;Nobody publishes tokens per second for your model on your RAM, and any page that gives you one has either measured a machine that is not yours or made it up. What can be established is a ceiling, from two numbers you can look up in five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two phases with different bottlenecks
&lt;/h2&gt;

&lt;p&gt;A request has a prefill phase, where the whole prompt goes through the model in one parallel pass, and a decode phase, where each output token requires its own pass. They are limited by different things, and conflating them is why CPU inference advice is so often useless.&lt;/p&gt;

&lt;p&gt;Decode is the one people mean when they say a local model is slow. To produce one token, the CPU must read every active weight in the model out of RAM and into cache, do a small amount of arithmetic with each, and move on. At a batch size of one there is nothing to amortise that read against — one pass over all the weights buys exactly one token. So decode is bound by memory bandwidth, and the arithmetic units are mostly idle waiting for data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bandwidth ceiling, derived
&lt;/h2&gt;

&lt;p&gt;If each token requires reading the weights once, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokens/second  &amp;lt;=  usable memory bandwidth (bytes/s)  /  weight bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numerator is derivable from the DDR standard your machine uses. A DDR4 or DDR5 channel is 64 bits wide, so it moves 8 bytes per transfer, and the number in the name is the transfer rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DDR4-3200, one channel:  3200e6 transfers/s x 8 B  = 25.6 GB/s
DDR4-3200, dual channel:                        = 51.2 GB/s
DDR5-5600, one DIMM:     5600e6 transfers/s x 8 B  = 44.8 GB/s
DDR5-5600, two DIMMs:                              = 89.6 GB/s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denominator is the size of the file on disk, which you can read off the repository listing before downloading. It is a good proxy for the bytes streamed per token in a dense model, because a GGUF file is almost entirely tensor data.&lt;/p&gt;

&lt;p&gt;Apple publishes unified memory bandwidth directly rather than leaving you to derive it: in &lt;a href="https://www.apple.com/newsroom/2024/10/apple-introduces-m4-pro-and-m4-max/" rel="noopener noreferrer"&gt;Apple’s October 2024 announcement of the M4 Pro and M4 Max&lt;/a&gt; the figures given are 273 GB/s for the M4 Pro and 546 GB/s for the M4 Max. That is the single reason Apple Silicon does well at local inference — not the CPU, the bus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling for common configurations
&lt;/h2&gt;

&lt;p&gt;Dividing the bandwidths above by real published file sizes gives the following ceilings. Every file size is from the corresponding Hugging Face GGUF repository listing, checked 2026-08-11; every bandwidth is either derived above or published by Apple. Every cell is &lt;em&gt;bandwidth divided by file size&lt;/em&gt; and nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model (Q4_K_M)             size      DDR4-3200   DDR5-5600   M4 Pro    M4 Max
                                     51.2 GB/s   89.6 GB/s  273 GB/s  546 GB/s
Phi-3.5-mini-instruct      2.39 GB      21.4        37.4      114.1     228.1
Meta-Llama-3.1-8B-Instr.   4.92 GB      10.4        18.2       55.5     111.0
Qwen2.5-Coder-14B-Instr.   8.99 GB       5.7        10.0       30.4      60.7
Meta-Llama-3.1-70B-Instr. 42.52 GB       1.2         2.1        6.4      12.8

  units: tokens per second, upper bound only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the bottom row carefully, because it is the useful one. A 70B at Q4_K_M on a dual-channel DDR5 desktop cannot exceed roughly two tokens per second no matter what CPU is attached, because 42.5 GB has to cross the memory bus for every single token. No amount of cores fixes that. It is also why the 70B row is the one where people conclude local inference does not work, when what does not work is that specific ratio.&lt;/p&gt;

&lt;p&gt;These are ceilings, not predictions, and they are the only kind of number that can honestly be given without running your machine. A real figure requires a measurement on your hardware — see &lt;a href="https://multigrid.ai/learn/measure-local-tokens-per-second" rel="noopener noreferrer"&gt;benchmarking your own tokens per second&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you will not hit the ceiling
&lt;/h2&gt;

&lt;p&gt;Achieved bandwidth is always below peak, and on a general-purpose desktop it is well below. The gap has named causes, all of which you can check on your own machine rather than take on trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Peak is a transfer rate, not a delivery rate.&lt;/strong&gt; Real access patterns pay for row activation, refresh cycles and bank conflicts. A STREAM-style benchmark on your own machine gives the achieved figure, and it is the number to put in the numerator if you want the ceiling to mean anything.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Single-channel configurations halve it.&lt;/strong&gt; A laptop with one DIMM populated, or soldered memory on a narrow bus, gets half the table above. This is the most common reason a machine underperforms its spec sheet, and it is checkable with &lt;code&gt;dmidecode&lt;/code&gt; on Linux or &lt;code&gt;wmic memorychip&lt;/code&gt; on Windows.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Thread count stops helping early.&lt;/strong&gt; Once enough threads are issuing loads to saturate the controller, more threads add contention. This is why llama.cpp’s &lt;code&gt;-t&lt;/code&gt; often peaks below the core count, and why the right value is found by sweeping it rather than by setting it to the number of cores.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Weights are not the only traffic.&lt;/strong&gt; The KV cache is read every token too, and it grows with context. On a long conversation this becomes a meaningful share of the bytes moved — see &lt;a href="https://multigrid.ai/learn/kv-cache" rel="noopener noreferrer"&gt;how the KV cache works&lt;/a&gt; for the mechanism.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prompt processing is a different number
&lt;/h2&gt;

&lt;p&gt;Prefill is not bandwidth-bound, because the whole prompt is available at once and every weight read is reused across all of its tokens. It is bound by arithmetic instead, at roughly two floating-point operations per parameter per token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.03e9 params x 2 FLOP  =  16.06 GFLOP per token of prompt
512-token prompt        =  8.2 TFLOP total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your CPU’s peak throughput is derivable the same way: cores x FMA units x SIMD lanes x 2 x clock. A sixteen-core machine with two AVX-512 FMA units doing sixteen FP32 lanes at 3.5 GHz peaks at about 3.6 TFLOP/s, which puts that 512-token prefill at roughly 2.3 seconds before any inefficiency — several seconds of silence before the first token, on a machine that then generates at a perfectly usable rate.&lt;/p&gt;

&lt;p&gt;The practical consequence is that CPU-only inference degrades on prompt length far more sharply than on output length, which is the opposite of the intuition people bring from hosted APIs. Short prompts and a small model are comfortable. Long documents are not, and no quantization level fixes that, because prefill cost scales with parameters and tokens, not with bits per weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/local-llm-feels-slower-than-cloud" rel="noopener noreferrer"&gt;Why Your Local Model Feels Slower Than ChatGPT on Good Hardware&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/measure-local-tokens-per-second" rel="noopener noreferrer"&gt;Benchmarking Your Own Local Model's Tokens per Second&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/local-model-disk-space-budget" rel="noopener noreferrer"&gt;Budgeting Disk Space for Keeping Several Local Models Around&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Writing a Contract Test Suite for Your Own LLM Gateway</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Wed, 12 Aug 2026 22:50:22 +0000</pubDate>
      <link>https://dev.to/multigrid/writing-a-contract-test-suite-for-your-own-llm-gateway-2kkm</link>
      <guid>https://dev.to/multigrid/writing-a-contract-test-suite-for-your-own-llm-gateway-2kkm</guid>
      <description>&lt;p&gt;If you run a proxy in front of one or more model providers, the interesting failures are not in the model. They are in your layer: a header you forwarded that you should have stripped, an upstream 429 you turned into a 500, a stream you accidentally buffered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer under test
&lt;/h2&gt;

&lt;p&gt;A gateway sits between your applications and one or more providers, and it promises two things at once. Downstream, it promises to speak the OpenAI-compatible contract so that ordinary SDKs work against it. Upstream, it promises to translate faithfully into whatever each provider actually wants. Almost every bug that costs you an incident lives in one of those two promises rather than in the inference.&lt;/p&gt;

&lt;p&gt;That means the suite you want is not the one from &lt;a href="https://multigrid.ai/learn/contract-test-openai-compatible-api" rel="noopener noreferrer"&gt;evaluating a candidate provider&lt;/a&gt;. That suite asks whether a remote service is shaped correctly. This one asks whether &lt;em&gt;your&lt;/em&gt; code preserves shape, and it should run without touching a provider at all: fast, deterministic, and on every pull request. The distinction matters because a suite that needs a real model is a suite that runs nightly at best, and a proxy bug found nightly has already shipped.&lt;/p&gt;

&lt;p&gt;List the promises explicitly before writing a line. A typical set: the response envelope is preserved; the model alias resolves to the documented concrete model; the upstream key never appears downstream and the downstream key never appears upstream; usage is reported and equals what the upstream reported; an upstream error becomes a documented downstream error rather than a stack trace; and a streamed request produces a stream, not a buffered response delivered at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stub the upstream, not the gateway
&lt;/h2&gt;

&lt;p&gt;The direction of the stub is the design decision. If you mock your own gateway you are testing nothing. What you want is a fake provider: a local HTTP server that returns whatever fixture the test needs, including malformed and hostile fixtures a real provider will not produce on demand. Mock Service Worker’s Node interceptor is the usual choice in TypeScript, and it works at the request level so your gateway’s real HTTP client, real timeouts and real retry logic all execute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gateway/contract/upstream.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;setupServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msw/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HttpResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setupServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIXTURE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chatcmpl-fixture-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;completionFixture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;over&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FIXTURE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat.completion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1754870400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;provider-model-v2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;finish_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;completion_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;over&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;respondsWith&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://upstream.test/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties make this worth the setup. The fixture is a value, so a test can mutate one field and assert that your gateway notices. And the interceptor records the outbound request, so you can assert on what your gateway &lt;em&gt;sent&lt;/em&gt;, which is half the contract and the half that is otherwise invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass-through fidelity
&lt;/h2&gt;

&lt;p&gt;The single most valuable assertion in a gateway suite is that unknown fields survive. A gateway that parses an upstream response into a typed struct and re-serialises it will silently drop every field its struct does not know about — and providers add fields constantly. Your users lose &lt;code&gt;logprobs&lt;/code&gt;, or a reasoning block, or a cache-hit count, and nobody notices until one of them opens a ticket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preserves fields the gateway does not model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;respondsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;completionFixture&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;system_fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fp_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;provider_specific_metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gatewayURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer tenant-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alias-fast&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;system_fingerprint&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fp_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider_specific_metadata&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The companion assertion runs in the other direction: capture the request your gateway made upstream and check that the client’s parameters arrived intact. A gateway that rebuilds the request body from a known-parameter list drops the one parameter a client added last week, and the client sees a model that ignores their &lt;code&gt;response_format&lt;/code&gt; with no error anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error mapping and header hygiene
&lt;/h2&gt;

&lt;p&gt;Error mapping is a contract in both directions and it is the part teams write once and never test. Assert each upstream status your gateway can receive against the downstream status it should produce: a 429 must stay a 429 or your clients’ backoff never engages; a &lt;code&gt;Retry-After&lt;/code&gt; header must survive, since it carries the only number worth obeying; a 400 from the provider about an invalid parameter must not become a 502, because a 502 tells the client to retry a request that will fail identically forever.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Upstream 429 with &lt;code&gt;Retry-After: 3&lt;/code&gt;&lt;/strong&gt; → downstream 429 with the same header. Assert the header, not only the status.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Upstream 401&lt;/strong&gt; → downstream 502 or 500, never 401. A 401 tells your tenant &lt;em&gt;their&lt;/em&gt; key is bad, and it is not — yours is.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Upstream timeout&lt;/strong&gt; → downstream 504 within your own documented deadline. Assert the elapsed time is bounded, or the test passes while the behaviour is unbounded.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Upstream returns HTML&lt;/strong&gt; — a CDN error page rather than JSON — → a structured error, not a JSON parse exception. This is a real production shape and worth a fixture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Header hygiene deserves its own explicit test because the failure is a security failure rather than an availability one. Assert that the downstream &lt;code&gt;Authorization&lt;/code&gt; value never appears in the captured upstream request, and that no upstream provider header that identifies your account is echoed downstream. Both are one-line assertions on captured objects and neither is caught by any other kind of test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming is a separate contract
&lt;/h2&gt;

&lt;p&gt;A gateway can pass every buffered test and still be broken for streaming, because the two are usually different code paths and the streaming one has a failure mode the buffered one cannot have: it can be correct in content and wrong in time. A proxy that reads the whole upstream stream, assembles it, and writes it out as SSE at the end produces byte-identical output and destroys the only reason anyone streams.&lt;/p&gt;

&lt;p&gt;So assert on timing, not only on bytes. With a stub upstream you control the delay between chunks, which makes this deterministic rather than flaky: emit chunk one, wait, emit chunk two, and assert that your gateway delivered the first chunk before the second was sent. Record the wall-clock offset of the first downstream chunk and require it to be below the upstream’s inter-chunk delay. The detail of the wire format — the &lt;code&gt;data:&lt;/code&gt; prefix, the blank line separator, the terminating sentinel — is covered in &lt;a href="https://multigrid.ai/learn/contract-test-streaming-chunk-format" rel="noopener noreferrer"&gt;contract tests for streaming chunk format&lt;/a&gt;, and a gateway suite should reuse the same parser its clients use rather than writing a second one.&lt;/p&gt;

&lt;p&gt;Most of the work in operating a gateway is exactly this suite: the error mapping, the header hygiene, the pass-through fidelity, and the streaming path, maintained across every provider you add. If that layer is not itself the product you are building, Multigrid is that layer as a service — one API and one key, with the provider differences absorbed behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/contract-test-openai-compatible-api" rel="noopener noreferrer"&gt;Contract Testing an OpenAI-Compatible API Before You Switch Providers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/contract-test-streaming-chunk-format" rel="noopener noreferrer"&gt;Contract Tests for Streaming Chunk Format&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/contract-suite-multi-provider" rel="noopener noreferrer"&gt;Running the Same Contract Suite Against Every Provider You Support&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>tutorial</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I Un-Killed a 1.2k-Star Reddit Downloader That Reddit's API Changes Broke</title>
      <dc:creator>hideki</dc:creator>
      <pubDate>Wed, 12 Aug 2026 22:48:59 +0000</pubDate>
      <link>https://dev.to/hidekiryu/i-un-killed-a-12k-star-reddit-downloader-that-reddits-api-changes-broke-56i1</link>
      <guid>https://dev.to/hidekiryu/i-un-killed-a-12k-star-reddit-downloader-that-reddits-api-changes-broke-56i1</guid>
      <description>&lt;p&gt;&lt;strong&gt;tl;dr&lt;/strong&gt; — RedditDownloader (RMD) was a solid tool for archiving Reddit media. In July 2023, Reddit killed Pushshift and locked down direct API access. The maintainer archived the project and explicitly invited forks. I replaced the dead data layer with the Sylvia API (sylvia-api.com) and got subreddit scanning working again. The fork is at &lt;a href="https://github.com/c1nn3r/RedditDownloader" rel="noopener noreferrer"&gt;github.com/c1nn3r/RedditDownloader&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;RMD was well built. The repo has about 1,200 stars. It shipped a real filter engine, a local web UI, image-hash duplicate detection, a sqlite state database, and resume support so a large archive run can stop and start without re-downloading.&lt;/p&gt;

&lt;p&gt;It scanned many places for media: subreddits, user posts, saved lists, upvoted lists, and multireddits. It extracted links from post URLs, from self-post text, and from comments. It handled most of the hosts you meet on Reddit: &lt;code&gt;i.redd.it&lt;/code&gt;, &lt;code&gt;v.redd.it&lt;/code&gt;, &lt;code&gt;preview.redd.it&lt;/code&gt;, imgur, gfycat, and tumblr, with &lt;code&gt;yt-dlp&lt;/code&gt; in the corner for video. It ran headless or as a server with a web UI. For the data-hoarding crowd — the people who believe the internet is ephemeral and the stuff they like will vanish — it was the tool.&lt;/p&gt;

&lt;p&gt;Then Reddit changed the rules. In July 2023, Reddit destroyed Pushshift, which RMD used for historical data. Reddit also restricted direct API access and made OAuth app registration slower and stricter. The maintainer, ShadowMoose, wrote a blunt shutdown note. He said that the changes made it impossible for RMD to operate. He said that he no longer wanted to build on Reddit's platform at all. Then he archived the repo. But he left the door open:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Other users are welcome to fork the project and continue onward, if anybody is willing to continue in spite of the Admin actions pushing against them."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I forked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually broken
&lt;/h2&gt;

&lt;p&gt;RMD splits cleanly into two halves: sources and processing. A source decides where posts come from. Processing decides what happens to a post once you have it — filtering, dedup, download, file naming.&lt;/p&gt;

&lt;p&gt;The processing half was healthy. The filters, the downloaders, the image-hash dedup, the sqlite state — none of that depended on the API changes.&lt;/p&gt;

&lt;p&gt;The source half was dead. Subreddit and user scanning depended on two things that no longer worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pushshift&lt;/strong&gt; — offline. The API is gone and the Python wrappers around it (&lt;code&gt;psaw&lt;/code&gt;, &lt;code&gt;pmaw&lt;/code&gt;) are orphaned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct PRAW / JSON access&lt;/strong&gt; — still alive, but the cost increased. You need your own OAuth app registration, which Reddit approves slowly and strictly. Rate limits come back as HTTP 429 responses, which make bulk scanning painful. And if Reddit has flagged your IP, the HTML and JSON endpoints just serve you a block page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything downstream of the data layer was fine. The rot was entirely in how RMD got post data in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I did not touch the processing pipeline. The source/processing split is the reason the fix is small: I wrote one new source.&lt;/p&gt;

&lt;p&gt;The new source is &lt;code&gt;SylviaSubredditSource&lt;/code&gt;. It replaces the dead Pushshift source for subreddit listings. It pulls posts from the Sylvia API, a Reddit data gateway that skips OAuth app registration entirely.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sylvia API (subreddit posts)
    → SylviaSubredditSource
    → RedditElement  (the same format RMD always used)
    → direct_link handler → filters → downloaders → disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RMD calls the source with a subreddit name, a sort order (top, new, hot, controversial), and a time window.&lt;/li&gt;
&lt;li&gt;The source calls &lt;code&gt;GET /v1/reddit/r/{subreddit}/{order}&lt;/code&gt; on the Sylvia API, with the time filter and a limit.&lt;/li&gt;
&lt;li&gt;Sylvia returns Reddit-shaped JSON — the same field names Pushshift used to return: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;selftext&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;score&lt;/code&gt;, &lt;code&gt;num_comments&lt;/code&gt;, &lt;code&gt;over_18&lt;/code&gt;, &lt;code&gt;created_utc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The source wraps each post in a dict subclass named &lt;code&gt;Submission&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RedditElement.detect_type()&lt;/code&gt; sees the class name, routes the object through the existing Pushshift parsing path, and produces the exact &lt;code&gt;RedditElement&lt;/code&gt; object RMD always used.&lt;/li&gt;
&lt;li&gt;Everything downstream runs unchanged: the &lt;code&gt;direct_link&lt;/code&gt; handler, the filters, the downloader pool, the file naming, the dedup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pagination uses the same &lt;code&gt;after&lt;/code&gt;-cursor pattern Reddit itself uses. The source loops until it hits the requested limit or the API stops returning posts. That is the same 1,000-post-per-listing window Reddit serves to everyone.&lt;/p&gt;

&lt;p&gt;Setup is one environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SYLVIA_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;syl_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add a "Sylvia Subreddit" source in the WebUI or in the settings file, exactly like any other source in RMD. That is the whole migration. You do not need to register a Reddit app, and you do not need to fill in the OAuth fields that have been sitting empty since 2023.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I did not want to claim that it works without proof. The fork ships two tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test_sylvia.py&lt;/code&gt; — pulls real posts from r/aww through the Sylvia API, builds &lt;code&gt;RedditElement&lt;/code&gt; objects, checks that media URLs are extracted, and checks that the URL filter still works.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test_download.py&lt;/code&gt; — end to end. It runs RMD's real &lt;code&gt;direct_link&lt;/code&gt; handler against URLs the Sylvia source produced, and confirms that files land on disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The download test tried 5 posts and wrote 4 files. The 5th was a self-post with no direct media link, which the handler correctly rejected. Files ranged from 160 KB to 1.3 MB. Every byte of the download path used RMD's real handler code — no stubs, no test doubles.&lt;/p&gt;

&lt;p&gt;I also checked the one thing I was worried about: the media CDN. Reddit blocks flagged IPs on its HTML and JSON endpoints, and this machine is flagged. The good news is that the media CDN (&lt;code&gt;i.redd.it&lt;/code&gt;, &lt;code&gt;preview.redd.it&lt;/code&gt;) does not use the same block list. Downloads worked from this machine. The data layer never touches the blocked endpoints at all, because Sylvia is the one making those requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still does not work
&lt;/h2&gt;

&lt;p&gt;I want to be clear about the limits, because some RMD features are not fixed and cannot be fixed by swapping a data source.&lt;/p&gt;

&lt;p&gt;The OAuth-only sources are still broken: saved posts, upvoted lists, multireddits, and "your own user's posts." These need an authenticated Reddit session. That is a property of your Reddit account, not a property of data access. No data gateway can stand in for it. Those sources still need PRAW auth exactly as before.&lt;/p&gt;

&lt;p&gt;There is also no deep history. Reddit's JSON API serves only a window of about 1,000 posts per listing, and Sylvia proxies that same window. The Pushshift-era ability to pull "every post ever made in this subreddit" is gone for everyone, not just for this fork.&lt;/p&gt;

&lt;p&gt;Media binaries still download from Reddit's CDN. That worked in my tests. If Reddit later blocks download IPs the way it blocks the HTML and JSON endpoints, you will need a proxy for the media fetch — but the data layer will keep working.&lt;/p&gt;

&lt;p&gt;If you used RMD for subreddit archiving, this fork gets you back to working. If you relied on saved or upvoted-list syncing, this does not help you yet. That is a different problem — authentication, not data access — and I have not tackled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fork instead of pull request
&lt;/h2&gt;

&lt;p&gt;The original repo is archived. The maintainer said, plainly, that he is done with anything Reddit-related. A pull request would sit in the queue forever. He explicitly invited forks in the shutdown note, so that is the route I took.&lt;/p&gt;

&lt;p&gt;Credit goes where credit is due. The architecture, the handlers, the filters, the dedup, the web UI — that is ShadowMoose and the contributors' work, and it is good work. I replaced one dead pipe. That is the whole diff, and it stayed that small because RMD was built well enough to separate "where the data comes from" from "what the data does."&lt;/p&gt;

&lt;h2&gt;
  
  
  Made possible by Sylvia API
&lt;/h2&gt;

&lt;p&gt;&lt;a href="//sylvia-api.com"&gt;Sylvia API&lt;/a&gt; is what made this fork possible. When Reddit killed Pushshift and locked down PRAW access, RedditDownloader's subreddit-scraping path died with them. Sylvia is a Reddit data gateway that serves Reddit JSON with no OAuth app registration, no rate-limit roulette, no 429s, no IP blocks — just a key and clean JSON. One source swap restored RMD's subreddit scan, filter engine, and media download pipeline for archiving, data hoarding, and image/video backups.&lt;/p&gt;

&lt;p&gt;Credit where credit is due: the architecture is ShadowMoose's. Sylvia is the pipe that made it run again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fork:&lt;/strong&gt; &lt;a href="https://github.com/c1nn3r/RedditDownloader" rel="noopener noreferrer"&gt;github.com/c1nn3r/RedditDownloader&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reddit</category>
      <category>opensource</category>
      <category>api</category>
      <category>python</category>
    </item>
  </channel>
</rss>
