<?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: Jeremiah Say</title>
    <description>The latest articles on DEV Community by Jeremiah Say (@jeremiah_say).</description>
    <link>https://dev.to/jeremiah_say</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3923050%2F096c2652-d95e-403a-9556-2522b68c09dd.jpg</url>
      <title>DEV Community: Jeremiah Say</title>
      <link>https://dev.to/jeremiah_say</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeremiah_say"/>
    <language>en</language>
    <item>
      <title>Your AI agent will credit famous quotes to the wrong person. Here's a LangChain tool that checks them</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:52:53 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/your-ai-agent-will-credit-famous-quotes-to-the-wrong-person-heres-a-langchain-tool-that-checks-4m0p</link>
      <guid>https://dev.to/jeremiah_say/your-ai-agent-will-credit-famous-quotes-to-the-wrong-person-heres-a-langchain-tool-that-checks-4m0p</guid>
      <description>&lt;p&gt;Paste this into almost any article about leadership and nobody blinks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Be the change you wish to see in the world." — Mahatma Gandhi&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no reliable source that Gandhi said it. The line isn't in his &lt;em&gt;Collected Works&lt;/em&gt;; it's a modern condensation of his ideas. "Insanity is doing the same thing over and over again and expecting different results" has no Einstein source either; the earliest known printing is a 1981 Narcotics Anonymous text.&lt;/p&gt;

&lt;p&gt;A language model answering from memory has a specific problem here. It has seen the popular credit far more often than the correction. I tested this on 22 October 2026: I gave Claude Sonnet (through the Claude Code CLI, with no tools) five well-known lines and asked "Who said each of these? Answer with the name only." It answered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Ralph Waldo Emerson   ("Do not follow where the path may lead…")
2. Mahatma Gandhi        ("Be the change you wish to see in the world.")
3. Albert Einstein       ("Insanity is doing the same thing over and over…")
4. Seneca                ("Every new beginning comes from some other beginning's end.")
5. Peter Drucker         ("The best way to predict the future is to create it.")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the popular credit for all five, and none of them holds up. To its credit, the model then added a note saying several were "commonly misattributed". But the note &lt;strong&gt;didn't name the real author&lt;/strong&gt; of #1 (Muriel Strode) or #4 (Dan Wilson, who wrote it for Semisonic's "Closing Time"), and it called #4 a "Seneca-like paraphrase", which is wrong. The model could tell that something was off. In this run it couldn't say what was right, or show a source.&lt;/p&gt;

&lt;p&gt;Now picture that inside an agent that writes captions or summarises articles, where only the name ends up in the output. That's where you want a lookup, not a memory.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://graciousquotes.com" rel="noopener noreferrer"&gt;GraciousQuotes&lt;/a&gt;, where every quote has a fact-checked record: a verdict, the reason for it, the source it was traced to, and a public correction log when we get one wrong. So I wrapped those records as LangChain tools, so the agent can look the quote up instead of remembering it. The package was &lt;a href="https://docs.langchain.com/oss/python/integrations/tools" rel="noopener noreferrer"&gt;listed in LangChain's integrations directory&lt;/a&gt; on 20 October 2026.&lt;/p&gt;

&lt;p&gt;This post covers what the tools return, how to use them in an agent and in CI, and three design decisions that took longer than the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install (no key, no sign-up)
&lt;/h2&gt;



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

&lt;/div&gt;





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

&lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QuoteAttributionCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quote&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;Be the change you wish to see in the world&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output, from the live API on 22 October 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match: exact wording
Verdict: Uncertain. It is often credited to Mahatma Gandhi, but there is no reliable source that Mahatma Gandhi said it.
Status: Disputed
Quote as checked: "Be the change you wish to see in the world."
Credit it as: Often attributed to Mahatma Gandhi
Why: Not in Gandhi's Collected Works; a modern condensation of his ideas.
Traced to: https://en.wikiquote.org/wiki/Mahatma_Gandhi#Misattributed
Record: https://graciousquotes.com/quotes-with-sources/mahatma-gandhi/#q3684
Source: Gracious Quotes (graciousquotes.com). Link the record above when you repeat this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer comes from a reviewed record, so if a verdict changes, the output changes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Name the model sees&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QuoteAttributionCheck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check_quote_attribution&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verdict for one quote: said by the credited person, uncertain, or credited to the wrong person. Includes the reason, the source and the record link.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QuoteSearch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;search_checked_quotes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checked quotes by wording, author or verdict (verified, plausible, disputed, misattributed).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QuoteCitation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cite_quote&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MLA 9, APA 7 or Chicago 17 citation for a checked quote, with a warning when it's usually credited to someone else.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In an agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_graciousquotes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QuoteAttributionCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QuoteCitation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QuoteSearch&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-5-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# any tool-calling model
&lt;/span&gt;    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;QuoteAttributionCheck&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;QuoteSearch&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;QuoteCitation&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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;content&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;Did Einstein say the definition of insanity is doing the same thing over and over?&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;This needs LangChain 1.0+ and Python 3.10+. The tools work on their own from Python 3.9. Every tool also supports &lt;code&gt;ainvoke&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check every quote in a draft, with no model at all
&lt;/h2&gt;

&lt;p&gt;You don't need an agent to use these. The more useful thing for me turned out to be a plain script: &lt;a href="https://github.com/graciousquotes/langchain-graciousquotes/blob/main/examples/check_draft.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/check_draft.py&lt;/code&gt;&lt;/a&gt; finds the quotes in a Markdown file or a live URL, reads the name the article gives each one ("Emerson told us", "— Seneca"), and checks both.&lt;/p&gt;

&lt;p&gt;Here's the run on the repo's sample draft, 22 October 2026 (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 to fix · 1 unverified · 2 OK

FIX        "Do not follow where the path may lead. Go instead where there is no path and leave a trail."
           Your draft credits: Ralph Waldo Emerson
           Record: Re-credited. No: Ralph Waldo Emerson did not say it. It comes from Muriel Strode.
           Fix: Credit Muriel Strode instead of Ralph Waldo Emerson.

FIX        "The only thing we have to fear is fear itself."
           Your draft credits: Mark Twain
           Record: Verified. Yes: President Franklin D. Roosevelt said it.
           Fix: Credit President Franklin D. Roosevelt instead of Mark Twain. Source: First Inaugural Address (1933).

FIX        "The best way to predict the future is to create it."
           Your draft credits: Peter Drucker
           Fix: No reliable source shows Peter Drucker said it. Write "often attributed to Peter Drucker", or cut it.

UNVERIFIED "The quiet hours before dawn belong to people who keep their own promises."
           Fix: No checked record. Keep the quote only if you can cite where it first appeared.

OK         "insanity is doing the same thing over and over again and expecting different results."
           Your draft credits: Albert Einstein (hedged)
           Note: Fine as written: you already present it as an attribution, which matches the record.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Emerson line is a good example of why this is worth automating: it is widely credited to Emerson, but it comes from Muriel Strode's poem "Wind-Wafted Wild Flowers", printed in &lt;em&gt;The Open Court&lt;/em&gt; in August 1903.&lt;/p&gt;

&lt;p&gt;The script exits with code 1 when something needs fixing (&lt;code&gt;--strict&lt;/code&gt; also fails on CHECK and UNVERIFIED), so it can block a publish step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python examples/check_draft.py content/posts/new-post.md &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last OK. The draft says "often attributed to Einstein", which matches the record, so it passes. A checker that flags every hedged attribution gets switched off within a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three design decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. "No record" is not a verdict
&lt;/h3&gt;

&lt;p&gt;The easy failure is a tool that returns nothing, after which the model fills the gap from memory and sounds just as confident. When there's no checked record, the tool says so explicitly and tells the model not to present a remembered author as confirmed. You can see it in the UNVERIFIED line above. It's the same reason a missing value in a spreadsheet shouldn't be read as zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The tool description is the model's prompt, so be honest about the limits
&lt;/h3&gt;

&lt;p&gt;The author filter in &lt;code&gt;search_checked_quotes&lt;/code&gt; matches the person who &lt;strong&gt;really&lt;/strong&gt; said the quote. That means "show me quotes wrongly credited to Mark Twain" can't be answered by searching for &lt;code&gt;author="Mark Twain"&lt;/code&gt;; those records are filed under the real author. I found this while testing, and the tool description now says it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The author filter matches the person who really said it, so it cannot list quotes wrongly credited to someone; use check_quote_attribution for a specific quote instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you only document a limit in the README, the model never reads it. Put it in the description.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Every result carries its own credit line
&lt;/h3&gt;

&lt;p&gt;Each result ends with &lt;code&gt;Source: Gracious Quotes … Link the record above when you repeat this.&lt;/code&gt; That's a &lt;a href="https://graciousquotes.com/terms-and-conditions/#ai" rel="noopener noreferrer"&gt;terms of use&lt;/a&gt; condition, and it also works in the user's favour: a quote verdict without a link to its evidence is just another claim. With the record link, the reader can check the source themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing against LangChain's own conformance suite
&lt;/h2&gt;

&lt;p&gt;The package passes &lt;code&gt;langchain-tests&lt;/code&gt; (&lt;code&gt;ToolsUnitTests&lt;/code&gt; and &lt;code&gt;ToolsIntegrationTests&lt;/code&gt;, sync and async) along with its own behaviour tests. Two things worth knowing if you build a tool package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;asyncio_mode = "auto"&lt;/code&gt; in your pytest config.&lt;/strong&gt; Without it, pytest skips the async cases and the run still looks like a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI runs with no API key on purpose.&lt;/strong&gt; The API is keyless, so a green build shows that the advertised zero-setup path works for a stranger, not just on my machine. A scheduled run also catches the live API drifting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A separate CI job builds the &lt;code&gt;create_agent&lt;/code&gt; example on Python 3.13 so the guide's code can't quietly break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting listed: file through the web form
&lt;/h2&gt;

&lt;p&gt;LangChain doesn't accept integration PRs. You file an "Integration listing" issue on &lt;code&gt;langchain-ai/docs&lt;/code&gt;, a maintainer applies a label, and a bot opens the docs PR. One trap from my first listing (a sister project): &lt;strong&gt;an issue created with &lt;code&gt;gh issue create&lt;/code&gt; never triggers the automation.&lt;/strong&gt; It doesn't get the template's labels, so the bot never sees it. Filed again through the web form, it went through in days. For this package: issue filed 9 October, approved and merged on 20 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, stated plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage is what we've checked.&lt;/strong&gt; Anything outside the records comes back UNVERIFIED rather than guessed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name detection in &lt;code&gt;check_draft.py&lt;/code&gt; is a heuristic.&lt;/strong&gt; When it isn't sure who a draft credits, it reports CHECK, not a firm verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verdicts can change.&lt;/strong&gt; When new evidence turns up we update the record and log it on the public &lt;a href="https://graciousquotes.com/corrections/" rel="noopener noreferrer"&gt;corrections page&lt;/a&gt;. The full method is on &lt;a href="https://graciousquotes.com/how-we-source-quotes/" rel="noopener noreferrer"&gt;How we source quotes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit:&lt;/strong&gt; 40 requests per 10 seconds per IP. A tool that hits it says so in its result instead of raising.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk copying or training on the records needs a licence.&lt;/strong&gt; Fetching records because a user asked is fine, with the credit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Guide and API reference: &lt;a href="https://graciousquotes.com/developers/langchain/" rel="noopener noreferrer"&gt;graciousquotes.com/developers/langchain/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/graciousquotes/langchain-graciousquotes" rel="noopener noreferrer"&gt;github.com/graciousquotes/langchain-graciousquotes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/langchain-graciousquotes/" rel="noopener noreferrer"&gt;langchain-graciousquotes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI 3.1: &lt;a href="https://graciousquotes.com/developers/openapi.json" rel="noopener noreferrer"&gt;graciousquotes.com/developers/openapi.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cite it: Say, J. (2026). &lt;em&gt;langchain-graciousquotes&lt;/em&gt; [Computer software]. Zenodo. &lt;a href="https://doi.org/10.5281/zenodo.23258870" rel="noopener noreferrer"&gt;doi.org/10.5281/zenodo.23258870&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you paste in a quote and think our verdict is wrong, open an issue with your source. Corrections with a primary source are the most useful thing anyone can send us.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Jeremiah Say, founder and editor of &lt;a href="https://graciousquotes.com/about/jeremiah-say/" rel="noopener noreferrer"&gt;GraciousQuotes&lt;/a&gt;. I research quote attributions against original books, speeches and letters, and I maintain this package. I wrote earlier about &lt;a href="https://dev.to/jeremiah_say/who-said-this-quote-matching-misremembered-quotes-when-people-never-type-them-right-3pfj"&gt;how the site matches misremembered quotes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>langchain</category>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>4.2 / 0.6 = 7.000000000000001: testing calculator formulas against hand-worked examples</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 16 Oct 2026 01:55:05 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/42-06-7000000000000001-testing-calculator-formulas-against-hand-worked-examples-mpo</link>
      <guid>https://dev.to/jeremiah_say/42-06-7000000000000001-testing-calculator-formulas-against-hand-worked-examples-mpo</guid>
      <description>&lt;p&gt;A bag of QUIKRETE concrete mix that weighs 80 lb yields 0.60 cubic feet. If your job needs 4.2 cubic feet, you need exactly 7 bags.&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 8&lt;/span&gt;
&lt;span class="mf"&gt;4.2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;              &lt;span class="c1"&gt;// 7.000000000000001&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JavaScript says 8. That's one wasted bag and an annoyed customer, from a bug that no amount of looking at the code will show you. Between 1 and 200 bags' worth of volume, the naive &lt;code&gt;Math.ceil(volume / perBag)&lt;/code&gt; gets &lt;strong&gt;10 of the 200 exact multiples wrong&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://toggle9.com" rel="noopener noreferrer"&gt;Toggle9&lt;/a&gt;, a set of free calculators that show their working, and bugs like this are the reason every tool goes through the same testing routine. Here it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start from the source, not the code
&lt;/h2&gt;

&lt;p&gt;Every number a tool uses comes from a primary source: a standard (NIST defines 1 foot as exactly 0.3048 m), a government table, or a manufacturer's data sheet. For concrete, the bag yields come straight from the QUIKRETE technical data sheet, and the code says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// QUIKRETE Concrete Mix (No. 1101) technical data sheet: yield per bag in ft³.&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;BAGS&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="na"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.375&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.675&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;h2&gt;
  
  
  2. Work the examples by hand, &lt;em&gt;before&lt;/em&gt; writing code
&lt;/h2&gt;

&lt;p&gt;Before any code exists, I work a few examples by hand from the source, including the awkward ones: tiny jobs, huge jobs, mixed units, and &lt;strong&gt;exact multiples&lt;/strong&gt;, which is where the 4.2 ÷ 0.6 bug lives.&lt;/p&gt;

&lt;p&gt;A worked example for a patio slab, 12 ft × 10 ft × 4 in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;volume = 12 × 10 × (4/12) = &lt;strong&gt;40 ft³&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;plus 10% for waste = &lt;strong&gt;44 ft³&lt;/strong&gt; = 44 / 27 = &lt;strong&gt;1.63 yd³&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;80 lb bags: 44 / 0.60 = 73.3 → &lt;strong&gt;74 bags&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;60 lb bags: 44 / 0.45 = 97.8 → &lt;strong&gt;98 bags&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ready-mix is sold in quarter yards: 1.63 → &lt;strong&gt;1.75 yd³&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those numbers are worked out by hand, not taken from the code's output. That matters: a test that copies whatever the code printed only proves the code is consistent with itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep the maths in its own file
&lt;/h2&gt;

&lt;p&gt;Each tool's calculation lives in a &lt;code&gt;compute.js&lt;/code&gt; that only does maths: no DOM, no formatting, no event handlers. The UI imports it. That rule means a layout change can never change an answer, and the maths can be tested in Node with nothing mocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tools/concrete/compute.js (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ft3&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ft3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;- 1e-9&lt;/code&gt; is the fix for the opening bug: subtract a tolerance far smaller than any real measurement before rounding up, so floating-point noise above an exact multiple doesn't cost an extra bag.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Hand-worked examples become tests
&lt;/h2&gt;

&lt;p&gt;Tests use Node's built-in &lt;code&gt;node:test&lt;/code&gt; and &lt;code&gt;assert&lt;/code&gt;, with no framework. Values are kept in SI units inside the engine, so tests convert at the edges:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&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="s1"&gt;node:test&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="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&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;partVolume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FT3&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="s1"&gt;../tools/concrete/compute.js&lt;/span&gt;&lt;span class="dl"&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;FT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.3048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0254&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;close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; != &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slab 12 ft x 10 ft x 4 in = 40 ft³&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;partVolume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slab&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;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;FT3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;totals with 10% waste: 44 ft³, 1.63 yd³, 74 x 80 lb bags, 98 x 60 lb&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FT3&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="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ft3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;yd3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lb&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lb&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.75&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1.63 yd³ rounds up to 1.75 in quarter-yard steps&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exact bag multiples do not round up an extra bag&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FT3&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 6 ft³ = exactly 10 x 80 lb&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lb&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;n&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test names &lt;em&gt;are&lt;/em&gt; the worked examples, so a failure reads like a sentence: "steps: 3 steps, 7 in rise, 11 in run, 4 ft wide = 12.83 ft³".&lt;/p&gt;

&lt;p&gt;Today the engine has 202 of these across 30 test files, and &lt;code&gt;npm test&lt;/code&gt; runs them in about half a second. A tool can't go live unless every one passes, and they all run again before every release.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Then check it in real browsers
&lt;/h2&gt;

&lt;p&gt;Unit tests prove the maths. They don't prove that typing &lt;code&gt;12' 6"&lt;/code&gt; into the real input on a phone produces the right number on screen. For that there's a scripted pass (&lt;code&gt;npm run check&lt;/code&gt;) that loads every tool in &lt;strong&gt;real Chrome and WebKit&lt;/strong&gt;, at phone and desktop widths, and runs each tool through a scenario:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;typeInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; .t9-part .t9-fields .t9-input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`12' 6"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`length 12' 6": → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; .t9-fig&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;typeInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; .t9-part .t9-fields .t9-input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`abc`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`bad input error: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; .t9-part .t9-fields .t9-err&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also reports console errors and horizontal overflow (a page that's wider than the phone), and a separate pass runs an automated accessibility check with axe-core. WebKit is there because iPhone Safari is where the surprises are.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Publish the receipts, and re-check
&lt;/h2&gt;

&lt;p&gt;Each tool page shows "Show the math" with the formula, the sources it uses with links, and the date they were last checked. Sources are re-checked every 12 months, because data sheets and government tables change, and corrections are logged publicly.&lt;/p&gt;

&lt;p&gt;The full process, with a real example worked from start to finish, is on &lt;a href="https://toggle9.com/how-we-check-our-tools/" rel="noopener noreferrer"&gt;How I check every tool&lt;/a&gt;. The concrete example above is the &lt;a href="https://toggle9.com/concrete-calculator/" rel="noopener noreferrer"&gt;concrete calculator&lt;/a&gt;, so you can check my 74 bags yourself.&lt;/p&gt;




&lt;p&gt;What's the nastiest floating-point rounding bug you've run into? This one would have cost someone a bag of concrete.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>testing</category>
      <category>webdev</category>
      <category>math</category>
    </item>
    <item>
      <title>Parsing 12' 6" and 2.4 m: measurement input that doesn't annoy people</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 16 Oct 2026 01:55:04 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/parsing-12-6-and-24-m-measurement-input-that-doesnt-annoy-people-157k</link>
      <guid>https://dev.to/jeremiah_say/parsing-12-6-and-24-m-measurement-input-that-doesnt-annoy-people-157k</guid>
      <description>&lt;p&gt;Most online calculators give you a number box and a unit dropdown. Then someone measures their patio with a tape, gets &lt;strong&gt;12 feet 6 inches&lt;/strong&gt;, and has to work out that that's 12.5 before they can type anything.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://toggle9.com" rel="noopener noreferrer"&gt;Toggle9&lt;/a&gt;, a set of free calculators, and I wanted the input box to accept what people actually write down. This post walks through the parser behind it: under 200 lines of plain JavaScript, no dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it has to accept
&lt;/h2&gt;

&lt;p&gt;These are real inputs and the values the parser returns (field default unit in brackets):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Typed&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12' 6"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12.5 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12'-6 1/2"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12.5417 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;12 1/2&lt;/code&gt; or &lt;code&gt;12½&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;12.5 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;2.4 m&lt;/code&gt; (in a feet field)&lt;/td&gt;
&lt;td&gt;7.874 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;2,5&lt;/code&gt; (in a metres field)&lt;/td&gt;
&lt;td&gt;2.5 m — decimal comma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1,250&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1250 — thousands separator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12' + 3' 6"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15.5 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(12 + 4) * 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;12' x 10'&lt;/code&gt; (area field)&lt;/td&gt;
&lt;td&gt;120 sq ft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;150 sq ft&lt;/code&gt; (square metres field)&lt;/td&gt;
&lt;td&gt;150 sq ft, stored as m²&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And it has to &lt;em&gt;refuse&lt;/em&gt; some things, with a message a person can act on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Typed&lt;/th&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;12 sq ft&lt;/code&gt; in a length field&lt;/td&gt;
&lt;td&gt;That looks like an area; this field needs a length.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12 apples&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6".&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 1: normalise the mess
&lt;/h2&gt;

&lt;p&gt;Before parsing anything, fold the many ways of writing the same thing into one:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VULGAR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;½&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="s1"&gt;1/2&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="s1"&gt;¼&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="s1"&gt;1/4&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="s1"&gt;¾&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="s1"&gt;3/4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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;function&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VULGAR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;d)?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g&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="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;d&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 12½ -&amp;gt; 12 1/2&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;×xX&lt;/span&gt;&lt;span class="se"&gt;](?=\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[\d&lt;/span&gt;&lt;span class="sr"&gt;(.&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/÷/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;−–—&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d)&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;(\d{3})(?!\d)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1$2&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,250 -&amp;gt; 1250&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d)&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;(\d)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1.$2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// 2,5 -&amp;gt; 2.5&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d)&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1 $2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 3-1/2 -&amp;gt; 3 1/2&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;'’′&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;ft|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;feet|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;foot&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*-&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(?=\d)&lt;/span&gt;&lt;span class="sr"&gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1 &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 12'-6" -&amp;gt; 12' 6"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&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;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x&lt;/code&gt; only becomes multiply when a number follows it&lt;/strong&gt;, so &lt;code&gt;12 x 10&lt;/code&gt; is a product but the &lt;code&gt;x&lt;/code&gt; inside a unit word is left alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The comma rule runs in a fixed order.&lt;/strong&gt; &lt;code&gt;1,250&lt;/code&gt; (comma before exactly three digits) is a thousands separator; any other &lt;code&gt;digit,digit&lt;/code&gt; is a decimal comma. That reads &lt;code&gt;2,5&lt;/code&gt; correctly for the many people who write decimals that way, and it's predictable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: lex numbers, fractions and units
&lt;/h2&gt;

&lt;p&gt;The lexer produces three kinds of token: numbers, operators and units. Fractions are folded into the number while lexing, because &lt;code&gt;1/2&lt;/code&gt; next to a number is part of the number, not a division:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;num&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="nx"&gt;length&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;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;        &lt;span class="c1"&gt;// tight a/b is a fraction&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frac&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;frac&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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\/(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// 3 1/2&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mixed&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="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mixed&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;mixed&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="nx"&gt;length&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;num&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&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;So &lt;code&gt;48 in / 2&lt;/code&gt; (spaces around the slash) is division, while &lt;code&gt;1/2&lt;/code&gt; is a fraction.&lt;/p&gt;

&lt;p&gt;Units are matched longest-first so &lt;code&gt;sq ft&lt;/code&gt; wins over &lt;code&gt;ft&lt;/code&gt;, and volume and area words are tried before length words. Each unit token carries its &lt;strong&gt;dimension&lt;/strong&gt;: the power of length (1 = length, 2 = area, 3 = volume). Quote marks (&lt;code&gt;'&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;′&lt;/code&gt;, &lt;code&gt;″&lt;/code&gt;, and their curly versions) count as feet and inches, which matters on phones that autocorrect &lt;code&gt;'&lt;/code&gt; into &lt;code&gt;’&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: evaluate with dimensions
&lt;/h2&gt;

&lt;p&gt;The evaluator is a small recursive-descent parser (&lt;code&gt;sum → product → quantity&lt;/code&gt;) where every value is &lt;code&gt;{ v, dim }&lt;/code&gt;, always in SI base units (metres, m², m³). Multiplying adds dimensions, dividing subtracts them:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;            &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is how &lt;code&gt;12' x 10'&lt;/code&gt; becomes an &lt;strong&gt;area&lt;/strong&gt;: length × length = dimension 2, so the area field accepts it and a length field rejects it.&lt;/p&gt;

&lt;p&gt;Two rules make it feel natural rather than strict:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adjacent pieces add up.&lt;/strong&gt; &lt;code&gt;5' 10"&lt;/code&gt; is two quantities side by side with no operator, and people mean "plus". Inside &lt;code&gt;quantity()&lt;/code&gt;, while the next token is a number and we're holding a length, keep adding. If the previous unit was feet and the next number has no unit, assume inches:&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;peek&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;num&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;add&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="nf"&gt;peek&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;peek&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 5' 10 -&amp;gt; 5' 10"&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Add a unit to the second number, e.g. 5 ft 10 in.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;q&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="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;add&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;&lt;strong&gt;Bare numbers borrow the field's unit, but only when they need to.&lt;/strong&gt; In &lt;code&gt;12' + 4&lt;/code&gt;, the &lt;code&gt;4&lt;/code&gt; has no unit. Adding a length to a plain number would be a dimension error, so plain numbers in a sum are "lifted" to the field's default unit first. In &lt;code&gt;(12 + 4) * 2&lt;/code&gt; nothing has a unit, so the whole thing is evaluated as a plain number and lifted once at the end: 32 ft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: errors a person can fix
&lt;/h2&gt;

&lt;p&gt;Every failure returns &lt;code&gt;{ ok: false, error }&lt;/code&gt; with a sentence the UI shows under the box. Three kinds of error cover nearly everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can't read it:&lt;/strong&gt; "I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6"." The examples matter more than the error itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong kind of quantity:&lt;/strong&gt; "That looks like an area; this field needs a length." This catches &lt;code&gt;12 sq ft&lt;/code&gt; typed into a length box, which a plain &lt;code&gt;parseFloat&lt;/code&gt; would happily turn into 12.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out of range:&lt;/strong&gt; "Use a value of 10 or less." Range checks run on the value &lt;em&gt;in the field's unit&lt;/em&gt;, not in metres, so the numbers in the message match what the person sees.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;The parser has its own test file (Node's built-in &lt;code&gt;node:test&lt;/code&gt;, no framework), with cases taken from what people actually type: &lt;code&gt;12' 6 1/2"&lt;/code&gt;, &lt;code&gt;12 x 10&lt;/code&gt; into an area field, &lt;code&gt;2 cu yd&lt;/code&gt; into a cubic-feet field. Any input that turns out to be read wrongly gets added as a test case first, then fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's used
&lt;/h2&gt;

&lt;p&gt;The same parser sits behind every measurement box on Toggle9, for example the &lt;a href="https://toggle9.com/concrete-calculator/" rel="noopener noreferrer"&gt;concrete calculator&lt;/a&gt; (slabs, footings and post holes) and the &lt;a href="https://toggle9.com/square-footage-calculator/" rel="noopener noreferrer"&gt;square footage calculator&lt;/a&gt;. Try typing &lt;code&gt;12' 6" x 10'&lt;/code&gt; into an area field.&lt;/p&gt;

&lt;p&gt;If you've built something similar, I'd like to hear how you handled the ambiguous cases. &lt;code&gt;5 10&lt;/code&gt; is my current favourite: is that 5 feet 10, or a typo?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>parsing</category>
      <category>ux</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is senior cat food lower in calories? I paired 18 senior foods with their adult twins to check</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 16 Oct 2026 01:55:02 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/is-senior-cat-food-lower-in-calories-i-paired-18-senior-foods-with-their-adult-twins-to-check-nh1</link>
      <guid>https://dev.to/jeremiah_say/is-senior-cat-food-lower-in-calories-i-paired-18-senior-foods-with-their-adult-twins-to-check-nh1</guid>
      <description>&lt;p&gt;Most people assume a "senior" cat food is the lighter version of the adult food. I wanted to check that on real labels, so I needed pairs: each senior food next to the adult food it was made from. Same brand, same form, same recipe, with only the life stage changed.&lt;/p&gt;

&lt;p&gt;Product catalogues don't record that link anywhere. All you have is the names, and those names are messy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Science Diet Adult 7+ Senior Vitality Chicken Recipe Cat Food
Science Diet Adult Chicken Recipe Cat Food
Fancy Feast Senior 7+ Paté Chicken Feast Cat Food
Fancy Feast Classic Paté Chicken Feast Gourmet Wet Cat Food
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below is the matcher I wrote, the trap that made the filters necessary, and what the pairs show.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://perbowl.com" rel="noopener noreferrer"&gt;PerBowl&lt;/a&gt;, which reads US dog and cat food labels (calories, guaranteed analysis, barcodes) and checks them against FDA recall records. On 16 Oct 2026 the site had &lt;strong&gt;39 complete cat foods&lt;/strong&gt; whose names say senior, mature, aging, 7+ or 11+: 16 dry and 23 wet. Vet diets and kitten foods are left out.&lt;/p&gt;

&lt;p&gt;A food counts as "senior" when its name matches this regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SENIOR&lt;/span&gt; &lt;span class="o"&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;senior|mature|aging|vitality|\b7\s?\+|\b11\s?\+|\b1[0-5]\s?\+&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other complete cat food from the same brands can be the adult twin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard rules first, fuzzy matching second
&lt;/h2&gt;

&lt;p&gt;My first attempt was plain fuzzy matching: tokenise both names and take the adult food with the best Jaccard similarity. It found plenty of matches, and too many were wrong. A senior &lt;em&gt;indoor&lt;/em&gt; recipe paired with a regular adult recipe, because the long shared tail ("Chicken &amp;amp; Rice Formula Dry Cat Food") outweighed the one word that matters.&lt;/p&gt;

&lt;p&gt;So the matcher now runs in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hard filters&lt;/strong&gt;, where any mismatch rules the candidate out: brand, form (dry or wet), the main protein, and the set of "special formula" words. Those are words like indoor, hairball, urinary, sensitive, and texture words like paté or gravy. If one name has a special word the other doesn't, they aren't the same recipe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jaccard on what's left&lt;/strong&gt;, after removing the senior words and the filler every name shares ("cat", "food", "formula", brand-line words like "science diet" or "pro plan"). The best candidate wins if it scores at least 0.6.
&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;re&lt;/span&gt;

&lt;span class="n"&gt;SENIOR&lt;/span&gt; &lt;span class="o"&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;senior|mature|aging|vitality|\b7\s?\+|\b11\s?\+|\b1[0-5]\s?\+&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="n"&gt;STOP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cat cats food dry wet formula recipe adult and with for the in canned entree entrée &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complete essentials plan pro purina one science diet hill s senior mature aging &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vitality natural healthy premium gourmet&lt;/span&gt;&lt;span class="sh"&gt;"&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;PROTEINS&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;chicken&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;beef&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;lamb&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;salmon&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;turkey&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;tuna&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;fish&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;duck&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;whitefish&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;ocean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;SPECIAL&lt;/span&gt; &lt;span class="o"&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(indoor|weight|light|hairball|urinary|sensitive|digestion|perfect|grain free|&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;liveclear|savory|healthy cuisine|tender|p[aâ]t[eé]|loaf|gravy|minced|stew|naturals)\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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SENIOR&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="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="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&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;amp;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&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;split&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;[^a-z0-9]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;STOP&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;proteins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&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;split&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;[^a-z]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROTEINS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&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;specials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SPECIAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;twin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;senior&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;adults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Hard rules first: same brand, same form, same main protein, same special formulas.
&lt;/span&gt;        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;senior&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;senior&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;proteins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;proteins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;senior&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="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;specials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;specials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;senior&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="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# Then the soft rule: most of the remaining words shared (Jaccard).
&lt;/span&gt;        &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;senior&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="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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;j&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;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ta&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;ts&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ta&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;ts&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ta&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best_j&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best_j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best_j&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The senior regex runs before tokenising.&lt;/strong&gt; Otherwise "7+" and "Senior Vitality" count as differences, and good pairs drop below the threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The protein check compares lists, not sets.&lt;/strong&gt; "Chicken &amp;amp; Salmon" and "Salmon" are different recipes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An adult food can be the twin of more than one senior food.&lt;/strong&gt; Hill's sells a 7+, an 11+ and a 7+ Senior Vitality version of the same adult chicken recipe. All three pair with it, which is correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the trap from the start of this section, on a made-up name:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&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;brand&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;Purina&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;form&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;dry&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;name&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;Pro Plan Adult 7+ Indoor Chicken &amp;amp; Rice Formula Dry Cat Food&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&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;brand&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;Purina&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;form&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;dry&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;name&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;Pro Plan Adult Complete Essentials Chicken &amp;amp; Rice Formula Dry Cat Food&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# Jaccard alone: {indoor, chicken, rice} vs {chicken, rice} = 0.67, so it passes
# With the special-word filter: "indoor" only on one side, so no twin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the filter, a high Jaccard score just means the names share a lot of words. It doesn't mean the recipes are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 18 pairs show
&lt;/h2&gt;

&lt;p&gt;Across the 39 senior foods, the matcher found &lt;strong&gt;18 pairs&lt;/strong&gt;. I compare calories per cup for dry food and per kg for wet. Cans and cups come in different sizes, so those are the only like-for-like units.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Senior version vs its adult twin&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fewer calories&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More calories&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exactly the same&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So it's a coin toss. A few examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Senior food&lt;/th&gt;
&lt;th&gt;Adult → senior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pro Plan LiveClear Adult 7+ Prime Plus Chicken &amp;amp; Rice (dry)&lt;/td&gt;
&lt;td&gt;494 → 604 kcal/cup (&lt;strong&gt;+110&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro Plan Adult 7+ Complete Essentials Chicken &amp;amp; Rice (dry)&lt;/td&gt;
&lt;td&gt;494 → 545 kcal/cup (+51)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Science Diet Adult 7+ Chicken Recipe (dry)&lt;/td&gt;
&lt;td&gt;483 → 393 kcal/cup (−90)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Science Diet Adult 11+ Indoor Chicken Recipe (dry)&lt;/td&gt;
&lt;td&gt;327 → 396 kcal/cup (+69)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Science Diet Adult 7+ Tender Tuna Dinner (wet)&lt;/td&gt;
&lt;td&gt;1,040 → 903 kcal/kg (&lt;strong&gt;−137&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORIJEN Guardian Senior (dry)&lt;/td&gt;
&lt;td&gt;498 → 460 kcal/cup (−38)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three Purina Pro Plan 7+ dry foods in the set have &lt;em&gt;more&lt;/em&gt; calories than their adult versions. Hill's goes both ways: within the same brand, the 7+ chicken recipe is lighter than the adult one and the 11+ indoor recipe is denser.&lt;/p&gt;

&lt;p&gt;The medians tell the same story. The middle senior dry cat food has &lt;strong&gt;425 kcal per cup&lt;/strong&gt; and the middle adult dry food has &lt;strong&gt;420&lt;/strong&gt;. Senior dry foods range from &lt;strong&gt;324 to 621 kcal per cup&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;None of this means the makers are wrong. Older cats aren't one group. Many cats aged roughly 7 to 12 are overweight and do better on fewer calories. Many cats over 12 are thin, and a 2005 review (by a Nestlé Purina researcher, so not independent) found that about a third of them digest fat less well. An independent 2013 study also measured lower digestibility in older cats. A denser senior food can be a deliberate choice for that second group. But "senior" on the bag doesn't tell you which group a food was made for, and AAFCO has no senior nutrient profile. So you have to read the calories.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second trap: two kinds of protein number
&lt;/h2&gt;

&lt;p&gt;Adding protein and fat columns raised a different problem. Most US labels give only the &lt;strong&gt;guaranteed analysis&lt;/strong&gt;, which is a legal &lt;em&gt;minimum&lt;/em&gt;. Hill's also publishes &lt;strong&gt;average&lt;/strong&gt; analyses, and for some Hill's foods the average is the only figure in my data.&lt;/p&gt;

&lt;p&gt;A minimum is a floor and an average is a typical value. Put one next to the other and a recipe can look several points richer in protein when nothing about it has changed.&lt;/p&gt;

&lt;p&gt;So the rule is in code. A pair uses Hill's averages only when both foods have them, and the table marks those rows "avg". Otherwise both sides use the guaranteed minimums. Both kinds of number are converted to dry matter (water removed), because a wet food at 78% moisture and a dry food at 10% can't be compared as printed.&lt;/p&gt;

&lt;p&gt;Even like for like, a few pairs move a lot. Science Diet Adult 7+ Savory Beef Entrée goes from 31% to 40% protein, comparing Hill's averages on both sides. So the rule doesn't hide real differences. It stops you from making fake ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run hard filters before you fuzzy-match.&lt;/strong&gt; Similarity scores reward shared boilerplate. The words that define a variant are often the words a token score treats as noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalise the thing you're testing before tokenising.&lt;/strong&gt; I strip the senior markers because they're the one difference every pair is supposed to have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what kind of number each field holds.&lt;/strong&gt; A minimum and an average can share a column name and still not be comparable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full table, with all 18 pairs and all 39 senior foods sortable from lightest to densest, is on PerBowl's &lt;a href="https://perbowl.com/cat-food/for-senior-cats/" rel="noopener noreferrer"&gt;cat food for senior cats&lt;/a&gt; page. The usual caveat applies too: if an older cat is losing weight, drinking more or eating differently, see a vet before changing food. A new label won't fix those.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>algorithms</category>
      <category>data</category>
    </item>
    <item>
      <title>FDA now classifies pet food recalls 4.7x faster than before 2020. Here's the 40-line check, and the trap in it</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 09 Oct 2026 11:00:33 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/fda-now-classifies-pet-food-recalls-47x-faster-than-before-2020-heres-the-40-line-check-and-the-4286</link>
      <guid>https://dev.to/jeremiah_say/fda-now-classifies-pet-food-recalls-47x-faster-than-before-2020-heres-the-40-line-check-and-the-4286</guid>
      <description>&lt;p&gt;When a pet food company starts a recall, the record most tools are built on isn't out yet. FDA's &lt;strong&gt;classified&lt;/strong&gt; recall record (the one with the Class I/II/III rating, the exact lot codes and the barcodes) only appears once FDA has reviewed the recall and classified it.&lt;/p&gt;

&lt;p&gt;So how long does that take? I counted, for every dog and cat food recall FDA has classified since its Enforcement Reports went online in 2012:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FDA classified it&lt;/th&gt;
&lt;th&gt;Recalls&lt;/th&gt;
&lt;th&gt;Median days from start to classification&lt;/th&gt;
&lt;th&gt;Classified within 30 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2012–2019&lt;/td&gt;
&lt;td&gt;158&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;140&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2020 onward&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's about 4.7 times faster. Below is the script that produces every number in this post, plus the check I ran before believing it. A drop like this is exactly what a common data bug looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://perbowl.com" rel="noopener noreferrer"&gt;PerBowl&lt;/a&gt;, which traces US dog and cat foods back to the companies and plants that make them. FDA publishes recalls through its Enforcement Report search, but there's no bulk download filtered to pet food. So I built one and released it as an &lt;a href="https://perbowl.com/recalls/dataset/" rel="noopener noreferrer"&gt;open dataset&lt;/a&gt; under CC BY 4.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Of the 3,660 veterinary-category product rows in FDA's Enforcement Reports, I kept 1,232 dog and cat food products. The other 2,428 were animal drugs and devices, livestock feed, food for other animals, and similar. Every dropped row is in &lt;code&gt;excluded.csv&lt;/code&gt; with the reason it was dropped, so the filter can be audited.&lt;/li&gt;
&lt;li&gt;Those 1,232 products group into 266 recall events, one row each in &lt;code&gt;recalls.csv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each event has &lt;code&gt;initiated_date&lt;/code&gt; (the firm started the recall) and &lt;code&gt;classified_date&lt;/code&gt; (FDA classified it). Both are the earliest date across the event's products.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's on &lt;a href="https://github.com/perbowl/pet-food-recalls" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://doi.org/10.5281/zenodo.23255873" rel="noopener noreferrer"&gt;Zenodo&lt;/a&gt; and &lt;a href="https://www.kaggle.com/datasets/greencalculus/us-dog-and-cat-food-recalls-fda" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt;. Every release is tagged, so the script below pins the version these numbers came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;Standard library only. Copy it and run it with &lt;code&gt;python3&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;

&lt;span class="n"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v2026-10-09&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# pinned so the numbers reproduce; "main" = latest release
&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://raw.githubusercontent.com/perbowl/pet-food-recalls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VERSION&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/recalls.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextIOWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&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;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initiated_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initiated_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;recalls&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;share&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;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;v&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;v&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;recalls with both dates: &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;recalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; of &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;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;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;classified 2012-2019: n=&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;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; median=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days, &amp;lt;=30d &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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;classified 2020 on:   n=&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;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; median=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days, &amp;lt;=30d &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%, &amp;lt;=60d &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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;ratio: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Class I&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;Class II&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;Class III&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fda_class&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fda_class&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  before &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (n=&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;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)   since &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (n=&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;years&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;y&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;years&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;y&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&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="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# The censoring check (see below): group by the year the FIRM STARTED the recall.
&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initiated_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;started 2020-2023: n=&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;settled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; median=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;early&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recalls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2012-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initiated_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2016-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;started 2012-2015: n=&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;early&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; median=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;early&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days&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;All 266 recalls have both dates, so nothing gets dropped. Since 2020, 86% were classified within 60 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  It holds for every class
&lt;/h2&gt;

&lt;p&gt;The obvious objection is that the mix of recalls changed. Fewer serious recalls would mean less review work and a faster median. But the drop shows up within each class:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;2012–2019&lt;/th&gt;
&lt;th&gt;2020 onward&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Class I (reasonable chance of serious harm)&lt;/td&gt;
&lt;td&gt;154 days (n=116)&lt;/td&gt;
&lt;td&gt;32 days (n=84)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class II&lt;/td&gt;
&lt;td&gt;118 days (n=28)&lt;/td&gt;
&lt;td&gt;22 days (n=20)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class III&lt;/td&gt;
&lt;td&gt;64 days (n=14)&lt;/td&gt;
&lt;td&gt;29 days (n=4)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before 2020, the most serious recalls waited the longest. Class III has too few rows since 2020 to mean much on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: you can't see the slow ones yet
&lt;/h2&gt;

&lt;p&gt;This is the part worth taking away, even if you never touch pet food data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dataset only contains recalls FDA has already classified.&lt;/strong&gt; A recall that started in spring 2026 and is still waiting isn't in it at all. Recent years are therefore missing their slowest cases, and the median for "2020 onward" gets pulled down by recalls that simply haven't finished yet. It's right-censoring, and it can make any process look as if it's speeding up.&lt;/p&gt;

&lt;p&gt;The fix is to choose a group of recalls that have all had time to finish. Group them by the year the &lt;strong&gt;firm started&lt;/strong&gt; the recall, not the year FDA classified it, and keep only starts from 2020 to 2023. Every one of those has had nearly three years or more to get classified, so if slow ones were still pending, they would be missing from this group and would show up later as very long lags.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;started 2020-2023: n=56 median=28 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;28 days, which matches the 30-day median for all recalls since 2020. So the drop isn't caused by unfinished recalls. For comparison, recalls started in 2012–2015 had a median of 158 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The year-by-year view is messier
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year classified&lt;/th&gt;
&lt;th&gt;Recalls&lt;/th&gt;
&lt;th&gt;Median days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2012&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2013&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2014&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2015&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;358&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2016&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2017&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2019&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2022&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before 2020 the medians jump around, and some years have only 10 recalls. From 2020 on, every year sits between 26 and 42. So it wasn't a gradual improvement. The numbers changed sharply around 2020 and have stayed low since.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; The data can't say. In December 2017, an HHS Inspector General audit (&lt;a href="https://oig.hhs.gov/oas/reports/region1/11601502.asp" rel="noopener noreferrer"&gt;A-01-16-01502&lt;/a&gt;) found that FDA "could not always ensure that firms initiated recalls promptly." The same month, FDA's commissioner announced a new senior team for difficult recalls. That fits the timing loosely: 2018 was quicker, but 2019 was slow again. I've put the details and sources in &lt;a href="https://perbowl.com/recalls/how-long-to-classify/" rel="noopener noreferrer"&gt;the full write-up&lt;/a&gt;. I'd treat the cause as an open question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters if you build on recall data
&lt;/h2&gt;

&lt;p&gt;A median of 30 days is still a month. Any alert, feed or "is my product recalled?" lookup built only on classified records shows a recall weeks after it started. The company's own announcement usually comes much earlier. &lt;a href="https://dev.to/jeremiah_say/same-day-pet-food-recall-alerts-from-one-fda-json-file-and-a-cron-job-5cjp"&gt;My last post&lt;/a&gt; covers getting those same-day announcements from one FDA JSON file, and why 21 of the 48 recalls FDA classified from 2024 onward never appeared as announcements at all. You need both sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The dates are the earliest across a recall's products. A recall that FDA classifies in stages counts from its first classification.&lt;/li&gt;
&lt;li&gt;"Dog and cat food" comes from a keyword filter on FDA's product descriptions. It's tuned by hand, and &lt;code&gt;excluded.csv&lt;/code&gt; exists so you can check every decision it made.&lt;/li&gt;
&lt;li&gt;These are US FDA recalls only. Recalls in other countries aren't included.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the data
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dataset page: &lt;a href="https://perbowl.com/recalls/dataset/" rel="noopener noreferrer"&gt;perbowl.com/recalls/dataset&lt;/a&gt;. It covers files, columns and how the dataset is built.&lt;/li&gt;
&lt;li&gt;Cite this version: PerBowl (2026), &lt;em&gt;US Dog and Cat Food Recalls (FDA, 2012 onward)&lt;/em&gt;, version 2026-10-09, &lt;a href="https://doi.org/10.5281/zenodo.23255874" rel="noopener noreferrer"&gt;doi:10.5281/zenodo.23255874&lt;/a&gt;. The concept DOI &lt;a href="https://doi.org/10.5281/zenodo.23255873" rel="noopener noreferrer"&gt;10.5281/zenodo.23255873&lt;/a&gt; always points to the latest release.&lt;/li&gt;
&lt;li&gt;It's updated monthly. If you find a recall that's misclassified or missing, open an issue on &lt;a href="https://github.com/perbowl/pet-food-recalls/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Corrections go into the next release.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>opendata</category>
      <category>datascience</category>
      <category>data</category>
    </item>
    <item>
      <title>Who said this quote? Matching misremembered quotes when people never type them right</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:58:16 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/who-said-this-quote-matching-misremembered-quotes-when-people-never-type-them-right-3pfj</link>
      <guid>https://dev.to/jeremiah_say/who-said-this-quote-matching-misremembered-quotes-when-people-never-type-them-right-3pfj</guid>
      <description>&lt;p&gt;Nobody types a famous quote exactly. They type it the way they remember it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;insanity is doing the same thing over and over and expecting different results&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The usual wording has an "again" in it. Some people add "the definition of". Some put the attribution on the end: "… - Albert Einstein". A search box that needs every word, or the exact phrase, misses most of these.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://graciousquotes.com" rel="noopener noreferrer"&gt;GraciousQuotes&lt;/a&gt;, where each quote carries a verdict (verified, disputed, misattributed and so on) with its source. The &lt;a href="https://graciousquotes.com/who-said-this-quote/" rel="noopener noreferrer"&gt;Who said this quote?&lt;/a&gt; checker takes whatever someone pastes and answers with the closest quote on the site and its verdict. For the line above, it answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Match&lt;/th&gt;
&lt;th&gt;Credited to&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Quote&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;close (0.846)&lt;/td&gt;
&lt;td&gt;Albert Einstein&lt;/td&gt;
&lt;td&gt;disputed&lt;/td&gt;
&lt;td&gt;Insanity is doing the same thing over and over again and expecting different results.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's how it gets there, in two stages: a cheap database search to find candidates, then a careful PHP scorer to rank them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plain search wasn't enough
&lt;/h2&gt;

&lt;p&gt;The site's existing search was &lt;strong&gt;word-AND&lt;/strong&gt;: return quotes containing every word typed. For checking quotes that fails in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It misses.&lt;/strong&gt; One extra word ("again", "the definition of") and the real quote is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It matches the wrong thing.&lt;/strong&gt; Common words appear everywhere, so a quote that happens to contain all the same words, in a different order and meaning something else, counts as a hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we actually want to know is: &lt;em&gt;is this the same sentence, give or take a few words?&lt;/em&gt; That's about word order, not just shared words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: normalise both sides
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, the query and every quote go through the same normalisation: decode HTML entities, strip accents, lowercase, delete apostrophes ("don't" and "dont" are the same word), and turn everything else that isn't a letter or digit into a space.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;gq_qc_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;html_entity_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;ENT_QUOTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remove_accents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2019}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2018}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/[^a-z0-9]+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\s+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query also loses a trailing credit ("… - Albert Einstein", "… —Mark Twain") and its wrapping quote marks, because people paste them along with the quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: candidates from MySQL FULLTEXT
&lt;/h2&gt;

&lt;p&gt;Scoring every quote in PHP would be slow, so MySQL narrows it down first. Quotes live in a separate FULLTEXT table rebuilt nightly, and the checker asks it in &lt;strong&gt;natural-language mode&lt;/strong&gt;, which ranks by relevance instead of requiring every word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT quote_id, text, MATCH(text) AGAINST (%s IN NATURAL LANGUAGE MODE) sc
   FROM &lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt; WHERE MATCH(text) AGAINST (%s IN NATURAL LANGUAGE MODE)
   ORDER BY sc DESC LIMIT 120"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$qn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$qn&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;120 candidates is generous. FULLTEXT is good at "these share unusual words" and bad at "these are the same sentence", so the real decision happens in the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: score word order and coverage
&lt;/h2&gt;

&lt;p&gt;For each candidate, three measures between 0 and 1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run:&lt;/strong&gt; the longest stretch of consecutive words the query and the quote share, divided by the query's length. This is what captures "same sentence": word order matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query coverage:&lt;/strong&gt; how much of what they typed is in the quote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote coverage:&lt;/strong&gt; how much of the quote they typed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The longest common run is the classic dynamic-programming longest-common-substring, over words instead of characters, keeping only the previous row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;gq_qc_lcs_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;$prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_fill&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="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$wa&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_fill&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="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$wb&lt;/span&gt; &lt;span class="p"&gt;)&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="nv"&gt;$wa&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$wb&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cur&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="nv"&gt;$best&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;Then a weighted score, with the run counting most:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$run&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$cov_q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;min&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="nv"&gt;$cov_c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$kind&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$run&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'close'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'possible'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked through for the insanity example (13 words typed; the quote has 14):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the longest shared run is "insanity is doing the same thing over and over": 9 of 13 words → &lt;strong&gt;run 0.692&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;all 13 typed words are in the quote → &lt;strong&gt;query coverage 1.0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;13 of the quote's 14 words were typed → quote coverage 0.929, × 1.5 capped at 1 → &lt;strong&gt;1.0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;score = 0.5 × 0.692 + 0.3 × 1.0 + 0.2 × 1.0 = &lt;strong&gt;0.846&lt;/strong&gt;, and the run is above 0.6, so it's &lt;strong&gt;close&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;× 1.5&lt;/code&gt; on quote coverage means typing most of a long quote counts fully. People usually remember the famous half.&lt;/p&gt;

&lt;p&gt;Before any of that, one shortcut: if the typed words appear &lt;strong&gt;in order inside&lt;/strong&gt; the quote (or a quote of at least 4 words appears inside the query), it's an &lt;strong&gt;exact&lt;/strong&gt; match with score 1.0. "Float like a butterfly sting like a bee" is exact against Muhammad Ali's full line, which continues "Rumble, young man, rumble."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: present answers, not search results
&lt;/h2&gt;

&lt;p&gt;A checker should give one answer, not ten results, so the ranking ends with a few rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ties go to the checked verdict.&lt;/strong&gt; If two quotes score the same, a verified or misattributed record outranks an unreviewed one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One card per wording.&lt;/strong&gt; Quotes whose first 80 normalised characters match are the same quote, shown once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look-alikes are noise next to a real match.&lt;/strong&gt; After an exact match, anything under 0.85 is dropped; after a close match, anything under 0.7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variants fold in.&lt;/strong&gt; Other wordings with the same author and verdict are listed under the top answer instead of repeating it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Learning from misses
&lt;/h2&gt;

&lt;p&gt;When the checker finds nothing, the query is counted by week (normalised text, first 300 characters, outcome) with no IP address, cookie or account, and rows older than 12 months are deleted. Those misses are the to-do list: they show which quotes people ask about that the site doesn't have yet.&lt;/p&gt;

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

&lt;p&gt;The checker is free at &lt;a href="https://graciousquotes.com/who-said-this-quote/" rel="noopener noreferrer"&gt;graciousquotes.com/who-said-this-quote&lt;/a&gt;. Paste a quote the way you remember it. The misquotes are the interesting ones.&lt;/p&gt;

</description>
      <category>php</category>
      <category>mysql</category>
      <category>search</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Same-day pet food recall alerts from one FDA JSON file and a cron job</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:58:12 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/same-day-pet-food-recall-alerts-from-one-fda-json-file-and-a-cron-job-5cjp</link>
      <guid>https://dev.to/jeremiah_say/same-day-pet-food-recall-alerts-from-one-fda-json-file-and-a-cron-job-5cjp</guid>
      <description>&lt;p&gt;If your dog's food is recalled, you want to know today, not next month.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://perbowl.com" rel="noopener noreferrer"&gt;PerBowl&lt;/a&gt;, a site that traces US dog and cat foods back to the companies and plants that make them, with every FDA recall since 2012. When I built recall alerts, I assumed the obvious source was the right one: FDA's &lt;strong&gt;classified&lt;/strong&gt; recall records (the enforcement reports, the ones with a Class I/II/III and full product lists).&lt;/p&gt;

&lt;p&gt;It's the wrong source for alerts. I matched 30 dog and cat food recall announcements since January 2024 against FDA's classified records, and the classified record arrived a &lt;strong&gt;median of 25 days later&lt;/strong&gt;, once 91 days later. (&lt;a href="https://perbowl.com/recalls/announced-vs-classified/" rel="noopener noreferrer"&gt;The full table and CSV are here&lt;/a&gt;.) By then the bag is long empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same-day source: one JSON file
&lt;/h2&gt;

&lt;p&gt;The company announcement, the press release a firm publishes when it recalls a product, shows up on FDA's "Recalls, Market Withdrawals, &amp;amp; Safety Alerts" page the day it's posted. That page is backed by a single JSON file:&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="err"&gt;https://www.fda.gov/datatables-json/recalls-market-withdrawals.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key, no pagination: about 1,080 rows today, covering every FDA-regulated product (food, drugs, devices, cosmetics, pet food). Each row looks roughly like this:&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;"field_change_date_2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"08/28/2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field_company_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Northwest Naturals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field_brand_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Northwest Naturals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field_product_description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chicken Recipe …"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field_recall_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Salmonella, Listeria"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field_regulated_product_field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Animal &amp;amp; Veterinary, Pet Food"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/safety/recalls-market-withdrawals-safety-alerts/…"&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;Some fields contain HTML, so everything goes through a strip-tags-and-decode step first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering to dog and cat food
&lt;/h2&gt;

&lt;p&gt;"Pet Food" in the product-type field catches most of them. The rest are filed under "Animal &amp;amp; Veterinary" and need a word check on the product, which then has to &lt;em&gt;exclude&lt;/em&gt; things that are animal products but not what a dog or cat owner signed up for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Pet Food'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Animal &amp;amp; Veterinary'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\b(dogs?|cats?|pets?|canine|feline|kitten|puppy|treats?|pig ears?|bully|chews?|jerky|rawhide|recipe|raw diet)\b/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$brand&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Dog and cat only: other animals' feed and veterinary drugs are not what subscribers asked for.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$pet&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\b(dogs?|cats?|canine|feline|kitten|puppy)\b/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\b(hedgehog|reptile|bird|parrot|fish|aquarium|rabbit|hamster|horse|equine|cattle|swine|poultry|livestock|chicken feed|layer|sheep|lamb|goat)\b|injection|tablets?|sterile/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$brand&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="nv"&gt;$pet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;Two lessons from tuning it on real rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep pig ears and chews.&lt;/strong&gt; Several Salmonella recalls are treats that never say "dog" in the product line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"chicken" and "lamb" are not animals here, they're recipes.&lt;/strong&gt; Which is why "lamb" only excludes a row when no dog or cat word is present, and why "chicken feed" is a phrase, not "chicken".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Only alert on what's new
&lt;/h2&gt;

&lt;p&gt;Every 6 hours the server fetches the file, filters it, and compares each row's &lt;code&gt;path&lt;/code&gt; (stable per announcement) with a stored list of paths already seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'pb_alerts_seen'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&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="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$seen&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// first run: everything already on FDA's list counts as old news&lt;/span&gt;
  &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'pb_alerts_seen'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$all&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;1000&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$all&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt; &lt;span class="p"&gt;)&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="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$seen&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="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;$seen&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&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="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'date'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$cut&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// an old entry FDA edited: not news&lt;/span&gt;
  &lt;span class="c1"&gt;// … match subscribers by brand, send, remember it for later …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guards matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first run seeds, it doesn't send.&lt;/strong&gt; Otherwise switching the feature on would email everyone about the last ten years of recalls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything dated more than 14 days ago is skipped&lt;/strong&gt;, because an old entry that FDA edits can turn up looking new.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The announcement is remembered too (date, company, brand), so when FDA's classified record arrives weeks later, subscribers to a &lt;em&gt;specific food&lt;/em&gt; get a follow-up that says "confirmed: your barcode is on the list" instead of a second "new recall" email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recalls that are never announced
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect: of the 48 dog and cat food recalls FDA classified since 2024, &lt;strong&gt;21 (44%) never appeared on the announcement list at all&lt;/strong&gt;. Those only exist as classified records, so the alert system has two triggers: announcements from the JSON file (same day), and newly imported classified recalls that match no earlier announcement.&lt;/p&gt;

&lt;p&gt;(The window starts in 2024 on purpose: FDA's list keeps far fewer older announcements, so any "never announced" count before then would be wrong.)&lt;/p&gt;

&lt;h2&gt;
  
  
  WordPress cron needed a nudge
&lt;/h2&gt;

&lt;p&gt;The checks run on WP-Cron, which only fires when someone loads a page. With Cloudflare caching HTML at the edge, most visits never reach PHP at all, so the "every 6 hours" check could go a day without running. The fix is a one-line system crontab that runs due events every 15 minutes:&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="k"&gt;*&lt;/span&gt;/15 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/site &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a daily watchdog that emails me if the FDA check hasn't succeeded in 13 hours, and a Monday summary email. If the summary doesn't arrive, the scheduled jobs have stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does now
&lt;/h2&gt;

&lt;p&gt;New FDA dog and cat food recall announcements go out the same day as emails to subscribers (by brand, by specific food, or all recalls), and as posts on Bluesky and X. You can sign up on &lt;a href="https://perbowl.com/recalls/" rel="noopener noreferrer"&gt;PerBowl's recall list&lt;/a&gt;, which also shows announcements FDA hasn't classified yet.&lt;/p&gt;

&lt;p&gt;The announcement-to-classification data, with every pair of dates, is &lt;a href="https://perbowl.com/wp-json/perbowl/v1/recall-lag.csv" rel="noopener noreferrer"&gt;downloadable as CSV&lt;/a&gt; if you'd like to dig into it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>wordpress</category>
      <category>opendata</category>
      <category>cron</category>
    </item>
    <item>
      <title>A fair Secret Santa draw with exclusions is a matching problem</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Thu, 08 Oct 2026 01:39:28 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/a-fair-secret-santa-draw-with-exclusions-is-a-matching-problem-36i</link>
      <guid>https://dev.to/jeremiah_say/a-fair-secret-santa-draw-with-exclusions-is-a-matching-problem-36i</guid>
      <description>&lt;p&gt;A Secret Santa draw looks like a shuffle. It stops being one the moment someone says "Ana and Ben are a couple, they can't draw each other" and "nobody gets the same person as last year".&lt;/p&gt;

&lt;p&gt;I built the &lt;a href="https://toggle9.com/secret-santa-generator/" rel="noopener noreferrer"&gt;Secret Santa generator on Toggle9&lt;/a&gt; and ended up with three small problems hiding inside it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is a draw possible at all&lt;/strong&gt; with these rules, and if not, &lt;em&gt;why not&lt;/em&gt;, in words a person can act on?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you pick one&lt;/strong&gt; so that every valid draw is equally likely?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many valid draws are there?&lt;/strong&gt; It turns out people like knowing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of it is pure functions over a 0/1 matrix, with no DOM, and randomness passed in so tests can seed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: an "allowed" matrix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;A[i][j]&lt;/code&gt; is true when person &lt;em&gt;i&lt;/em&gt; may buy for person &lt;em&gt;j&lt;/em&gt;. Start with everything allowed except yourself, then switch off exclusions and last year's pairs:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;allowedMatrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avoid&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;i&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;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&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;i&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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="k"&gt;for &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;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;no&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idx&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleLowerCase&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="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&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="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idx&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleLowerCase&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="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;A&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;A draw is then a permutation &lt;code&gt;to&lt;/code&gt; where &lt;code&gt;A[i][to[i]]&lt;/code&gt; holds for everyone. With no extra rules that's a &lt;strong&gt;derangement&lt;/strong&gt;: a permutation with no fixed points. For 6 people there are 265 of them out of 720 shuffles, about 36.8%. (That ratio tends to 1/e, which is why "shuffle until nobody has themselves" works fine for small groups.)&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Is it possible? Matching, plus Hall's theorem for the "why"
&lt;/h2&gt;

&lt;p&gt;Whether &lt;em&gt;some&lt;/em&gt; valid draw exists is a bipartite matching question: givers on one side, receivers on the other, edges where &lt;code&gt;A&lt;/code&gt; allows. Kuhn's augmenting-path algorithm answers it in a few lines.&lt;/p&gt;

&lt;p&gt;The more interesting part is what to say when it fails. "No valid draw" is useless to a group organiser. &lt;strong&gt;Hall's theorem&lt;/strong&gt; says a perfect matching fails to exist exactly when some group of &lt;em&gt;k&lt;/em&gt; givers can only reach fewer than &lt;em&gt;k&lt;/em&gt; receivers. And the failing run of Kuhn's algorithm hands you that group: everything reachable from the stuck giver along alternating paths.&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tryAug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// i cannot be placed: everything reachable from i by alternating paths is the Hall violator&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nx"&gt;rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&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;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&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="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&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="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;gs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;givers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;gs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// receivers.length &amp;lt; givers.length&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small example: three people, and Ana and Ben may not draw each other. Ana can only buy for Cal, and so can Ben. The function returns &lt;code&gt;givers: [Ana, Ben], receivers: [Cal]&lt;/code&gt;, which the page turns into a sentence: &lt;em&gt;Ana and Ben can only buy for Cal: 2 people, 1 person to buy for.&lt;/em&gt; Now the organiser knows which rule to drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Picking one fairly: rejection sampling
&lt;/h2&gt;

&lt;p&gt;The tempting approach is to assign people one at a time, picking a random allowed receiver for each and backtracking when stuck. It finds a draw quickly, but &lt;strong&gt;not uniformly&lt;/strong&gt;: early givers' choices skew who is left for later givers, so some valid draws come up more often than others.&lt;/p&gt;

&lt;p&gt;The simplest fair method is also the oldest: shuffle (Fisher–Yates), check against the rules, and try again if it fails.&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxTries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;rnd&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="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;exact&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="na"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&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;Every permutation is equally likely from the shuffle, and we keep only the valid ones, so every &lt;em&gt;valid&lt;/em&gt; draw is equally likely too. For a typical office or family group this succeeds within a handful of tries. In a 6-person test with two couples, it took 2.&lt;/p&gt;

&lt;p&gt;The catch is a group with so many rules that valid draws are a tiny fraction of all shuffles. After 400,000 tries the code falls back to a randomised depth-first search plus random valid swaps (40·n² attempts), and &lt;strong&gt;reports &lt;code&gt;exact: false&lt;/code&gt;&lt;/strong&gt; rather than pretending the result is perfectly uniform. In practice only heavily constrained groups ever reach it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rnd&lt;/code&gt; is passed in: the page uses &lt;code&gt;crypto.getRandomValues&lt;/code&gt;, and the tests pass a seeded generator so a draw can be replayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variants: "one big circle" and "no swapping pairs"
&lt;/h2&gt;

&lt;p&gt;Two options change the shape of a valid draw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One big circle:&lt;/strong&gt; Ana → Ben → Cal → … → Ana, so gifts can be opened in a chain. Now a draw is a directed &lt;strong&gt;Hamiltonian cycle&lt;/strong&gt; in the allowed graph, not just any permutation. The shuffle step generates a random cycle order instead of a random permutation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No swaps:&lt;/strong&gt; if Ana buys for Ben, Ben shouldn't buy for Ana. That forbids 2-cycles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both plug into the same &lt;code&gt;isValid&lt;/code&gt; check, so the rejection-sampling loop stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Counting the draws
&lt;/h2&gt;

&lt;p&gt;Showing "there are 116 possible draws" is a small thing, but it reassures people that the rules haven't boxed the draw in. Counting depends on the variant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain draw:&lt;/strong&gt; the number of perfect matchings is the &lt;strong&gt;permanent&lt;/strong&gt; of &lt;code&gt;A&lt;/code&gt;. Ryser's formula with a Gray-code walk computes it in O(2ⁿ·n). The intermediate terms overflow exact double precision from about 13 people, so larger groups switch to &lt;code&gt;BigInt&lt;/code&gt; (the final count is still small enough for a &lt;code&gt;Number&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Circle:&lt;/strong&gt; count Hamiltonian cycles with a bitmask DP over subsets, fixed to start at person 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No swaps:&lt;/strong&gt; brute force for up to 9 people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Above 16 people the page simply doesn't show a count, because 2ⁿ stops being quick enough.&lt;/p&gt;

&lt;p&gt;For the same 6 people with two couples who can't draw each other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;th&gt;Valid draws&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Couples excluded&lt;/td&gt;
&lt;td&gt;116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Couples excluded + one big circle&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Couples excluded + no swaps&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The reveal links
&lt;/h2&gt;

&lt;p&gt;Once drawn, each person gets their own link with their match encoded after the &lt;code&gt;#&lt;/code&gt;, so it is never sent to a server. The text is XOR-scrambled with a random salt so a name doesn't show in a chat preview. That is &lt;strong&gt;not encryption&lt;/strong&gt;, and the page says so: it stops accidental peeking, not a determined snoop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;The draw has its own test file in Node's built-in &lt;code&gt;node:test&lt;/code&gt;, with a seeded random generator so every run is repeatable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;counts match the derangement numbers (OEIS A000166) with no rules, and brute force with rules, including the BigInt path at 13–16 people;&lt;/li&gt;
&lt;li&gt;impossible groups must name the givers and receivers that cause it;&lt;/li&gt;
&lt;li&gt;every returned draw must pass &lt;code&gt;isValid&lt;/code&gt;, in every mode;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fairness:&lt;/strong&gt; 4 people have 9 possible draws, so 18,000 seeded draws should land on each of them 2,000 times, give or take 10%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd like to try the edge cases, &lt;a href="https://toggle9.com/secret-santa-generator/" rel="noopener noreferrer"&gt;the generator is free with no sign-up&lt;/a&gt;. Three people where two can't draw each other is the quickest way to see the "why not" message.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>math</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Summing 50,000 emission line items in the wrong order changes your total</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 12 Jun 2026 06:38:12 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/summing-50000-emission-line-items-in-the-wrong-order-changes-your-total-2969</link>
      <guid>https://dev.to/jeremiah_say/summing-50000-emission-line-items-in-the-wrong-order-changes-your-total-2969</guid>
      <description>&lt;p&gt;&lt;em&gt;Floating-point addition isn't associative. For a corporate inventory with tens of thousands of rows, naive summation drifts — and the number you disclose depends on row order. Here's why, and the fix.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Here's a result that should bother anyone building carbon software. Take a corporate emissions inventory — tens of thousands of line items, each a number in tonnes CO₂e. Sum it. Now sort the same rows differently and sum again. The totals don't match. Not by much — maybe the third or fourth decimal place — but they don't match, and nothing in your code changed except the order.&lt;/p&gt;

&lt;p&gt;If you've never seen this, open a console:&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="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;   &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the same bug, scaled up to a reporting deliverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why order changes the answer
&lt;/h2&gt;

&lt;p&gt;IEEE 754 doubles have 52 bits of mantissa. That's about 15–16 significant decimal digits of precision — generous, until you add numbers of very different magnitudes.&lt;/p&gt;

&lt;p&gt;When you add a small number to a large running total, the small number gets shifted right to line up the exponents before the addition happens. Bits that fall off the end of the mantissa are gone. Add a &lt;code&gt;0.0001 tCO₂e&lt;/code&gt; line to a running total of &lt;code&gt;80000.0&lt;/code&gt; and there simply aren't enough mantissa bits to hold both the 80,000 and the 0.0001 — the small value is partially or completely swallowed.&lt;/p&gt;

&lt;p&gt;Float addition, as a result, isn't associative. &lt;code&gt;(a + b) + c&lt;/code&gt; is not guaranteed to equal &lt;code&gt;a + (b + c)&lt;/code&gt;. Sum your rows largest-first and the small values vanish early against a big accumulator. Sum smallest-first and they accumulate into something large enough to survive. Same data, different total.&lt;/p&gt;

&lt;p&gt;Here's the effect, deliberately constructed to be visible:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80000&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;smalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// small values first, then the big one&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;smalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;big&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// big value first, then the smalls&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;big&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;smalls&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 80004.99999999...&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 80004.99999999... but not the same trailing digits&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 50,000 small lines &lt;em&gt;should&lt;/em&gt; contribute exactly 5.0. Depending on order, some of that contribution erodes against the large accumulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for an inventory specifically
&lt;/h2&gt;

&lt;p&gt;In most software a fourth-decimal drift is noise. In greenhouse gas accounting it's a problem for one specific reason: &lt;strong&gt;your numbers get re-run by someone else.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An assurance provider performing limited or reasonable assurance will rebuild your total from the underlying activity data. If their sum and your disclosed sum disagree — even slightly, even for a reason as defensible as floating-point order — that's a discrepancy someone has to explain. Reproducibility isn't a nice-to-have in this context; non-reproducibility is an audit finding.&lt;/p&gt;

&lt;p&gt;And inventories are exactly the shape that triggers the problem: a handful of very large line items (grid electricity for a head office, a corporate vehicle fleet) sitting in the same sum as thousands of tiny ones (individual business trips, small fugitive losses). Large magnitude spread, high row count — the two conditions that make naive summation drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: compensated summation
&lt;/h2&gt;

&lt;p&gt;Kahan summation (also called compensated summation) keeps a second variable that tracks the low-order bits naive addition throws away, and feeds them back in on the next iteration.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;kahanSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// running compensation for lost low-order bits&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;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;values&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// bring back what we lost last time&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// the lossy addition&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// recover exactly what got dropped&lt;/span&gt;
    &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&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="nx"&gt;sum&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 trick is the third line. &lt;code&gt;(t - sum)&lt;/code&gt; recovers the part of &lt;code&gt;y&lt;/code&gt; that actually made it into the sum; subtracting &lt;code&gt;y&lt;/code&gt; leaves the part that &lt;em&gt;didn't&lt;/em&gt; — the error term. You carry that error into the next iteration and add it back. The compensation &lt;code&gt;c&lt;/code&gt; is, in effect, the bits that fell off the mantissa, saved for next time.&lt;/p&gt;

&lt;p&gt;Run the earlier example through &lt;code&gt;kahanSum&lt;/code&gt; and both orderings return the same total, accurate to the precision you'd expect from the inputs.&lt;/p&gt;

&lt;p&gt;It's not magic — it can't manufacture precision the inputs never had — but it removes the order-dependence and recovers the low-order bits that naive summation discards. For an inventory total that someone else is going to re-derive, that order-independence is the property you actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is overkill
&lt;/h2&gt;

&lt;p&gt;Most of the time you don't need this. The drift only becomes material when &lt;strong&gt;row count and magnitude spread are both high&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A few dozen rows of similar magnitude:&lt;/strong&gt; don't bother. The error is far below any rounding you'd disclose at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thousands of rows, similar magnitude:&lt;/strong&gt; borderline. Usually fine, but cheap to be safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thousands of rows with a wide magnitude spread&lt;/strong&gt; (large facility totals summed alongside tiny per-trip lines): use compensated summation. This is the inventory case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rough mental threshold: if your largest line item is several orders of magnitude bigger than your smallest, and you have enough small lines that their collective contribution matters, naive summation can lose a material slice of the small-line total. That's most real corporate inventories.&lt;/p&gt;

&lt;p&gt;If you want to avoid the question entirely, you can sum in fixed-point — work in integer grams or integer kg CO₂e instead of fractional tonnes — and convert only at display time. That sidesteps float drift at the cost of carrying integers around. Compensated summation is the less invasive change if you're already float-based.&lt;/p&gt;

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

&lt;p&gt;Most carbon calculators never hit this, and that's exactly the problem. They're tested against toy datasets — a dozen rows, a worked example from a guidance document — where naive summation is indistinguishable from compensated. The bug doesn't exist at demo scale. It surfaces at real inventory scale, in production, in the one number a company actually discloses, and it surfaces as an unexplained discrepancy in someone else's spreadsheet.&lt;/p&gt;

&lt;p&gt;If you're building anything that sums emissions data for disclosure, this is worth eight lines of defensive code. I build &lt;a href="https://greencalculus.com/calculators/" rel="noopener noreferrer"&gt;carbon calculators where the methodology and provenance are the whole point&lt;/a&gt;, and order-independent summation is one of those things that costs almost nothing to get right and is genuinely awkward to explain after the fact.&lt;/p&gt;

&lt;p&gt;Sort your rows however you like. The total shouldn't care.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>carbon</category>
    </item>
    <item>
      <title>I Built a Carbon Calculator Where the Emission Factor Doesn't Exist</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:37:36 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/i-built-a-carbon-calculator-where-the-emission-factor-doesnt-exist-2og3</link>
      <guid>https://dev.to/jeremiah_say/i-built-a-carbon-calculator-where-the-emission-factor-doesnt-exist-2og3</guid>
      <description>&lt;p&gt;Most emissions calculators are a lookup table with a UI bolted on. You pick "diesel," the code fetches &lt;code&gt;2.51 kgCO₂e/litre&lt;/code&gt; from a factor table, multiplies by your input, done. I've built about a thousand of these. The refrigerant one broke the pattern, and the reason it broke is a small idea that's worth sharing if you ever model physical systems in code.&lt;/p&gt;

&lt;p&gt;For refrigerant leakage, &lt;strong&gt;there is no emission factor to look up.&lt;/strong&gt; The factor &lt;em&gt;is&lt;/em&gt; the global warming potential of the gas itself.&lt;/p&gt;

&lt;p&gt;Let me explain what that means and why it changed how I structured the data layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second domain primer
&lt;/h2&gt;

&lt;p&gt;When a chiller or supermarket fridge leaks, the refrigerant gas escapes to atmosphere. Different gases trap different amounts of heat. We normalise that to &lt;strong&gt;GWP&lt;/strong&gt; — Global Warming Potential — the warming caused by 1 kg of the gas relative to 1 kg of CO₂ over 100 years.&lt;/p&gt;

&lt;p&gt;CO₂ has a GWP of 1. R-410A, a common air-conditioning refrigerant, has a GWP of &lt;strong&gt;2,256&lt;/strong&gt;. So one leaked kilogram of R-410A is climate-equivalent to 2,256 kg of CO₂ [source: IPCC AR6, WG1 Ch7]. That multiplier is the entire story.&lt;/p&gt;

&lt;p&gt;The calculation is almost insultingly simple:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refrigerantEmissions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chargeKg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leakRatePct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gwp100&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;leakedMass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chargeKg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leakRatePct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// kg of gas&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;leakedMass&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;gwp100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                         &lt;span class="c1"&gt;// kg CO₂e&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No factor table. No unit conversion gymnastics. Three numbers. So why did this one take longer to build than the combustion calculators that &lt;em&gt;do&lt;/em&gt; need factor tables?&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual hard part: the factor is a moving target with two valid values at once
&lt;/h2&gt;

&lt;p&gt;Here's the trap. The IPCC publishes GWP values, and &lt;strong&gt;it revises them.&lt;/strong&gt; AR5 (2014) said R-143a had a GWP of 4,470. AR6 (2021) says 5,810. That's a &lt;strong&gt;+30% shift in the answer&lt;/strong&gt; for the exact same physical leak [source: IPCC AR5 / AR6 WG1].&lt;/p&gt;

&lt;p&gt;And — this is the part that breaks naive data modelling — &lt;strong&gt;both values are still legally correct right now&lt;/strong&gt;, depending on who's asking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EU CSRD reporting → AR6, mandatory.&lt;/li&gt;
&lt;li&gt;A 2019 baseline you set under AR5 → still AR5 until you rebaseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the factor isn't a constant. It's a function of &lt;code&gt;(gas, assessment_report)&lt;/code&gt;. My first instinct — &lt;code&gt;refrigerants["R-410A"].gwp&lt;/code&gt; — was wrong, because there is no single &lt;code&gt;.gwp&lt;/code&gt;. There are two, and the &lt;em&gt;correct&lt;/em&gt; one is a property of the user's reporting context, not the gas.&lt;/p&gt;

&lt;p&gt;The data shape that actually works:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refrigerants&lt;/span&gt; &lt;span class="o"&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;R-410A&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;R-410A — Building / split AC (HFC blend)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;gwp100&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ar6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ar5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2088&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// both live, caller picks&lt;/span&gt;
    &lt;span class="na"&gt;fgasThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                 &lt;span class="c1"&gt;// GWP ≥ 2,500? (regulatory flag)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...50 more&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalises well beyond carbon: &lt;strong&gt;when your source of truth revises itself but the old version stays valid, the version is part of the key, not metadata you can flatten away.&lt;/strong&gt; I've since refactored other calculators the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift is not noise — and that's a UX decision, not a data one
&lt;/h2&gt;

&lt;p&gt;For most emission categories, AR5 vs AR6 is sub-1% — genuinely ignorable. For refrigerants it isn't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refrigerant&lt;/th&gt;
&lt;th&gt;AR5 GWP-100&lt;/th&gt;
&lt;th&gt;AR6 GWP-100&lt;/th&gt;
&lt;th&gt;Drift&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R-32&lt;/td&gt;
&lt;td&gt;675&lt;/td&gt;
&lt;td&gt;771&lt;/td&gt;
&lt;td&gt;+14.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R-134a&lt;/td&gt;
&lt;td&gt;1,430&lt;/td&gt;
&lt;td&gt;1,526&lt;/td&gt;
&lt;td&gt;+6.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R-143a&lt;/td&gt;
&lt;td&gt;4,470&lt;/td&gt;
&lt;td&gt;5,810&lt;/td&gt;
&lt;td&gt;+30.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R-410A&lt;/td&gt;
&lt;td&gt;2,088&lt;/td&gt;
&lt;td&gt;2,256&lt;/td&gt;
&lt;td&gt;+8.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R-404A&lt;/td&gt;
&lt;td&gt;3,922&lt;/td&gt;
&lt;td&gt;4,170&lt;/td&gt;
&lt;td&gt;+6.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Source: IPCC AR5 (WG1 Ch8) and AR6 (WG1 Ch7) GWP-100 values.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A naive build just exposes a toggle and lets the number silently jump 30%. I think that's a quiet way to mislead a user who doesn't know the chemistry changed under them. So the engine computes the drift and surfaces it inline whenever &lt;code&gt;|drift| ≥ 3%&lt;/code&gt; — the user sees &lt;em&gt;"switching to AR6 adds X% on your gas mix"&lt;/em&gt; before they export anything.&lt;/p&gt;

&lt;p&gt;That's a small rule, but it encodes a position I'll defend: &lt;strong&gt;a calculator that can produce two different right answers has a duty to tell you why they differ.&lt;/strong&gt; Hiding it behind a toggle is technically correct and practically dishonest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak rate is a default, and defaults are a trust contract
&lt;/h2&gt;

&lt;p&gt;The other input — annual leak rate — comes from IPCC Tier 1 defaults by equipment class (Table 7.9 of the 2019 Refinement). Transport refrigeration's published range is &lt;strong&gt;15% to 50%&lt;/strong&gt;. That's enormous. I store the mid-range as the preset but treat it as explicitly provisional:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;equipmentDefaults&lt;/span&gt; &lt;span class="o"&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;transport-refrigeration&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;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&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;supermarket&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;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;35&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;chillers&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;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&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 engine never lets a default masquerade as a measurement. A measured, documented rate always overrides the preset, and the audit export records &lt;em&gt;which&lt;/em&gt; basis was used. If you're building anything where users will be audited on your output, the single most valuable feature is &lt;strong&gt;provenance on every number&lt;/strong&gt; — what it was, where it came from, and whether it was assumed or measured. That's worth more than any chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;The full thing — all 51 refrigerants, the AR5/AR6 engine, the F-Gas regulatory flags, and an audit-mode export that hashes every result back to its source — is live on GreenCalculus. If you want to see the moving-target factor model in the wild, the &lt;a href="https://greencalculus.com/calculators/scope-1-refrigerant-leakage-calculator/" rel="noopener noreferrer"&gt;Scope 1 refrigerant leakage calculator on GreenCalculus&lt;/a&gt; is the cleanest example of the pattern.&lt;/p&gt;

&lt;p&gt;If you model physical or regulatory systems in code, the takeaway I'd leave you with: &lt;strong&gt;the hard part is rarely the arithmetic. It's that your authoritative source is itself versioned, sometimes contradictory, and always more nuanced than a single number.&lt;/strong&gt; Build for that from day one and the refactor never comes.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>datascience</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The "Audit Trail" Pattern: Architecture for Immutable Sustainability Data</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Fri, 29 May 2026 03:06:36 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/the-audit-trail-pattern-architecture-for-immutable-sustainability-data-1lf</link>
      <guid>https://dev.to/jeremiah_say/the-audit-trail-pattern-architecture-for-immutable-sustainability-data-1lf</guid>
      <description>&lt;p&gt;In standard CRUD apps, if a user updates their profile name, you overwrite the database row. No big deal. But in &lt;strong&gt;&lt;a href="https://dev.to/jeremiah_say/csrdesrs-e1-disclosure-requirements-translated-into-data-fields-a-developers-map-4i4g"&gt;CSRD (Corporate Sustainability Reporting Directive)&lt;/a&gt;&lt;/strong&gt; software, overwriting a row is a compliance nightmare.&lt;/p&gt;

&lt;p&gt;If an auditor asks, &lt;em&gt;"Why did your Scope 1 emissions for 2024 change between July and October?"&lt;/em&gt; and your answer is &lt;em&gt;"A dev ran a migration that updated the emission factors,"&lt;/em&gt; you've just failed the audit.&lt;/p&gt;

&lt;p&gt;To build a compliant carbon engine, you have to move away from "Current State" databases and toward &lt;strong&gt;Immutable Data Architectures&lt;/strong&gt;. This is the pattern I use at &lt;a href="https://greencalculus.com" rel="noopener noreferrer"&gt;GreenCalculus.com&lt;/a&gt; to ensure that once a report is "frozen," the data stays frozen—even if the underlying code or emission factors change.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: The "Floating" Emission Factor
&lt;/h2&gt;

&lt;p&gt;Most carbon calculations look like this in the backend:&lt;/p&gt;

&lt;p&gt;$$E = A \times EF$$&lt;/p&gt;

&lt;p&gt;Where $E$ is emissions ($tCO_2e$), $A$ is activity data (e.g., liters of diesel), and $EF$ is the Emission Factor.&lt;/p&gt;

&lt;p&gt;The mistake is storing only $A$ and looking up $EF$ at runtime. Emission factor sets (like DEFRA or IEA) are updated annually. If you update your &lt;code&gt;factors&lt;/code&gt; table to the 2026 set, your 2024 calculations will suddenly change because the runtime lookup finds a new value. This is called &lt;strong&gt;Data Drift&lt;/strong&gt;, and it's an auditor's biggest red flag.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: The Snapshot Pattern
&lt;/h2&gt;

&lt;p&gt;Instead of linking to a factor, you must &lt;strong&gt;persist the snapshot&lt;/strong&gt; of the calculation at the moment it occurred. You don't just store the result; you store the "receipt" of the math.&lt;/p&gt;

&lt;h3&gt;
  
  
  The JSON Data Structure
&lt;/h3&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;"activity_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fuel_log_789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"calculated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-29T10:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"liters"&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;"snapshot"&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;"factor_used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.6765&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"factor_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEFRA_2025"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gwp_basis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AR6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"formula_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v2.1.0"&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;"output_tco2e"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.33825&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checksum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4..."&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;
  
  
  Implementation: The Three-Tier Storage Strategy
&lt;/h2&gt;

&lt;p&gt;To achieve this in a standard PHP/JavaScript stack, you need to separate your raw data from your calculated disclosures.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Activity Ledger (Source of Truth)
&lt;/h3&gt;

&lt;p&gt;The ledger stores the raw evidence (e.g., "Invoice #123: 500L Diesel"). Once an entry is verified, it becomes &lt;strong&gt;Append-Only&lt;/strong&gt;. Any corrections are handled via "reversal entries" (credit/debit style), exactly like a financial ledger.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Factor Registry (The Library)
&lt;/h3&gt;

&lt;p&gt;Never delete or &lt;code&gt;UPDATE&lt;/code&gt; an old emission factor. Your database schema should treat factors as immutable versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;emission_factors&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- e.g. "Diesel (Average Biofuel Blend)"&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version_start&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version_end&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="c1"&gt;-- Null if current&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The Calculation Archive (The Receipt)
&lt;/h3&gt;

&lt;p&gt;This table stores every "point-in-time" result. When an auditor asks for the numbers, you query this table, &lt;strong&gt;not&lt;/strong&gt; a live calculation function.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Code: A PHP/Laravel Implementation
&lt;/h2&gt;

&lt;p&gt;When a calculation is triggered, we don't just return a number; we return a &lt;code&gt;CalculationReceipt&lt;/code&gt; object that contains the entire context, including a hash of the code version used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CarbonCalculator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Activity&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CalculationReceipt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Find the factor that was active at the time the fuel was consumed&lt;/span&gt;
        &lt;span class="nv"&gt;$factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFactorFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;consumed_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$factor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// We store the literal values used, NOT just a foreign key&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CalculationReceipt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'activity_id'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tco2e'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'meta_snapshot'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'factor_name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$factor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'factor_value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$factor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'gwp_basis'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'AR6'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'logic_hash'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;hash_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'md5'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Proves which code version ran this&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'is_frozen'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Audit-Proofing the Frontend
&lt;/h2&gt;

&lt;p&gt;When a user views their "2025 Inventory," the frontend should never trigger a fresh calculation. It should fetch the &lt;strong&gt;stored receipts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;However, if a methodology changes (e.g., the EU updates the ESRS E1 guidance), you can run a "What-If" check. If the system detects that a factor has been updated since the receipt was generated, you display a &lt;strong&gt;Discrepancy Warning&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Methodology Change:&lt;/strong&gt; A newer emission factor (DEFRA 2026) is available. Your current report uses DEFRA 2025. [Recalculate and Re-verify?]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This puts the power in the hands of the sustainability officer to decide when to "re-state" their figures—a formal requirement in CSRD reporting.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this architecture prevents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silent Drifts:&lt;/strong&gt; You avoid the "Why is our total different than it was yesterday?" slack message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit Failure:&lt;/strong&gt; You can produce a "lineage report" for every single gram of $CO_2$ in the system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regression Bugs:&lt;/strong&gt; A bug introduced in a new feature won't accidentally corrupt historical data because historical data is physically separated from the calculation engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Golden Rule for CSRD Devs:&lt;/strong&gt; In carbon accounting, an "edited" record is a liability. Build for the trail, not just the total.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building the data integrity layer for &lt;a href="https://greencalculus.com" rel="noopener noreferrer"&gt;GreenCalculus.com&lt;/a&gt;. If you're working on CSRD or ESRS compliance, let's swap notes in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>sustainability</category>
      <category>database</category>
    </item>
    <item>
      <title>Floating-point will quietly corrupt your emissions math, and 0.1 + 0.2 already warned you</title>
      <dc:creator>Jeremiah Say</dc:creator>
      <pubDate>Sat, 23 May 2026 00:04:20 +0000</pubDate>
      <link>https://dev.to/jeremiah_say/floating-point-will-quietly-corrupt-your-emissions-math-and-01-02-already-warned-you-g2</link>
      <guid>https://dev.to/jeremiah_say/floating-point-will-quietly-corrupt-your-emissions-math-and-01-02-already-warned-you-g2</guid>
      <description>&lt;p&gt;Every developer has seen this:&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="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
&lt;span class="c1"&gt;// 0.30000000000000004&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the most-shared bug in programming. You learn it, you nod, you move on — because in most code the error is so far down the decimal that nothing notices. A pixel is off by a billionth. A timer fires a nanosecond late. Nobody files a ticket.&lt;/p&gt;

&lt;p&gt;Then you build a system whose entire job is to add thousands of numbers together and produce a total that has to match a figure someone calculated by hand. And the rounding error you waved away in the tutorial becomes a line in an audit report that says: &lt;em&gt;the system total does not reconcile to the source data.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's where I met this bug for real, building the calculation layer at &lt;a href="https://greencalculus.com" rel="noopener noreferrer"&gt;GreenCalculus.com&lt;/a&gt;. It runs over a thousand emissions calculators, and every one of them has the same hard requirement: the number the engine produces must reconcile, to the last reported digit, against the number a practitioner gets when they check the math themselves. "Close enough" is a finding. This post is the set of rules that get you from &lt;em&gt;the math looks right&lt;/em&gt; to &lt;em&gt;the math reconciles&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this bites harder in measurement code than anywhere else
&lt;/h2&gt;

&lt;p&gt;Most software treats numbers as means to an end — coordinates, counters, ratios feeding a render. The exact value rarely &lt;em&gt;is&lt;/em&gt; the deliverable.&lt;/p&gt;

&lt;p&gt;In emissions accounting, the number &lt;strong&gt;is&lt;/strong&gt; the deliverable. A reported figure of &lt;code&gt;1,240.37 tCO2e&lt;/code&gt; is the product. If an auditor recomputes it and gets &lt;code&gt;1,240.41&lt;/code&gt;, the 0.04 isn't noise — it's an unexplained discrepancy, and unexplained discrepancies are exactly what an audit exists to surface. You don't get to say "floating-point." You get to fix your data model.&lt;/p&gt;

&lt;p&gt;Three properties of this domain turn a harmless rounding error into a real defect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Massive summation.&lt;/strong&gt; A Scope 3 inventory adds tens of thousands of line items. Error accumulates with count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wide dynamic range.&lt;/strong&gt; You add a 4,000,000 kg figure to a 0.002 kg figure in the same total. This is precisely the case floating-point handles worst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External reconciliation.&lt;/strong&gt; The output is checked against an independent calculation. There's no hiding internal drift, because someone outside your system recomputes it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The float problem, stated precisely
&lt;/h2&gt;

&lt;p&gt;A 64-bit IEEE 754 float (&lt;code&gt;double&lt;/code&gt;, JavaScript's only number type) stores 52 bits of mantissa — about 15–17 significant decimal digits. &lt;code&gt;0.1&lt;/code&gt;, &lt;code&gt;0.2&lt;/code&gt;, and &lt;code&gt;0.3&lt;/code&gt; are not representable exactly in binary, the same way &lt;code&gt;1/3&lt;/code&gt; isn't representable exactly in decimal. They're stored as the nearest available binary fraction, and the tiny errors survive arithmetic.&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="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// "0.30000000000000004441"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a single operation the error is ~10⁻¹⁷. The problem is what happens when you do the operation 50,000 times, and when the operands differ in magnitude by a factor of a billion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure mode 1: the total that won't reconcile
&lt;/h2&gt;

&lt;p&gt;Naive summation of a long list of factors:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;totalEmissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lineItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// each term carries float error&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&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;Each &lt;code&gt;activity * item.factor&lt;/code&gt; lands on the nearest representable double, slightly off. Each &lt;code&gt;+=&lt;/code&gt; rounds again. Over tens of thousands of items the errors are &lt;em&gt;mostly&lt;/em&gt; random and partly cancel — but "mostly" is not "exactly," and the residual is deterministic for a given input order. Run the same data through a spreadsheet that sums in a different order and you get a different last digit. Now you have two "correct" totals that disagree, and no way to say which is right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: control precision at the boundary, not in the loop
&lt;/h3&gt;

&lt;p&gt;Two defensible strategies, depending on stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decimal arithmetic for the money-and-mass path.&lt;/strong&gt; Store and sum factors as arbitrary-precision decimals so there's no binary-representation error at all:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Decimal&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decimal.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;totalEmissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lineItems&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="nx"&gt;lineItems&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&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;acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Decimal&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// convert once, at the very end, after rounding regime applied&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decimal is slower. You don't need it everywhere — you need it on the path whose output is a reported figure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kahan summation when you must stay in floats.&lt;/strong&gt; If the hot path can't afford Decimal, compensated summation recovers most of the lost low-order bits by tracking the rounding error and feeding it back in:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;kahanSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;compensation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// running error term&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;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;values&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;compensation&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;compensation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// recovers what the addition just dropped&lt;/span&gt;
    &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&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="nx"&gt;sum&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 single addition &lt;code&gt;sum + y&lt;/code&gt; loses the low bits of &lt;code&gt;y&lt;/code&gt; when &lt;code&gt;sum&lt;/code&gt; is large; the next line reconstructs exactly those bits and carries them into the following iteration. For a long sum of mixed-magnitude terms this is the difference between a stable total and one that drifts with input order.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure mode 2: the wide-magnitude swallow
&lt;/h2&gt;

&lt;p&gt;This is the one that produces a &lt;em&gt;wrong&lt;/em&gt; number, not just an unstable one.&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000_000&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="c1"&gt;// a large facility's annual figure, kg&lt;/span&gt;
&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.002&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// a trace fugitive line, kg&lt;/span&gt;
&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;002&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// true here — but stack enough trace lines...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the accumulator is large and the addend is tiny, the addend's significant bits fall off the bottom of the mantissa and are silently dropped. One trace line is fine. Ten thousand of them, each individually swallowed, is a missing category total. The sum &lt;em&gt;looks&lt;/em&gt; authoritative — it's a clean large number — which is exactly why nobody catches it by eye.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: sum within magnitude bands, then combine
&lt;/h3&gt;

&lt;p&gt;Add small things to small things before adding them to big things. Sort ascending and the smallest terms accumulate among themselves into a magnitude the large accumulator can actually absorb:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bandedSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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;ascending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;kahanSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// small-first ordering + compensation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorting ascending before a compensated sum is a cheap, robust default for "many small, few large." It won't matter on uniform data — but reference-data sums are rarely uniform.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure mode 3: the ratio that amplifies
&lt;/h2&gt;

&lt;p&gt;Intensity metrics divide one computed figure by another — emissions per unit revenue, per tonne of product, per kWh. Division doesn't create error, but it &lt;em&gt;amplifies&lt;/em&gt; whatever error the operands already carry, and ratios of two nearly-equal slightly-wrong numbers are the worst case.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intensityA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total2025&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;output2025&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;intensityB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total2024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;output2024&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;yoyChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intensityA&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;intensityB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;intensityB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// tiny - tiny, over tiny&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;intensityA&lt;/code&gt; and &lt;code&gt;intensityB&lt;/code&gt; are close, their difference is dominated by the rounding error in each, and dividing that noisy difference by a small base inflates it into a visible percentage. Your year-on-year intensity change can read as ±0.3% pure artefact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: defer rounding, widen the working precision
&lt;/h3&gt;

&lt;p&gt;Carry full precision through the entire chain and round &lt;strong&gt;once&lt;/strong&gt;, at presentation. Never round an intermediate that feeds a later division. If the figures are reconciliation-critical, do the division in Decimal too — the cost is trivial relative to one division per report.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure mode 4: the unit-boundary shave
&lt;/h2&gt;

&lt;p&gt;Every conversion is a multiply, and every multiply is a place to lose digits. The g → kg → t chain looks innocent:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1234567&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;kg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grams&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 1234.567&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tonnes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 1.234567&lt;/span&gt;
&lt;span class="c1"&gt;// round-trip back up and compare:&lt;/span&gt;
&lt;span class="nx"&gt;tonnes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;grams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not guaranteed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each division lands on the nearest double; chaining them compounds the drift, and a value that round-trips through three units may not come back to where it started. The fix is the same architectural rule that governs the whole post: &lt;strong&gt;store in one canonical unit at full precision, convert only at the display boundary.&lt;/strong&gt; Don't persist the converted value and re-derive from it — persist the canonical value and convert on read.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule underneath all four
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same decision made wrong: &lt;em&gt;where does precision get fixed, and how many times.&lt;/em&gt; The failures all come from rounding early, rounding repeatedly, or rounding in the middle of a chain that isn't done yet.&lt;/p&gt;

&lt;p&gt;The architecture that prevents all four:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One canonical unit, full precision, in storage.&lt;/strong&gt; No pre-rounded, pre-converted values persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A declared rounding regime, applied once, at the boundary.&lt;/strong&gt; Half-up, half-even — pick one, document it, apply it where the number leaves the system, never before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decimal on any path whose output is a reported figure.&lt;/strong&gt; Floats are fine for charts and progress bars. They are not fine for the deliverable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compensated, magnitude-aware summation&lt;/strong&gt; wherever you must aggregate many terms in floating point.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the same shape as a rule we apply to every figure in the GreenCalculus data layer: a number is meaningless without its declared basis. There, the basis is the GWP version and the emission-factor source. Here, it's the unit and the rounding regime. In both cases the discipline is identical — &lt;em&gt;the number is not just its digits; it's its digits plus the declared rules that produced them.&lt;/em&gt; Store the rules with the number, or you can't defend the number.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.1 + 0.2&lt;/code&gt; was never the bug. It was the warning that your numbers carry rules you haven't written down yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The GreenCalculus calculation layer applies these rules across 1,000+ emissions calculators, every one of which has to reconcile against its source data to the last reported digit. Methodology documentation at &lt;a href="https://greencalculus.com" rel="noopener noreferrer"&gt;greencalculus.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>math</category>
      <category>carbon</category>
    </item>
  </channel>
</rss>
