<?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: JonathanSolvesProblems</title>
    <description>The latest articles on DEV Community by JonathanSolvesProblems (@jonathansolvesstuff).</description>
    <link>https://dev.to/jonathansolvesstuff</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%2F4025240%2F2f4d8cfd-9eac-49b2-8c29-fb49f8c09125.jpg</url>
      <title>DEV Community: JonathanSolvesProblems</title>
      <link>https://dev.to/jonathansolvesstuff</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonathansolvesstuff"/>
    <language>en</language>
    <item>
      <title>Spare Thumbs: Gemma 4 answers "can we move the meeting?" in my words, from my real calendar, and never double-books me</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:35:29 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/spare-thumbs-gemma-4-answers-can-we-move-the-meeting-in-my-words-from-my-real-calendar-and-4p52</link>
      <guid>https://dev.to/jonathansolvesstuff/spare-thumbs-gemma-4-answers-can-we-move-the-meeting-in-my-words-from-my-real-calendar-and-4p52</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge, Week 3: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Clients move meetings at night. "Something came up Thursday, could we do Friday 10h instead?" To answer, I open my calendar, find a gap, and type the same reply I always type. It happens about once a week, across email, WhatsApp and Discord.&lt;/p&gt;

&lt;p&gt;Spare Thumbs answers for me. Gemma 4 runs on my laptop and watches my Gmail. When a client asks to move a meeting, it finds the meeting, checks my real Google Calendar, and leaves a reply in the Gmail thread as a draft, written the way I write. If their time is free, it says yes. If not, it offers times I am actually free. When the client says "11h works", it moves the event in Google Calendar and drafts the confirmation.&lt;/p&gt;

&lt;p&gt;The lazy part: I tap send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/ls6LhVsdbxI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;In the video, a second address of mine plays a client called Sam. Its two messages were placed in my inbox with the Gmail API (I could not send from that account while filming). Everything after that is live: Gemma's steps in the terminal, the draft in the thread, the send, and the calendar move. The calendar is my real one: Friday 10h is a real call about a scratched tile, so Gemma has to work around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Gemma 4 on its own is happy to agree to anything. I gave it ten "can we move it?" requests against a busy week, once without my calendar and once as Spare Thumbs:&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;Drafts that offer a time I am already booked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 without my calendar&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7 of 10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spare Thumbs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same voice examples, same requests. The only difference is that Spare Thumbs looks before it answers, and the code checks every time it writes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The client asked&lt;/th&gt;
&lt;th&gt;Gemma 4 without my calendar&lt;/th&gt;
&lt;th&gt;Spare Thumbs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;move our 10h to 13h?&lt;/td&gt;
&lt;td&gt;"no problem at all, 13h on monday works for me! thx :)" (13h is the insurance assessor)&lt;/td&gt;
&lt;td&gt;"i can't do 13h cuz i have another meeting then, but i'm free at 14h30 or 15h."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Friday 11h works for me, does it for you?&lt;/td&gt;
&lt;td&gt;"friday 11h works for me! perfect. thx" (11h is the accountant)&lt;/td&gt;
&lt;td&gt;"sorry, 11h is taken for me. how about 10h or 14h on friday instead?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wednesday 14h instead?&lt;/td&gt;
&lt;td&gt;"wednesday at 14h is good. thx!" (on site all afternoon)&lt;/td&gt;
&lt;td&gt;"14h on wednesday is booked for me, sorry! how about 11h or 17h instead?"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 decides every step.&lt;/strong&gt; &lt;code&gt;gemma4:e4b&lt;/code&gt; runs through Ollama with native function calling and five tools: &lt;code&gt;find_meetings&lt;/code&gt;, &lt;code&gt;check_calendar&lt;/code&gt;, &lt;code&gt;draft_reply&lt;/code&gt;, &lt;code&gt;move_event&lt;/code&gt; and &lt;code&gt;hand_off&lt;/code&gt;. For each new message it chooses which day to check, reads what is booked, then accepts, offers other times, asks one question, or leaves the message for me. When the client replies, the same loop runs again with what it offered earlier, and it chooses to move the event and confirm. The loop ends when Gemma stops calling tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code makes sure.&lt;/strong&gt; The prompt asks Gemma to only offer free times, but a prompt cannot promise anything. So &lt;code&gt;draft_reply&lt;/code&gt; and &lt;code&gt;move_event&lt;/code&gt; refuse any time that overlaps my calendar, falls on a weekend, sits outside 9h to 18h, or is in the past. They also refuse a time written in the reply that was never passed in to be checked (an early version wrote "17h" in the text and skipped the check), and French words in a reply to an English message. A refusal goes back to Gemma as an error and it tries again. In the ten-request test, the gate caught one busy slot and one French slip before they ever reached a draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EmbeddingGemma gives it my voice.&lt;/strong&gt; I have 63 messages of my own across WhatsApp and Discord, which is too few to fine-tune on and plenty to retrieve from. For each incoming message, EmbeddingGemma finds the five replies I wrote to the most similar messages, and Gemma 4 sees them as examples, along with a short style card it wrote from all of my replies. Every reply I send gets added to the examples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing leaves my laptop&lt;/strong&gt; except the draft itself, which goes into my own Gmail. Both models run locally. Raw chats, tokens and examples live in a folder outside the repo, and passwords, emails, links, phone numbers, names and company names are redacted before anything reaches a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it sound like me?
&lt;/h2&gt;

&lt;p&gt;I did not want the result to wait on friends filling in a form, so a model from another family played the friend. Qwen 3.5 (4B) read 8 of my real messages, then saw a message, my real reply, Gemma's reply with my examples, and Gemma's reply without them, and picked the one I wrote. Each of 15 held-out messages was asked in all 6 orders, so a judge that likes "A" gains nothing.&lt;/p&gt;

&lt;p&gt;It picked Spare Thumbs as me 56% of the time, my real reply 33%, and plain Gemma 11%. Turning off my examples and the style card drops it from 56% to 11%.&lt;/p&gt;

&lt;p&gt;A second judge, Qwen 2.5 (7B), disagreed: it picked plain Gemma 53% of the time, Spare Thumbs 36%, and my real reply only 11%. It prefers the polished AI reply over my own messages, so it cannot recognise how I write. I am reporting it anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;My clients' messages are my clients' business. The reason I can run this at all is that Gemma 4 and EmbeddingGemma are open weights I can run on my own laptop: the mail, the calendar and my 63 real replies never go to a hosted model. Open weights also make the gate possible. I can wrap a model in code that refuses, retries and logs every step, and show the loop in a 100-line file instead of a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;A reschedule used to cost me about five minutes: open the calendar, find a gap, type the reply, then come back for the confirmation and move the event. Now it costs one tap on Send. It ran this week on my real inbox and calendar: the demo above is the first live run, with no retake on the agent's side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma: Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) runs the agent loop through native function calling, and EmbeddingGemma retrieves the voice examples. Both run locally through Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It only drafts. Nothing reaches a client until I press send, on purpose.&lt;/li&gt;
&lt;li&gt;It answers in Gmail and Google Calendar only. My WhatsApp and Discord messages taught it my voice, but it does not answer there yet.&lt;/li&gt;
&lt;li&gt;It only answers senders on an allow list.&lt;/li&gt;
&lt;li&gt;The ten-request test runs on a made-up busy week, so the numbers are repeatable. The demo runs on my real calendar.&lt;/li&gt;
&lt;li&gt;The voice judge is a model, not people who know me.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/spare-thumbs" rel="noopener noreferrer"&gt;
        spare-thumbs
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Gemma 4 on my laptop answers 'can we move the meeting?' in my words, from my real Google Calendar, and never offers a time I'm booked.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Spare Thumbs&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Clients move meetings at night. Spare Thumbs answers "can we move the meeting?" for me, in my words, from my real calendar.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Demo video: &lt;a href="https://www.youtube.com/watch?v=ls6LhVsdbxI" rel="nofollow noopener noreferrer"&gt;https://www.youtube.com/watch?v=ls6LhVsdbxI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Write-up: &lt;a href="https://jonathanandrei.com/blog/spare-thumbs-gemma-4-reschedule-replies-real-calendar/" rel="nofollow noopener noreferrer"&gt;https://jonathanandrei.com/blog/spare-thumbs-gemma-4-reschedule-replies-real-calendar/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Gemma 4 runs on my laptop through Ollama and watches my Gmail. When a client asks to move a meeting, it finds the meeting, checks my Google Calendar, and leaves a reply as a draft in the Gmail thread. If their time is free it says yes; if not, it offers times I am actually free. When the client confirms, it moves the event in Google Calendar and drafts the confirmation. I tap send.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Results&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Ten reschedule requests against a busy week, answered twice by Gemma 4 with my voice examples on:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drafts that offer a time I am already booked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 without my calendar&lt;/td&gt;
&lt;td&gt;7 of 10 (11 of 14 times offered)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spare Thumbs&lt;/td&gt;
&lt;td&gt;0 of 10 (0 of&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/spare-thumbs" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The longer write-up is on my site: &lt;a href="https://jonathanandrei.com/blog/spare-thumbs-gemma-4-reschedule-replies-real-calendar/" rel="noopener noreferrer"&gt;https://jonathanandrei.com/blog/spare-thumbs-gemma-4-reschedule-replies-real-calendar/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ai</category>
    </item>
    <item>
      <title>A $1.35 fine-tune taught an open 4B model that CRA is ARC in French. It still writes NIS for SIN.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Fri, 16 Oct 2026 21:20:31 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/a-135-fine-tune-taught-an-open-4b-model-that-cra-is-arc-in-french-it-still-writes-nis-for-sin-39e5</link>
      <guid>https://dev.to/jonathansolvesstuff/a-135-fine-tune-taught-an-open-4b-model-that-cra-is-arc-in-french-it-still-writes-nis-for-sin-39e5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge, Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Every page on my site has a French twin. I build things on the side (send me a brief, get a working demo back in 48 hours), and the site ships in English and fr-CA, so every new page means a translation.&lt;/p&gt;

&lt;p&gt;So I gave an open 4B model a line any Canadian small-business page would have: "you need a business number (BN) from the CRA". It wrote "auprès du RRCQ". There is no RRCQ. The Canada Revenue Agency is the ARC in French, and in Canada's open data the government's translators wrote ARC all 31 times the pair shows up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F533c12sb4gh3sj59pxdi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F533c12sb4gh3sj59pxdi.png" alt="The light table: the English sentence, the untouched model's sheet with RRCQ circled, the fine-tuned sheet reading de l'ARC" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The hero is recorded output from the deployed models, not a mock-up.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Who pays: a small business pays a bilingual freelancer per page to put its site into French. The client won't read the French closely. They will notice CRA where it should say ARC. With an open model on the freelancer's own laptop there's no per-word bill, and the client's text never leaves the machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sans calque&lt;/strong&gt; is the tool for that freelancer. Paste English, get French from a model I fine-tuned for $1.35 on government text, next to the untouched base model, plus a review list. It flags three things, and each flag says where its rule came from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an English acronym left in the French&lt;/li&gt;
&lt;li&gt;a missing French acronym the government uses (ARC for CRA, NAS for SIN), from 466 pairs learned from open data&lt;/li&gt;
&lt;li&gt;a term the Translation Bureau's Termium Plus says to avoid, like "année fiscale" for fiscal year (it wants "exercice financier"). On "most small businesses use the calendar year as their fiscal year", the untouched model writes année fiscale and the fine-tune writes année financière. Both models write "comptes à payer" for accounts payable, which Termium also flags, and the check catches it on both&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing gets fixed silently. A flagged sentence lands on the review list, and the "French, ready to send" box tells you how many blocks to check first.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Try it: &lt;strong&gt;&lt;a href="https://sans-calque.51-161-82-166.sslip.io" rel="noopener noreferrer"&gt;https://sans-calque.51-161-82-166.sslip.io&lt;/a&gt;&lt;/strong&gt; (it calls the fine-tune live on Tinker, with a daily word cap so the credit lasts). Click "Use the sample" for the sentences in this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feo301umajvtbhoss8ejw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feo301umajvtbhoss8ejw.png" alt="Results as a diff: each English line, the untouched and fine-tuned French under it, flags in the margin with their source" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The sample on the live site. Margin notes say where each rule came from.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/iObGJm1Id6Y" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/sans-calque" rel="noopener noreferrer"&gt;
        sans-calque
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      English to Canadian French on an open 4B model fine-tuned on government text; every flag cites its source
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Sans calque&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;English to Canadian French for small-business pages, on a 4-billion-parameter open model I fine-tuned for about $1.35 and can run on a laptop. Every flag it raises cites where its rule came from.&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 2: Side Hustle.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Live app: &lt;a href="https://sans-calque.51-161-82-166.sslip.io" rel="nofollow noopener noreferrer"&gt;https://sans-calque.51-161-82-166.sslip.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video (1:42): &lt;a href="https://www.youtube.com/watch?v=iObGJm1Id6Y" rel="nofollow noopener noreferrer"&gt;https://www.youtube.com/watch?v=iObGJm1Id6Y&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Write-up: &lt;a href="https://jonathanandrei.com/blog/sans-calque-canadian-french-fine-tune-tinker/" rel="nofollow noopener noreferrer"&gt;https://jonathanandrei.com/blog/sans-calque-canadian-french-fine-tune-tinker/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Paste English. You get the fine-tuned model's French next to the untouched base model's, a review list, and a "French, ready to send" box. The review list flags three things, each with its source:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An English acronym left in the French&lt;/strong&gt;, such as "CRA" in a French sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A French acronym that is missing&lt;/strong&gt;, when the government uses a known one: ARC for CRA (31 of 31 government pairs), NAS for SIN (10 of 10). The map has 466 acronyms, learned from the training pairs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A term the Translation&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/sans-calque" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;A longer write-up is on my blog: &lt;a href="https://jonathanandrei.com/blog/sans-calque-canadian-french-fine-tune-tinker/" rel="noopener noreferrer"&gt;Sans calque, a Canadian French fine-tune on Tinker&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data is all open.&lt;/strong&gt; Every dataset on Canada's Open Government Portal has its title and description in both languages, under the Open Government Licence. I pulled all 48,116 records, dropped the ones whose French the portal marks as machine-translated, and paired paragraphs and sentences only when both sides split the same way. That gave 82,455 pairs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tune is two runs on Tinker.&lt;/strong&gt; Qwen3.5-4B, LoRA rank 32, 8,000 pairs, 2 epochs, about 13 minutes each. Run 1 was a random sample. Run 2 drew 2,000 of its pairs from the 10,752 that contain a known acronym, because run 1 still wrote "de la RC" for CRA. Estimated cost: $1.26 and $1.35, from Tinker's per-token price and the real token count. The training script refuses to start if the estimate goes over budget, because the credit is a one-time $10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grading is not mine.&lt;/strong&gt; I scored everything on canada.ca small-business pages, whose French was written by Government of Canada translators: 300 random sentences, plus all 147 that touch a Termium "avoid" concept. None of those sentences were in training.&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;Gemma 3 4B&lt;/th&gt;
&lt;th&gt;Qwen3.5-4B base&lt;/th&gt;
&lt;th&gt;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Government's French acronym written correctly&lt;/td&gt;
&lt;td&gt;36.0%&lt;/td&gt;
&lt;td&gt;50.8%&lt;/td&gt;
&lt;td&gt;55.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;66.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English acronyms left in the French&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentences with a Termium "avoid" term (of 147)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chrF++&lt;/td&gt;
&lt;td&gt;50.58&lt;/td&gt;
&lt;td&gt;55.32&lt;/td&gt;
&lt;td&gt;55.65&lt;/td&gt;
&lt;td&gt;56.09&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gain is acronyms and terms, not overall quality. The acronym gain holds up: run 2 beats the base model in all 1,000 bootstrap resamples of the 146 held-out sentences that contain a government French acronym. Overall quality doesn't: run 2 beats the base model on chrF++ in 92% of 1,000 bootstrap resamples, and I had set 95% as the bar before running it, so I don't claim it. Run 2 isn't better everywhere either. It leaves more English acronyms and more Termium terms than run 1. I serve it anyway because it gets the acronyms a client notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not always right, and the base model sometimes is.&lt;/strong&gt; On "you can apply for your SIN", the base model writes "numéro d'assurance sociale (NAS)", which is correct. Run 2 writes "NIS". The acronym check catches it: the government wrote NAS in 10 of 10 pairs. Run 2 also wrote "Conserver" where an instruction needs "Conservez", and none of my checks catch that one. That's why the box says to read it before you send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The checks got checked too.&lt;/strong&gt; My first Termium check flagged the government's own translators 21 times out of 25. The label it was counting, "avoid, see observation", means avoid in &lt;em&gt;some&lt;/em&gt; contexts, so I dropped it. What's left flags the human translation once, for "année fiscale", which Termium calls a calque and canada.ca still uses. I left that one in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check_claims.py&lt;/code&gt; rebuilds every number in the README from the results files and fails if the prose and the data disagree. It also fails if a model output quoted in the docs isn't in the recorded outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Innovation Matters
&lt;/h2&gt;

&lt;p&gt;The weights are mine. I exported the fine-tune from Tinker, converted it to a 2.59 GB 4-bit file and it runs in Ollama on my laptop's 8 GB GPU at 32.5 tokens a second. Client text never has to leave the machine, and there is no per-word bill. The same web page runs against it: I loaded it with every request outside my laptop blocked, and it still translated, fonts included, with zero requests out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneeml0xr3de08wcvrvnh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneeml0xr3de08wcvrvnh.png" alt="Local mode: the header reads On this computer, through Ollama, and the same flags appear" width="799" height="306"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Local mode with every request outside the laptop blocked. Two crops of one capture: the header and the first two rows. The local untouched model invented a different acronym, RReC, and it still gets caught.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That took three dead ends: Ollama no longer accepts LoRA adapters, its own quantiser needs a Mac, and an 8-bit file ran at 0.8 tokens a second. Shrinking it to 4-bit can cost something, so I scored the laptop copies on the same test. For run 1 it did: chrF++ 54.96 against 55.65 in the cloud. For run 2 it didn't. The laptop copy scored 56.43 against 56.09 and got 67.8% of the government's acronyms right against 66.9%. Statistically those are the same model, so I'm not claiming the laptop is better, just that nothing was lost.&lt;/p&gt;

&lt;p&gt;It's narrow on purpose. The people who'd pay for this are freelancers and small agencies doing Canadian French, and me. But the recipe isn't Canadian: fine-tune on a government's own open bilingual text, then check every term against that government's term bank. Anywhere both exist (the EU's IATE term base, for one) the same scripts should apply, though I've only measured it here.&lt;/p&gt;

&lt;p&gt;And the open data is the reason any of this works. The training pairs, the terminology bank and the acronym evidence all come from the Government of Canada's open data, and the test is the same government's translators. A closed model can't show you where its "ARC" came from. This one can, down to the 31 pairs.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;Me, on my own site. jonathanandrei.com ships in English and fr-CA, so I pasted the English "How I work" page into the live app: 41 blocks.&lt;/p&gt;

&lt;p&gt;The fine-tune raised 8 flags. 7 were noise for this kind of text: MCP, LLM, RAG, FHIR, SDK and a NICE guideline code, all acronyms my own French page keeps in English. The acronym rule learned how the government writes, and tech jargon doesn't follow it.&lt;/p&gt;

&lt;p&gt;The 8th was real. My sentence says the logs held data "the AI had already claimed was PHI-free". The French came back as "lesquels avaient déjà été déclarés exempts", which means "which had already been declared free". The AI is gone from the sentence, and it was the whole point of it. The flag fired because the expected "IA" was missing, and it was missing because the model dropped the subject.&lt;/p&gt;

&lt;p&gt;So on my own page: one catch that mattered, seven to wave off. That told me what to build next, so I built it: a "keep in English" list per client, and a "Fetch the page" box so the page comes in by its address instead of by copy and paste. Same page, fetched by the app, with MCP, LLM, RAG, FHIR, SDK and the guideline code on the list: 8 flags became 1, and the one left is the real catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Use of Tinker&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Walked an Open Model's Bingo Card Through a Town With Almost No Nature Records. I Found 2 of 16.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Tue, 06 Oct 2026 19:51:00 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/i-walked-an-open-models-bingo-card-through-a-town-with-almost-no-nature-records-i-found-2-of-16-649</link>
      <guid>https://dev.to/jonathansolvesstuff/i-walked-an-open-models-bingo-card-through-a-town-with-almost-no-nature-records-i-found-2-of-16-649</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Hawkesbury, Ontario, is a town of about ten thousand people on the Ottawa River. Around it, iNaturalist holds about 3,000 research-grade records in total. Across every October on record, the most-logged species within 10 km of town was seen four times.&lt;/p&gt;

&lt;p&gt;That number is the whole problem. The obvious way to get someone outside in October is to hand them a list of what people saw near them last October. In Montreal that list writes itself. In Hawkesbury it is whatever one or two people happened to photograph, and you cannot rank anything from four sightings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nature Bingo&lt;/strong&gt; is a card of the 16 things most likely to be out near your town this October. You open it, put the phone away, and go find them. When you find one, you tap its square and it gets dabbed with ink and stamped with the time. You can also print it and leave the phone at home entirely. The card is the screen; the walk is the point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvoj75l60nirf4iatn9e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvoj75l60nirf4iatn9e.jpg" alt="Hawkesbury's card: 16 squares of photos, from boxelder bugs to an American elm" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The trick for small towns is that the card borrows from its neighbours. It is ranked by an open-weight model that learned ten years of Octobers across 48 towns between Ottawa and Montreal, so a quiet town can lean on what the whole region knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Try it: &lt;strong&gt;&lt;a href="https://naturebingo.vercel.app" rel="noopener noreferrer"&gt;naturebingo.vercel.app&lt;/a&gt;&lt;/strong&gt; (pick any of the 48 towns, or keep Hawkesbury).&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Xo2zRxRYsWE" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;There is also a &lt;a href="https://www.youtube.com/shorts/UwPOgILmLak" rel="noopener noreferrer"&gt;51-second version&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/nature-bingo" rel="noopener noreferrer"&gt;
        nature-bingo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A bingo card of the 16 things most likely out this October near your town. TabPFN, an open-weight model, ranked on a laptop GPU from iNaturalist records.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Nature Bingo&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A bingo card of the 16 things most likely to be out near your town this October. Open it, put the phone away, and go find them. Tap a square when you do, or print the card.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://naturebingo.vercel.app" rel="nofollow noopener noreferrer"&gt;Open the app&lt;/a&gt;&lt;/strong&gt; · &lt;a href="https://www.youtube.com/watch?v=Xo2zRxRYsWE" rel="nofollow noopener noreferrer"&gt;Demo video&lt;/a&gt; · &lt;a href="https://www.youtube.com/shorts/UwPOgILmLak" rel="nofollow noopener noreferrer"&gt;Short&lt;/a&gt; · &lt;a href="https://jonathanandrei.com/blog/nature-bingo-printable-card-tabpfn-inaturalist/" rel="nofollow noopener noreferrer"&gt;Write-up&lt;/a&gt; · &lt;a href="https://www.inaturalist.org/observations/jonathansolvesproblems" rel="nofollow noopener noreferrer"&gt;My walk on iNaturalist&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why small towns&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A "what was seen near here last October" list works in a city. It does not work in a small town. Around Hawkesbury, Ontario, iNaturalist has about 3,000 research-grade records in total, and the most-logged species across every October on record has four sightings.&lt;/p&gt;
&lt;p&gt;So the card borrows from the neighbours. &lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt;, an open-weight tabular model, learns from ten years of Octobers (2016 to 2025) across 48 towns between Ottawa and Montreal. For each candidate species in each town it…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/nature-bingo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Every number in this post is in the repo: the backtest in &lt;code&gt;results/backtest.json&lt;/code&gt;, the split by town history in &lt;code&gt;results/backtest_bands.json&lt;/code&gt;, and the walk in &lt;code&gt;data/walks.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data.&lt;/strong&gt; For 48 small towns between Ottawa and Montreal, I pulled iNaturalist research-grade species counts for every year from 2016 to 2025: within 10 km of the town in October, within 10 km in any month, and within 50 km in October. For each town and year, the 300 species most logged across the region become candidates, about 86,000 training rows of (town, year, species), each described only by what happened in earlier years: how often it was logged here, how often across the region, in how many separate years, how seasonal it is locally, and how much anyone was out logging at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model.&lt;/strong&gt; &lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt; is an open-weight foundation model for tables. Instead of training a model on my data, it reads a sample of labelled rows as context and predicts the rest in one pass, which suits a problem where every town is small and the useful signal is in how towns resemble each other. It runs locally on my laptop's RTX 5060. Making Hawkesbury's card takes 19 seconds, and nothing goes to a model API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test.&lt;/strong&gt; I did not want to grade this against my own idea of what should be out, so I let the public grade it. I trained on target years up to 2024, held October 2025 out entirely, made every town's card blind, and counted how many of its 16 squares somebody actually photographed and logged on iNaturalist that month. Four methods, same towns, same month:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Squares photographed, of 16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN&lt;/td&gt;
&lt;td&gt;5.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The town's own past Octobers&lt;/td&gt;
&lt;td&gt;4.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient boosting (scikit-learn, same features)&lt;/td&gt;
&lt;td&gt;4.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regional most-seen&lt;/td&gt;
&lt;td&gt;3.65&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Town by town against the town's own past Octobers, TabPFN's card was better in 23 towns, the same in 14 and worse in 11.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forhyb0u1nerx8m56t0uj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forhyb0u1nerx8m56t0uj.jpg" alt="October 2025 graded: 23 towns better, 14 the same, 11 worse" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does not do.&lt;/strong&gt; I built this for small towns, and in the 16 towns with the least October history it does not win. Gradient boosting found 2.19 photographed squares there and TabPFN 1.88, both ahead of last October's list at 1.19. Hawkesbury, the town I walked, is one of the places TabPFN lost: 3 squares against 6. Where the data is thinnest, no method finds more than about 2 of 16, because almost nobody is out there taking pictures. The grader also has a bias I cannot remove: it rewards guessing what people photograph, which leans towards big, common, daytime things near paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The card says what it cannot know.&lt;/strong&gt; Under every card, in the product rather than a README, it states its own score: "When I ran the same method on last October, people photographed 3 of the 16 things on this town's card. The other 13 went unphotographed, which is not the same as absent." Every photo on the card is an openly licensed iNaturalist photo, credited by name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I took it outside.&lt;/strong&gt; The brief asked us to use it and say how it went. On Tuesday 6 October, from 10:18 to 10:54, I walked Hawkesbury's card at Confederation Park, under the big Franco-Ontarian flag. My phone stayed in my pocket except to tap a square, take a photo, and fly the drone for the video.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u0ag1h438z56059zhhw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u0ag1h438z56059zhhw.jpg" alt="Confederation Park in Hawkesbury, on the Ottawa River, under the Franco-Ontarian flag" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In about half an hour I confirmed 2 of the 16: dozens of Canada geese on the lawn at 10:52, and a mallard drake in the shallows a minute later. The ring-billed gulls on the shore are marked probable, not confirmed, because the ring on the bill is not visible in my photos. I also found things the card did not have: a huge old cottonwood, a spruce, and a few large white geese mixed in with the Canada geese.&lt;/p&gt;

&lt;p&gt;I posted the photos to iNaturalist that afternoon. Within hours, &lt;a href="https://www.inaturalist.org/observations/406393901" rel="noopener noreferrer"&gt;the goose&lt;/a&gt; and &lt;a href="https://www.inaturalist.org/observations/406393902" rel="noopener noreferrer"&gt;the mallard&lt;/a&gt; reached Research Grade, identified independently by another observer. &lt;a href="https://www.inaturalist.org/observations/406393900" rel="noopener noreferrer"&gt;The gull&lt;/a&gt; is still waiting for a second opinion, which is exactly what "probable" meant. So the two squares I ticked were not graded by me or by the model, but by another person looking at my photos.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnlvczyw1y0h5pmptpv2d.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnlvczyw1y0h5pmptpv2d.jpg" alt="The walk record on Hawkesbury's card: 2 of 16 confirmed by photo, 1 probable" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of sixteen is not a great score, and it is one morning, not a measurement. For scale: across all of last October, everyone on iNaturalist together photographed 3 of the squares on the card the model made for this town. One person in 36 minutes found 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A card for every small town has to be free to make.&lt;/strong&gt; The backtest scored 14,400 candidate rows, and every card scores 300 species per town. Locally that cost about eleven minutes of laptop GPU for the whole test and nothing per card. Behind a metered API, the experiment that tells me whether the card works at all would have been the expensive part, and the cheap move would have been to skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open let me test the model against plain alternatives on equal terms.&lt;/strong&gt; Because the weights run on my own machine, I could run TabPFN and scikit-learn's gradient boosting on the same rows, the same features and the same held-out month, and report where the open-weight model loses as well as where it wins. That table is only possible when I control both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open data made the grading honest.&lt;/strong&gt; The training data and the answer key are both public iNaturalist records under open licences. Anyone can re-pull October 2025 and check my 23, 14 and 11. And because iNaturalist is open, the loop closes: my three observations are now part of the data the next card learns from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ranking never touches someone else's server.&lt;/strong&gt; The model sees counts per town, not where you are. The only outside calls are to iNaturalist's public API for those counts.&lt;/p&gt;

&lt;p&gt;To be fair about the edges: TabPFN's weights are free but gated. You accept Prior Labs' license once and get a token before they download, and the license is theirs, not MIT. And the 8 GB on my laptop GPU meant TabPFN saw a 4,000-row sample of the 85,974 training rows, not all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of TabPFN&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Alberta stopped changing its clocks in June. 19 of 19 frontier models still put Calgary on standard time in November.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:52:48 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/alberta-stopped-changing-its-clocks-in-june-19-of-19-frontier-models-still-put-calgary-on-standard-3b33</link>
      <guid>https://dev.to/jonathansolvesstuff/alberta-stopped-changing-its-clocks-in-june-19-of-19-frontier-models-still-put-calgary-on-standard-3b33</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/oTj1_WVNkIg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;On June 18, Alberta's &lt;a href="https://www.alberta.ca/albertas-new-time-system-abt" rel="noopener noreferrer"&gt;Official Time Act&lt;/a&gt; came into force and the province stopped changing its clocks. British Columbia had done it in March. The &lt;a href="https://www.gov.nt.ca/en/newsroom/northwest-territories-ends-seasonal-time-change" rel="noopener noreferrer"&gt;Northwest Territories followed in August&lt;/a&gt;, &lt;a href="https://www.timeanddate.com/news/time/morocco-abolish-dst.html" rel="noopener noreferrer"&gt;Morocco went back to plain UTC on September 20&lt;/a&gt;, and on September 17 &lt;a href="https://news.gov.mb.ca/news/index.html?item=75397" rel="noopener noreferrer"&gt;Manitoba announced&lt;/a&gt; it will not fall back on November 1 either. &lt;a href="https://www.cbc.ca/news/canada/calgary/alberta-permanent-daylight-savings-businesses-calendars-9.7331872" rel="noopener noreferrer"&gt;CBC is running stories&lt;/a&gt; about a Calgary hairstylist whose winter bookings all moved an hour. &lt;a href="https://techcommunity.microsoft.com/blog/dstblog/interim-guidance-for-alberta-time-zone-changes-2026/4545015" rel="noopener noreferrer"&gt;Microsoft published interim guidance&lt;/a&gt; for Windows.&lt;/p&gt;

&lt;p&gt;So I asked the question someone in Calgary is asking right now. It is 9 a.m. here on November 15. What time is that in Toronto?&lt;/p&gt;

&lt;p&gt;Then I put it to 19 models on Kaggle Benchmarks. All 19 said 11:00. The answer is 10:00.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I benchmarked and why
&lt;/h2&gt;

&lt;p&gt;Every model's knowledge stops at its training cutoff, and most of the time you cannot see the edge. Time zones are different. Governments change them by statute, on a date, and the IANA time zone database records every change within days. That gives a benchmark two things most benchmarks never get: an answer key nobody has to write, and a calendar to hold the model's knowledge against.&lt;/p&gt;

&lt;p&gt;The answer key is tzdata 2026d, the September release of the same database that runs the clock on your phone. I did not type a single expected answer. A script asks Python's &lt;code&gt;zoneinfo&lt;/code&gt; what the clock did in a given place on a given date, and that is the truth. The maintainers ship a few releases a year, so the key rewrites itself and the benchmark does not go stale.&lt;/p&gt;

&lt;p&gt;125 questions, three kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is the UTC offset in Calgary at noon on November 15, 2026? Answer as +HH:MM or -HH:MM.&lt;/li&gt;
&lt;li&gt;It is 09:00 on November 15 in Calgary. What is the local date and time in Toronto?&lt;/li&gt;
&lt;li&gt;Do the clocks in Calgary change at any point during November 1, 2026, and in which direction?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six families of places and dates. Textbook controls (New York, London, Tokyo). Awkward offsets (Kathmandu at +05:45, the Chatham Islands at +12:45, Lord Howe Island's 30-minute daylight saving). Southern-hemisphere daylight saving. Changes legislated between 2022 and 2025 (Iran, Jordan, Syria, Mexico, Greenland, Egypt, Kazakhstan, Paraguay, Chile's Aysén region). The 2026 wave (British Columbia, Alberta, Northwest Territories, Morocco). And Manitoba, which announced its change on September 17, after the release I graded against, so its three questions are asked, recorded, and not counted in the score. That leaves 122 graded questions.&lt;/p&gt;

&lt;p&gt;The part I care about most is the dating trick. Of the 122, 46 have an answer that changed between one tzdata release and another. I keep every release since 2022a unpacked, twenty of them, score each model's answer sheet against all twenty, and the release a model agrees with most is the month its world clock stopped. A model that still puts Almaty at +06:00 has a clock from before February 2024. One that puts Vancouver at -08:00 in December 2026 has a clock from before April 2026.&lt;/p&gt;

&lt;p&gt;Answers are structured output, so grading is a string comparison after normalising &lt;code&gt;UTC-6&lt;/code&gt;, &lt;code&gt;-6:00&lt;/code&gt; and &lt;code&gt;−06:00&lt;/code&gt; to the same thing. A wrong hour is a wrong hour. There is no judge model anywhere in the loop, and the task records the model's one-sentence reason for each answer without grading it, which is where the quotes below come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which models I ran it against
&lt;/h2&gt;

&lt;p&gt;19 models, all through Kaggle Benchmarks, same prompt, temperature 0, no tools. I included 2025 models on purpose (Gemini 2.5 Pro from June, DeepSeek-R1 from May, Claude Haiku 4.5 from October) because a dating method that cannot date a 2025 model to 2025 is not worth reading. Then I asked 46 of the questions a second time with a one-function tool the model was free to ignore: &lt;code&gt;zone_clock(iana_zone, local_datetime)&lt;/code&gt;, which reads tzdata 2026d. Not "use this tool." Just "it is there."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Released&lt;/th&gt;
&lt;th&gt;Score (of 122)&lt;/th&gt;
&lt;th&gt;2026 questions right (of 25)&lt;/th&gt;
&lt;th&gt;Clock dated by the ladder&lt;/th&gt;
&lt;th&gt;Vendor's stated cutoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Astra&lt;/td&gt;
&lt;td&gt;2026-09-03&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2026b (April 2026), agrees on 45 of 46&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;2026-07-24&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025b to 2026a (March 2025 to March 2026), agrees on 46 of 46&lt;/td&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025b to 2026a (March 2025 to March 2026), agrees on 46 of 46&lt;/td&gt;
&lt;td&gt;2025-12-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Terra&lt;/td&gt;
&lt;td&gt;2026-07-09&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025b to 2026a (March 2025 to March 2026), agrees on 46 of 46&lt;/td&gt;
&lt;td&gt;2026-02-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Pro&lt;/td&gt;
&lt;td&gt;2025-06&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2025a (January 2025), agrees on 42 of 46&lt;/td&gt;
&lt;td&gt;2025-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;2026-08&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025a (January 2025), agrees on 44 of 46&lt;/td&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;2026-09-02&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025a (January 2025), agrees on 44 of 46&lt;/td&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro&lt;/td&gt;
&lt;td&gt;2026-02-19&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2025a (January 2025), agrees on 43 of 46&lt;/td&gt;
&lt;td&gt;2025-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;2026-02-11&lt;/td&gt;
&lt;td&gt;93 of 121&lt;/td&gt;
&lt;td&gt;4 of 24&lt;/td&gt;
&lt;td&gt;2024a to 2024b (February 2024 to September 2024), agrees on 42 of 46&lt;/td&gt;
&lt;td&gt;not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2024a to 2024b (February 2024 to September 2024), agrees on 39 of 46&lt;/td&gt;
&lt;td&gt;not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;2026-04-02&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2024a to 2024b (February 2024 to September 2024), agrees on 41 of 46&lt;/td&gt;
&lt;td&gt;2025-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20 Reasoning&lt;/td&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2023a to 2023c (March 2023), agrees on 40 of 46&lt;/td&gt;
&lt;td&gt;2025-09-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;2026-03-17&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2024a to 2024b (February 2024 to September 2024), agrees on 39 of 46&lt;/td&gt;
&lt;td&gt;2025-08-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.5&lt;/td&gt;
&lt;td&gt;2025-11&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2022g (November 2022), agrees on 37 of 46&lt;/td&gt;
&lt;td&gt;not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;2026-06-30&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2022f (October 2022), agrees on 39 of 46&lt;/td&gt;
&lt;td&gt;2026-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-Next 80B Thinking&lt;/td&gt;
&lt;td&gt;2025-09-11&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2022f (October 2022), agrees on 37 of 46&lt;/td&gt;
&lt;td&gt;2025-09-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1&lt;/td&gt;
&lt;td&gt;2025-05-28&lt;/td&gt;
&lt;td&gt;82 of 121&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2022a (March 2022), agrees on 36 of 46&lt;/td&gt;
&lt;td&gt;2025-03-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20&lt;/td&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2022e (October 2022), agrees on 34 of 46&lt;/td&gt;
&lt;td&gt;2025-09-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;2025-10-15&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2022a to 2022d (March 2022 to September 2022), agrees on 39 of 46&lt;/td&gt;
&lt;td&gt;2025-02&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GLM-5 and DeepSeek-R1 each lost one call to their backends, so each is scored on 121 questions; every other model answered all 122, though Gemma 4 left the answer field empty on six of them, and an empty answer counts as wrong. The vendor cutoffs are what each company publishes where it publishes one, and third-party trackers where it does not. Claude Opus 4.1 and Grok 4.6 are on Kaggle's list but return 404 from the proxy, so they are not here.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Everyone knows how clocks work
&lt;/h3&gt;

&lt;p&gt;Fifteen of the 19 answered all 26 control questions correctly, and nobody scored below 23 of 26. Twelve got every awkward-offset question right, including Lord Howe Island's half-hour spring forward and the Chatham Islands at +13:45 in January. The southern hemisphere was nearly as clean. This is the part where the &lt;a href="https://arxiv.org/abs/2406.09170" rel="noopener noreferrer"&gt;Test of Time paper&lt;/a&gt; found models scoring 74 to 90 percent on time-zone questions back in 2024 and attributed it to the amount of time-zone text on the internet. That reading holds. Time zones as a topic are learned.&lt;/p&gt;

&lt;p&gt;Time zones as of a date are a different thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nobody knows about Alberta
&lt;/h3&gt;

&lt;p&gt;Twenty of the 25 questions about the 2026 wave have an answer that changed this year. The other five are controls inside the family, Vancouver in July or Fort Nelson in November, where nothing changed. Twelve of the 19 got all five. Thirteen of the 19 got none of the 20 changed answers right, including Claude Opus 5, GPT-5.5, GPT-5.6 Terra and three of the five Geminis.&lt;/p&gt;

&lt;p&gt;On the 20 changed questions, across 19 models, there were 18 correct answers. GPT-6 Astra produced 5 of them. Four are about British Columbia, with the right reason: "British Columbia adopted permanent UTC−07:00 time in March 2026, so Vancouver does not turn clocks back in autumn." The fifth is an Inuvik conversion with no reason given. The other 13 came from models that did not know, and not one of those 13 notes says that anything changed in 2026. Qwen said "Clocks do not change on 2026-11-01 as the fall back occurs on 2026-11-02." November 1 is the Sunday. Gemini 2.5 Pro said Inuvik's clocks do not change because "the Northwest Territories permanently observes Mountain Daylight Time (UTC-6) year-round", then put Inuvik on UTC-7 in the next question. I read every one of the thirteen. Not one model knew that Alberta, the Northwest Territories or Morocco had changed anything.&lt;/p&gt;

&lt;p&gt;The Calgary offset question is the one I would put in front of a judge, because it cannot be right by accident. Calgary at noon on November 15, 2026 is -06:00. All 19 models said -07:00.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfub5xqpxzx2k7sssiyr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfub5xqpxzx2k7sssiyr.png" alt="How many of the 25 questions about the 2026 changes each model got right, from memory and with the tool" width="800" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Dating the clocks
&lt;/h3&gt;

&lt;p&gt;This is the chart the whole benchmark was built for. Each line is one model's agreement with each tzdata release since 2022, on the 46 answers that changed. The peak is the release the model's clock is dated to. Identical curves are drawn once and labelled with every model on them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falexlyu1lcscqkjumq7s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falexlyu1lcscqkjumq7s.png" alt="Answers matching each tzdata release, per vendor" width="800" height="675"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GPT-6 Astra is the cleanest result in the set. Its curve rises through every release since 2022, agrees with tzdata 2026b on 45 of the 46 changed answers, and falls off a cliff at 2026c. The one it disagrees on is that Inuvik conversion. tzdata 2026b was released on April 22, 2026, and carries British Columbia; the next release, 2026c, came on July 8. So the ladder says this clock stopped somewhere between those two dates. OpenAI says the model's cutoff is April 30, 2026. Those agree, and the ladder got there from nothing but clock questions.&lt;/p&gt;

&lt;p&gt;GPT-5.5, GPT-5.6 Terra and Claude Opus 5 share one curve, and it is also a perfect 46 of 46, at 2026a. That is the last release before British Columbia. OpenAI's stated cutoffs for its two are December 2025 and February 2026, both consistent. Anthropic says Opus 5 was trained on data to May 2026, which should include a change that took effect on March 9. Its own note on Vancouver in December says why it does not: "On 2026-12-15 Vancouver is on Pacific Standard Time (UTC-8), since daylight saving ended on the first Sunday of November 2026 and British Columbia's permanent-DST legislation has not been brought into force." It knows the law. Its clock stopped before the law started.&lt;/p&gt;

&lt;p&gt;Gemini 2.5 Pro, 3.1 Pro, 3.7 Flash and 3.8 Flash all date to tzdata 2025a, released January 15, 2025. Gemini 2.5 Pro from June 2025, Gemini 3.7 Flash from August 2026 and Gemini 3.8 Flash from September 2026 all peak there, and the two Flash curves never differ by more than one answer anywhere on the ladder. Only Flash-Lite sits elsewhere, at 2024a to 2024b with a blurry 39 of 46. Google's model card for 3.8 Flash gives a cutoff of March 2026 "for some domains" and January 2025 for the rest. The clock is one of the rest, and it has not moved in fifteen months of releases.&lt;/p&gt;

&lt;p&gt;Claude Sonnet 5 dates to October 2022 and Claude Haiku 4.5 to somewhere between March and September 2022, against stated cutoffs of January 2026 and February 2025. These are not parsing accidents, I read the answers. Sonnet 5 says "Iran observed daylight saving time (UTC+4:30) during summer 2023 before abolishing DST later that year" (Iran abolished it in 2022), puts Cairo on +02:00 in July 2023 in a sentence that mentions Egypt's 2023 reintroduction of daylight saving and then ignores it, and says "Almaty has used the UTC+6 time zone year-round since 2024-03-01", which is the day Kazakhstan moved Almaty to +05:00. It scored 16 of 31 on the changes legislated between 2022 and 2025. GPT-6 Astra, GPT-5.5, GPT-5.6 Terra and Opus 5 scored 31 of 31.&lt;/p&gt;

&lt;p&gt;The peak height matters as much as its position. A model at 46 of 46 has a sharp clock: everything before the peak right, everything after it wrong. A model at 34 of 46 has a blurry one, and the dating is a best fit rather than a fact. The table gives both numbers for every model.&lt;/p&gt;

&lt;p&gt;How much of this is noise? I fixed the harness several times along the way, so 15 models ran the from-memory questions at least twice, 42 runs in all. The median model's score moved by 2 questions between runs, and the largest swing was 10 questions, Claude Haiku 4.5. The release its clock dates to stayed the same in 10 of the 15, and moved by one or two releases in the rest. Every run of every model put Calgary on -07:00.&lt;/p&gt;

&lt;h3&gt;
  
  
  With the tool
&lt;/h3&gt;

&lt;p&gt;Then I asked again with a tool on the table. &lt;code&gt;zone_clock(iana_zone, local_datetime)&lt;/code&gt; reads tzdata 2026d and returns the offset in force. The system prompt says the tool exists and that the model may call it or answer without it. Nothing tells the model its knowledge might be out of date.&lt;/p&gt;

&lt;p&gt;This half asks 46 of the 125 questions: every question about 2026, the three about Manitoba, and eighteen older ones as a control group. 43 of the 46 are graded. The reason it is a subset is money, which I get to below. 16 models finished it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;From memory (of 43)&lt;/th&gt;
&lt;th&gt;With the tool (of 43)&lt;/th&gt;
&lt;th&gt;Asked the tool&lt;/th&gt;
&lt;th&gt;Overrode it&lt;/th&gt;
&lt;th&gt;Asked about another zone only&lt;/th&gt;
&lt;th&gt;Answer changed when restated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.5&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Terra&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;42 of 43&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20 Reasoning&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;33 of 43&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;20 of 42&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;41 of 43&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;40 of 43&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;43 of 43&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-Next 80B Thinking&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;2 of 43&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Almost everyone checks.&lt;/strong&gt; Eleven of the 16 asked the tool on every one of the 43 questions. GPT-5.6 Terra asked on 42, Gemini 3.1 Pro on 41, Claude Sonnet 5 on 40 and GPT-5.5 on 33. Qwen asked on 2, answered the rest from memory, and scored 19, which is lower than the 21 it got without the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking mostly works.&lt;/strong&gt; Fourteen of the 16 got at least 34 of 43 with the tool, and nobody got more than 23 from memory. Claude Haiku 4.5, whose clock dated to 2022 in the memory half, is the only model that got all 43. It never once answered against the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking is not believing.&lt;/strong&gt; Take the question this post opened with. With the database one call away, 8 of the 16 still said 11:00. Two of those eight wrote the correct offsets in the same sentence. GPT-5.6 Terra's note reads "Calgary used UTC−06:00 and Toronto used UTC−05:00." Its answer was 11:00.&lt;/p&gt;

&lt;p&gt;Claude Opus 5 argued with the tool in so many words. On Calgary's offset it wrote "the tool reports -06:00 with abbreviation CST, which does not match Alberta's actual rules" before coming round to the tool's answer. On Casablanca in December it did not come round. It answered +01:00 and left this note: "the zone_clock lookup returned +00:00, which conflicts with this known rule". Gemini 3.5 Flash-Lite called the tool 3 times about Calgary's clocks on November 1, was told they do not change, and answered that they fall back. Across the 16 models there were 47 answers like that, where the model asked about the right place on the right date and then answered something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some of it is a second chance to be wrong.&lt;/strong&gt; Kaggle's SDK runs the tool loop, then sends a final message, "Now format your previous answer using the requested schema.", and the benchmark grades that restated answer. &lt;code&gt;analysis/tool_trace.py&lt;/code&gt; compares the two, and the last column of the table counts the cases where they differ. In 28 cases the model's own words had the right answer and the restated one did not. Claude Sonnet 5 wrote "Per IANA tzdata 2026d, Vancouver is on permanent standard time (abbreviated MST, no DST) at -07:00 for this date", then restated the offset as -08:00. 13 answers went the other way and were fixed at that step. Gemini 3.7 Flash ran this task three times while I was fixing the harness and scored 42, 40 and 39.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure I like best is Coyhaique.&lt;/strong&gt; Chile's Aysén region got its own zone, America/Coyhaique, in tzdata 2025b. A model whose clock stopped before that does not know the zone exists, so it asks the database about America/Santiago, gets a true answer about the wrong place, and reports it. 9 of the 16 missed the question, and only five asked about America/Coyhaique at all. Gemini 3.8 Flash: "Coyhaique in the Aysén Region follows official mainland Chile standard time (America/Santiago), which observes UTC-04:00 during the winter months." A lookup tool fixes what the model knows to look up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a subset, and who is missing.&lt;/strong&gt; Kaggle gives each account a model quota of $10 a day, and a tool loop is expensive because every round sends the whole conversation again. Gemini 3.8 Flash cost $0.16 for the 125 questions from memory and $0.99 for the same 125 with the tool. My first full tool lineup ran out of quota partway through, and the runs queued behind it failed on a 403 from the proxy. So the tool half is 46 questions. GPT-6 Astra is missing for a different reason: the API refuses function tools for that model unless reasoning is off, and refuses to switch its reasoning off, so Kaggle's SDK has no way to hand it a tool. GPT-5.6 Terra has the same rule and does allow reasoning off, so it ran this half that way. DeepSeek-R1 does not support tool calling at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manitoba, which the database caught up with
&lt;/h3&gt;

&lt;p&gt;Manitoba announced on September 17 that it will not fall back on November 1. When I built the answer key no release had it, so its three questions were asked, recorded, and left out of the score. Then, on September 30, the tz maintainers shipped tzdata 2026e: "Manitoba moves to permanent -05 on 2026-10-31".&lt;/p&gt;

&lt;p&gt;So I graded those three answers against 2026e. Winnipeg at noon on November 15 is -05:00, and 9 a.m. in Winnipeg is 9 a.m. in Toronto. All 19 models said -06:00, and all 19 said 10:00. Asked whether Winnipeg's clocks change on November 1, 18 said they fall back. Qwen said they do not, and it only got that right because it believes the fall-back is on November 2.&lt;/p&gt;

&lt;p&gt;The benchmark ran before the database knew, and the database caught up a week later. The models will take a training run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;Release date is not the clock. Gemini 3.8 Flash shipped on September 2, 2026 with its world clock on the same release as Gemini 2.5 Pro from June 2025: tzdata 2025a. Fifteen months of model releases, one clock.&lt;/p&gt;

&lt;p&gt;Knowing about a law is not knowing its date. Opus 5 can tell you British Columbia passed a permanent daylight time law and still puts Vancouver on the wrong offset, because the fact it learned was "passed" and the fact that matters is "in force since March 9."&lt;/p&gt;

&lt;p&gt;The models agree with each other more than with the world. On the Calgary to Toronto question, all 19 models gave the same wrong answer, with the same confidence, and most of them cited the same rule: daylight saving ends on the first Sunday in November. That rule was true in Alberta for 55 years. It stopped being true on June 18.&lt;/p&gt;

&lt;p&gt;Checking is not believing. Fourteen of the 16 models that had the database asked it on at least 40 of the 43 questions, and eight of the 16 still told Calgary it was 11:00 in Toronto. Claude Opus 5 told the database it was wrong about Alberta.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure next
&lt;/h2&gt;

&lt;p&gt;Run the same 125 questions again after tzdata 2027a, without changing a line, and see which vendors' clocks moved. The answer key updates itself; the benchmark is designed to be re-run.&lt;/p&gt;

&lt;p&gt;Tell the model the current date in the prompt and see whether any of them hedge. Not one of the 19 said "as of my training data" on a question about November 2026.&lt;/p&gt;

&lt;p&gt;Add a web-search condition beside the tzdata tool. With a database one call away, nearly every model looked. The open question is whether a model goes looking when nothing on the table suggests it should.&lt;/p&gt;

&lt;p&gt;Grade the model's own words as well as the restated answer, as two scores. The gap between them is a measurement of the harness, and I only found it by reading transcripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to see it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The benchmark on Kaggle: &lt;a href="https://www.kaggle.com/benchmarks/jonathanandrei/world-clock" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/jonathanandrei/world-clock&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Its two tasks: &lt;a href="https://www.kaggle.com/benchmarks/tasks/jonathanandrei/world-clock-from-memory" rel="noopener noreferrer"&gt;from memory&lt;/a&gt; and &lt;a href="https://www.kaggle.com/benchmarks/tasks/jonathanandrei/world-clock-tool-v2" rel="noopener noreferrer"&gt;with the tzdata tool&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code, answer key, every result and both charts: &lt;a href="https://github.com/JonathanSolvesProblems/world-clock" rel="noopener noreferrer"&gt;https://github.com/JonathanSolvesProblems/world-clock&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The 90-second demo: &lt;a href="https://www.youtube.com/watch?v=oTj1_WVNkIg" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=oTj1_WVNkIg&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;This write-up on my site: &lt;a href="https://jonathanandrei.com/blog/world-clock-benchmark-graded-by-the-iana-tz-database/" rel="noopener noreferrer"&gt;https://jonathanandrei.com/blog/world-clock-benchmark-graded-by-the-iana-tz-database/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo has a script called &lt;code&gt;check_claims.py&lt;/code&gt;. It reads the results and fails if any number in this post disagrees with them. It ran before this was published.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>My mom has published 710 pieces in 16 years. Nothing she wrote links to 526 of them.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 04 Oct 2026 21:34:36 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/my-mom-has-published-710-pieces-in-16-years-nothing-she-wrote-links-to-526-of-them-1886</link>
      <guid>https://dev.to/jonathansolvesstuff/my-mom-has-published-710-pieces-in-16-years-nothing-she-wrote-links-to-526-of-them-1886</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;In May my mom sent me a poster she had made. A graveyard at dusk, six tombstones, and on each tombstone the title of something she had written and where it ran. Across the top: "Don't treat your content like a graveyard. Treat it like a library." Under it she typed, "Remember the app idea I had?"&lt;/p&gt;

&lt;p&gt;My mom is Mona Andrei. She writes humour. She has kept a blog called &lt;a href="https://www.moxie-dude.com" rel="noopener noreferrer"&gt;Moxie-Dude&lt;/a&gt; since 2010, she writes a column for Westmount Magazine, she has a Substack for single moms, and she wrote a book called Superwoman. She wanted two things: everything she has written in one place, and something that would point her back at what she already wrote when she sits down to write something new.&lt;/p&gt;

&lt;p&gt;I built her a social media scheduler. It had a landing page and a logo and it ran on sample data. She found a product online that already did that, and texted me: "Found it. I will find the hole."&lt;/p&gt;

&lt;p&gt;This weekend I built what she asked for the first time. It is called &lt;strong&gt;The Moxie Library&lt;/strong&gt;, and it is a single web page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk9qzqld6i5jf9knz0v0k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk9qzqld6i5jf9knz0v0k.png" alt="The desk with two test sentences on the left, and her 2014 piece about cooking red meat coming back on the right. Sixteen years of slabs run along the bottom." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the left is a sheet of paper. She writes a draft there, or pastes one. When she pauses, the page looks through everything she has published and brings back the pieces on the same subject. Each one comes with her own paragraph quoted, where it ran, how many years ago, and a button that copies a link she can paste straight into WordPress or Substack.&lt;/p&gt;

&lt;p&gt;Along the bottom is the ground. Every piece she has published is drawn there as a small slab, by year. If nothing else she wrote links to it, it is a tombstone. If something does, it is a book on a shelf. A tombstone and a book spine turn out to be the same shape, which is the whole design.&lt;/p&gt;

&lt;p&gt;Nothing on the page is written for her. Every suggestion is a real link and a passage cut out of her own text. If she writes about something she has never covered, it says "Nothing close. This one is new." and shows nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Her graveyard, counted
&lt;/h3&gt;

&lt;p&gt;I pulled everything public: 650 posts from her blog, 37 columns from Westmount Magazine and 23 pieces from her Substack. That is 710 pieces and 353,232 words.&lt;/p&gt;

&lt;p&gt;Then I counted the links she had made by hand from one of her pieces to another.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;526 of the 710&lt;/strong&gt; have nothing of hers pointing at them.&lt;/li&gt;
&lt;li&gt;On her blog she linked back to an earlier post 179 times. &lt;strong&gt;150 of those&lt;/strong&gt; pointed at something from the previous 30 days.&lt;/li&gt;
&lt;li&gt;In 16 years she reached back &lt;strong&gt;more than a year 9 times&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Left to memory, a writer links to what she wrote last week. The poster was right, and it was more right than she knew: three of the six pieces on her tombstones are in the library, and all three are in the ground.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwtxir7leu07joieps0de.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwtxir7leu07joieps0de.png" alt="Sixteen years of her writing drawn as slabs by year. The grey round-topped ones are tombstones: pieces nothing else of hers links to." width="800" height="138"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/VD4t_Ot2BOA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Live page: &lt;strong&gt;&lt;a href="https://moxie-library.vercel.app" rel="noopener noreferrer"&gt;moxie-library.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My mom said yes to being the example, and to the page quoting her. Her text is hers, every quoted passage links back to where she published it, and the page asks search engines not to index it, so it does not compete with her own blog.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1s2cv5dd9ytjk2ms9uwv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1s2cv5dd9ytjk2ms9uwv.gif" alt="A short recording: two sentences typed, five of her pieces arriving, then a subject she has never covered" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Things to try:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Type a couple of sentences about a kitchen disaster. In the recording I typed "I tried a new recipe tonight and set off the smoke alarm twice. The kids ordered pizza before I had finished apologising to the neighbours." Those are my test sentences, not hers. What comes back is hers: five pieces written between 2013 and 2016, starting with the one about why she should never try to cook red meat again.&lt;/li&gt;
&lt;li&gt;Type something a humour writer in Montreal has probably never covered. I used the Treaty of Westphalia. You get "Nothing close."&lt;/li&gt;
&lt;li&gt;Turn your Wi-Fi off and type another line. It still answers.&lt;/li&gt;
&lt;li&gt;Click any tombstone along the bottom, or type part of a title in the search box. You get that piece and the three closest to it, any of which could link to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/moxie-library" rel="noopener noreferrer"&gt;
        moxie-library
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A library of one writer's 16 years of work, with a desk that remembers.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;The Moxie Library&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Everything my mom has published, in one place, with a desk that points her back at what she
already wrote.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://moxie-library.vercel.app" rel="nofollow noopener noreferrer"&gt;moxie-library.vercel.app&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://www.youtube.com/watch?v=VD4t_Ot2BOA" rel="nofollow noopener noreferrer"&gt;youtube.com/watch?v=VD4t_Ot2BOA&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Write-up: &lt;a href="https://jonathanandrei.com/blog/moxie-library-writers-archive-graded-by-her-own-links/" rel="nofollow noopener noreferrer"&gt;jonathanandrei.com/blog/moxie-library-writers-archive-graded-by-her-own-links&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Weekend Challenge: Build for a Friend, 2 to 4 October 2026.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Who it is for&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Mona Andrei writes humour. She has kept &lt;a href="https://www.moxie-dude.com" rel="nofollow noopener noreferrer"&gt;moxie-dude.com&lt;/a&gt; since 2010
writes for Westmount Magazine, and has a Substack. In May 2026 she sent me a poster she had made
that said "Don't treat your content like a graveyard. Treat it like a library," and asked for
two things: all of her writing in one place, and a way to be pointed back at what she had
already written when she starts something new.&lt;/p&gt;
&lt;p&gt;I built her a social auto-poster that month. It was the wrong thing, and it is not in this repo.
This is the thing she asked for.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/moxie-library" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Her writing is her copyright, so her text is not in the repo. The scripts rebuild the library from her public pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Getting 16 years of writing.&lt;/strong&gt; Her blog is WordPress, and WordPress has a public REST API, so 650 posts came down in seven requests. Substack has a public archive endpoint. Westmount Magazine is also WordPress, but searching it for her name returns articles about her as well as by her, so I kept a piece only when "By Mona Andrei" appears in its first 200 characters. That dropped five, including the one announcing she had won Humour Blogger of the Year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cutting it into passages.&lt;/strong&gt; Each piece is cut on paragraph boundaries into passages of about 120 words. 2,809 of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model.&lt;/strong&gt; &lt;a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2" rel="noopener noreferrer"&gt;all-MiniLM-L6-v2&lt;/a&gt;, an open-weight embedding model, 23 MB at 8-bit. It runs through &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;transformers.js&lt;/a&gt; on WebAssembly, in a web worker, inside her browser tab. The whole index of her writing is 2,809 vectors of 384 numbers each, stored as one byte per number: 1.08 MB.&lt;/p&gt;

&lt;p&gt;When she pauses typing, the draft is cut into passages the same way, each one is embedded in the tab, and every piece is scored by its closest passage. That is all the matching there is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I picked the model by testing three on her own links.&lt;/strong&gt; I ran all-MiniLM, nomic-embed-text and EmbeddingGemma through Ollama on my laptop's GPU. On an early version of the test described below they scored 7, 10 and 9 of 19, which is inside the noise. Then I tried the best scorer, nomic-embed-text, in the runtime the page uses: over a second per passage on a CPU. The smallest took 78 ms. So the smallest shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second open model picks one line per piece.&lt;/strong&gt; Gemma 3 4B, running locally through Ollama, reads each piece and proposes the one sentence most worth quoting. Then a string comparison decides. The line is kept only if it can be found word for word in the piece, and what gets stored is the span cut from her text, not Gemma's retyping of it. It was asked about 694 pieces. 560 lines were kept, 131 of them only after one correction. 134 were thrown out: 23 were not word for word hers, 18 were the wrong length, 91 ran on past the limit, and twice it said there was no line, which is an allowed answer. For the other 9 pieces the local server returned an error every time, so they have no line. The page shows a line only where one was kept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One bug worth passing on.&lt;/strong&gt; transformers.js 4.3 loaded the model from local files and then failed with &lt;code&gt;this.tokenizer is not a function&lt;/code&gt;. It checks whether tokenizer files exist only when the local model path is not a full URL. I had passed &lt;code&gt;new URL('./models/', location).href&lt;/code&gt;. Passing &lt;code&gt;.pathname&lt;/code&gt; instead fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How well does it find things? She graded it.
&lt;/h3&gt;

&lt;p&gt;I did not write the answer key. She did, between 2011 and 2026, every time she linked one post to another.&lt;/p&gt;

&lt;p&gt;The test: take a post where she linked back to an earlier one. Remove the link and its text. Treat what is left as a draft. Ask for every earlier post, ranked. See where the one she actually linked to lands.&lt;/p&gt;

&lt;p&gt;Most of her links are no test at all, because 150 of 179 point at last month and "show the newest post" finds them. The ones that matter are the 19 that reach back more than 90 days.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Her real target in the first five&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Newest post first&lt;/td&gt;
&lt;td&gt;1 of 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword search (BM25)&lt;/td&gt;
&lt;td&gt;7 of 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The model in the page&lt;/td&gt;
&lt;td&gt;9 of 19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nineteen is a small number, and nine against seven is not a difference I would defend. What it shows is that a 23 MB open model in a browser tab does at least as well as keyword search at the one thing she cannot do from memory. I also tried fusing the two (10 of 19) and did not ship a second ranker for one extra hit.&lt;/p&gt;

&lt;p&gt;Those numbers come from the exact vectors the page loads, not a separate run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things I got wrong on the way
&lt;/h3&gt;

&lt;p&gt;My first rule for "nothing close" was a similarity level that nine in ten of her real links cleared. It was useless. The nearest unrelated passage usually scores higher than the post she actually linked, so the page would never have said no. The rule that shipped comes from her archive instead: for every piece she has published, how close was the closest earlier one? 19 in 20 clear a certain level. A draft below that level is further from her past work than almost anything she has ever published, and that is when the page says the subject is new.&lt;/p&gt;

&lt;p&gt;The second one is the first paragraph of this post. I built the scheduler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checks that fail out loud
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;check_site.mjs&lt;/code&gt; opens the page in real Chromium and checks what the page promises: the model loads from local files, a passage of hers finds its own piece first, the quoted passage is word for word from the library, the Treaty of Westphalia gets "Nothing close", the page still answers with the network switched off, any piece can be opened from the keyboard, and not one request leaves the page's own origin. 14 checks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check_claims.py&lt;/code&gt; reads every number in the README and in this post and compares it with the data. On its first run it caught a stale word count in my own plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Her drafts are the input. An unpublished draft is the one thing a writer does not hand to a server she does not control, and here there is no server to hand it to. The model's files sit beside the page. The page counts its own requests to any other server and prints the number at the bottom. It is 0.&lt;/p&gt;

&lt;p&gt;It works on a plane. After the first load the page needs nothing.&lt;/p&gt;

&lt;p&gt;It costs nothing to run, which matters more than it sounds. My mom is not going to pay a monthly fee to search her own writing, and I am not going to pay per request so that she can.&lt;/p&gt;

&lt;p&gt;And I could choose the model by measuring it against her, on my own laptop, in an afternoon. Three open models, her 19 links, pick the one that is small enough for her browser.&lt;/p&gt;

&lt;p&gt;Where would closed have been better? Probably on raw accuracy. I did not run a closed embedding model against her links, so I am not going to claim open won that. What open did that closed could not do is run inside her tab, offline, with her draft going nowhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  What she said
&lt;/h3&gt;

&lt;p&gt;Nothing yet. She said yes to being the example, but she has not sat down with it, so there is no quote here. I would rather leave that blank than write one for her, which is also the rule the page follows.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cannot do
&lt;/h3&gt;

&lt;p&gt;It matches subjects, not jokes, and it cannot tell whether a link would be welcome. It reads passages of about 120 words, so a one-line aside can slip past. Three of the outlets on her poster are not in it yet, because their sites would not let me read them. And 19 links is a small test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 3 4B runs locally through Ollama and proposes one pull line per piece, and a word-for-word check decides which ones are kept. EmbeddingGemma was one of the three models I tested against her links. To be clear about the rest: the model that does the matching in the page is all-MiniLM-L6-v2, not Gemma.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;Every word quoted in the library was written by my mom, Mona Andrei. Read her where she published it: her blog &lt;a href="https://www.moxie-dude.com" rel="noopener noreferrer"&gt;Moxie-Dude&lt;/a&gt;, her Substack &lt;a href="https://moxiemona.substack.com" rel="noopener noreferrer"&gt;Single Moms with Moxie&lt;/a&gt;, and her column in &lt;a href="https://www.westmountmag.ca" rel="noopener noreferrer"&gt;Westmount Magazine&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wrote up the build in more detail on my own site: &lt;a href="https://jonathanandrei.com/blog/moxie-library-writers-archive-graded-by-her-own-links/" rel="noopener noreferrer"&gt;My mom has published 710 pieces in 16 years. Nothing she wrote links to 526 of them.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The turquoise, the charcoal and the Raleway type are the ones on her blog. Her words are set in Literata. Both fonts are SIL Open Font License.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Will It Focus: 21 of 22 Quotes Word for Word From the Manufacturer's Own Documents</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:33:31 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/will-it-focus-21-of-22-quotes-word-for-word-from-the-manufacturers-own-documents-4imm</link>
      <guid>https://dev.to/jonathansolvesstuff/will-it-focus-21-of-22-quotes-word-for-word-from-the-manufacturers-own-documents-4imm</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;When I was working in film, my Canon T5i would sometimes have trouble focusing in auto mode, and I often just switched to manual and did it myself.&lt;/p&gt;

&lt;p&gt;Canon's own manual explains it. Page 100: "In Basic Zone modes, the camera will normally focus the closest subject automatically. Therefore, it may not always focus your target subject."&lt;/p&gt;

&lt;p&gt;Will It Focus is an agent that answers that kind of question for any body, lens and adapter in its dataset: will they autofocus together, separately for viewfinder photos, live view and video, and if focus still misses, why. It works across mounts and adapters, so "Sigma 35mm Art on a Sony a7 IV through the MC-11: does AF-C work?" gets a real answer too.&lt;/p&gt;

&lt;p&gt;The rule it lives by: a quote is only shown as the manufacturer's words if it is. Every quote the agent returns is looked up in the Sanity record it cites, by code, after the model is done. If it matches a whole sentence of that record, it lines up like a split-image focusing screen coming into focus. If the model reworded it, the page says so and shows the record's actual sentence underneath. If no manufacturer covers the combination, it refuses instead of guessing that an adapter that fits will also focus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/NJ9Xf6ussV4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Live, no login: &lt;strong&gt;&lt;a href="https://will-it-focus.vercel.app" rel="noopener noreferrer"&gt;https://will-it-focus.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The four example questions are saved answers, so they load instantly. Type your own for a live run through Sanity Context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3z7lng9zjs8dk4az9qv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3z7lng9zjs8dk4az9qv9.png" alt="The verdict for a Sigma lens on a Sony body through the MC-11: AF-S works, AF-C does not, and Sigma's own table row is quoted and verified" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Sigma answer above quotes the actual row from Sigma's MC-11 compatibility table, &lt;code&gt;35mm F1.4 DG HSM ○ × ×&lt;/code&gt;, with a link to the page it came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwctsor7dp7w4hfe16tx4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwctsor7dp7w4hfe16tx4.png" alt="A Nikon F lens on a Canon RF body: no manufacturer record covers it, so there is no verdict" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is what happens when nobody has published an answer. No verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/JonathanSolvesProblems/will-it-focus" rel="noopener noreferrer"&gt;https://github.com/JonathanSolvesProblems/will-it-focus&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;studio/schemaTypes/&lt;/code&gt;: the schema&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data/build-records.mjs&lt;/code&gt;: builds every record from its source, with the verbatim quote&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/check_quotes.py&lt;/code&gt;: fails if any quote in the dataset is not on the stated page of its PDF, or not in the collected page text for web sources&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/check_claims.py&lt;/code&gt;: fails if a number in this post or the README disagrees with the data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;web/src/agent/&lt;/code&gt;: the agent, the quote gate and the cost guards&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eval/&lt;/code&gt;: 20 questions, three runs each, and every answer the agent gave&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A longer write-up of the build is on my site: &lt;a href="https://jonathanandrei.com/blog/will-it-focus-camera-autofocus-manufacturer-documents/" rel="noopener noreferrer"&gt;https://jonathanandrei.com/blog/will-it-focus-camera-autofocus-manufacturer-documents/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Two Sanity Context endpoints, one agent.&lt;/strong&gt; The agent connects to both over MCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;will-it-focus&lt;/code&gt; serves the dataset in GROQ mode. This is where verdicts come from.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;will-it-focus-kb&lt;/code&gt; serves a Knowledge Base built from six manufacturer PDFs: the T5i manual (388 pages), both 18-55 kit lens instruction sheets, Canon Canada's 2013 T5i launch release, and Sigma's two MC-11 compatibility tables. Each file counted as one document against the 150 limit, including the 388-page manual.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I started with one endpoint and attached both sources. Saving the dataset replaced the Knowledge Base rather than adding to it, so I split them. Both tool sets get a prefix so the agent can tell &lt;code&gt;dataset_initial_context&lt;/code&gt; from &lt;code&gt;kb_initial_context&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema.&lt;/strong&gt; 131 documents across seven types: 35 bodies, 34 lenses, 10 mounts, 5 adapters, 3 sensor formats, 36 compatibility records and 8 focus caveats. A few decisions that turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Autofocus is split by how you shoot (&lt;code&gt;viewfinderPhoto&lt;/code&gt;, &lt;code&gt;liveViewPhoto&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;), because the same body focuses with different hardware in each.&lt;/li&gt;
&lt;li&gt;A compatibility record is only as wide as its source. Canon's note on the 18-55 STM names four bodies, so the record references four bodies. When Sigma's table does not name bodies, the record references none, which the agent reads as "not limited to particular bodies."&lt;/li&gt;
&lt;li&gt;Every field a source might not state accepts "not stated by any source". A schema that forces yes or no forces the model to guess.&lt;/li&gt;
&lt;li&gt;Each fact carries a &lt;code&gt;source&lt;/code&gt; object: URL, publisher, page, retrieved date, and the exact sentence it rests on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where the Knowledge Base fits.&lt;/strong&gt; It is good at explaining. It rewrote the six PDFs into ten navigable entries, and the agent uses them for the why. It also rewrites, which is its job, so an entry is a summary and not Canon's words. The page labels every Knowledge Base point that way. The first time I ran the agent on the Knowledge Base alone, it put quotation marks around 13 sentences, and none of them appear in any of the six documents. That is what pushed me to keep the verbatim text in typed records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured.&lt;/strong&gt; 20 questions whose answers are in Sigma's, Canon's and Metabones' own tables and manuals, run three ways on gpt-5.4-mini:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sanity Context sources&lt;/th&gt;
&lt;th&gt;Verdicts matching the manufacturer&lt;/th&gt;
&lt;th&gt;Quoted passages found word for word in a manufacturer document&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge Base only&lt;/td&gt;
&lt;td&gt;37 of 43&lt;/td&gt;
&lt;td&gt;1 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset only&lt;/td&gt;
&lt;td&gt;39 of 43&lt;/td&gt;
&lt;td&gt;28 of 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both (what the app runs)&lt;/td&gt;
&lt;td&gt;38 of 43&lt;/td&gt;
&lt;td&gt;21 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The verdicts are close either way. The difference is whether the words in quotation marks belong to the manufacturer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema fixes the eval found.&lt;/strong&gt; Two of my own mistakes showed up in the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I first stored Sigma's DMF column inside a free-text note. The agent could not query it and answered "not covered" on every DMF question. It is now a typed field.&lt;/li&gt;
&lt;li&gt;The smaller model missed records because "Sony a7 IV" did not match the alias "a7 IV", and it then guessed an id. Bodies and lenses now carry the names people actually type, and the agent is told never to construct an id. Most of its misses went away, which is how the dataset-only row above got to 39 of 43.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it does not do.&lt;/strong&gt; The gate proves a quote is in the record it cites, and checks each AF state against the cited records. It does not prove the agent cited the right record for your exact combination. The dataset is small: the T5i era of Canon, Sigma's MC-11, Metabones' Mark V and Canon's EF-EOS R adapter. Five flange distances come from Wikipedia because no manufacturer page states them, and those records say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Project ID: &lt;code&gt;qnl9jh8n&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;code&gt;production&lt;/code&gt; (public). Example: &lt;a href="https://qnl9jh8n.api.sanity.io/v2025-02-19/data/query/production?query=*%5B_type%3D%3D%22compatibilityRecord%22%5D" rel="noopener noreferrer"&gt;every compatibility record&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Studio: &lt;a href="https://will-it-focus.sanity.studio" rel="noopener noreferrer"&gt;https://will-it-focus.sanity.studio&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>ai</category>
      <category>sanity</category>
    </item>
    <item>
      <title>A maintainer, me, and Sentry's own AI all reached for the same wrong fix</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Wed, 19 Aug 2026 23:35:04 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/a-maintainer-me-and-sentrys-own-ai-all-reached-for-the-same-wrong-fix-4ef4</link>
      <guid>https://dev.to/jonathansolvesstuff/a-maintainer-me-and-sentrys-own-ai-all-reached-for-the-same-wrong-fix-4ef4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three people tried to fix the same memory leak in Sentry's Python SDK. One was a Sentry maintainer. One was me. One was Sentry's own AI debugger.&lt;/p&gt;

&lt;p&gt;All three fixes were wrong, and they were wrong for the same underlying reason. That convergence is the actual story, and it took me a while to see it, because I was too busy being pleased with my own version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DedupeIntegration&lt;/code&gt; remembers the last exception it saw so it can drop a duplicate report. It stores a weak reference, because holding the exception would hold its traceback, and a traceback holds every frame, and a frame holds every local variable in it.&lt;/p&gt;

&lt;p&gt;Then there is this:&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="c1"&gt;# we can only weakref non builtin types
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot weak-reference a &lt;code&gt;ValueError&lt;/code&gt;. So the fallback runs, and it does the exact thing the weak reference existed to prevent. It lives in a &lt;code&gt;ContextVar&lt;/code&gt;, so under asyncio every task keeps its own copy: one retained exception, one traceback, one full set of frame locals, per live task.&lt;/p&gt;

&lt;p&gt;Measured across 200 long-lived sessions holding a megabyte each: 205 MB retained, all 200 still reachable after &lt;code&gt;gc.collect()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That part I have written up in full &lt;a href="https://jonathanandrei.com/blog/sentry-python-dedupe-memory-leak-weakref-fix/" rel="noopener noreferrer"&gt;elsewhere&lt;/a&gt;. This post is about the three fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt one: the maintainer
&lt;/h2&gt;

&lt;p&gt;Digging through the fork's branch list, I found two abandoned branches from September 2025, neither merged. The first replaces the stored exception with a SHA-256 fingerprint over &lt;code&gt;(type_module, type_name, id(exc_value))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is a reasonable instinct. Store a cheap value, not the object. The problem is &lt;code&gt;id()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not retaining the exception is precisely what frees its address, and CPython reuses freed addresses immediately. Over 2000 distinct, sequentially allocated &lt;code&gt;ValueError&lt;/code&gt;s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distinct exceptions created : 2000
distinct fingerprints       : 2
address-reuse collisions    : 1998
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fingerprints for two thousand distinct errors. Every collision is a real error that gets silently dropped as a duplicate. The fix trades a memory leak for losing almost every error you report, which is worse than the leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt two: me
&lt;/h2&gt;

&lt;p&gt;I did not see that branch until much later, which is lucky, because I made the same class of mistake independently.&lt;/p&gt;

&lt;p&gt;My idea was a value fingerprint with no &lt;code&gt;id()&lt;/code&gt; in it: exception type, message, and the origin frame from the traceback. All immutable primitives. Nothing retained. I was confident enough to post it publicly on the issue.&lt;/p&gt;

&lt;p&gt;The SDK's own test suite killed it in two different ways within about a minute of running.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test_breadcrumbs&lt;/code&gt; captures two distinct, never-raised &lt;code&gt;ValueError()&lt;/code&gt; instances. No traceback, no message, so both fingerprints are identical and the second event is dropped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test_option_before_breadcrumb&lt;/code&gt; is worse. It calls the same function three times, each raising a separate &lt;code&gt;ValueError("aha!")&lt;/code&gt; from the same line. Same type, same message, same origin frame. Three identical fingerprints, two events wrongly deduplicated.&lt;/p&gt;

&lt;p&gt;The flaw is not in my choice of fields. It is structural:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A value fingerprint cannot distinguish &lt;strong&gt;"the same exception object captured twice"&lt;/strong&gt; from &lt;strong&gt;"the same error raised twice from the same line."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first must deduplicate. The second must not. By value, they are identical. No amount of extra fields fixes that, because the thing being asked for is not a property of the value.&lt;/p&gt;

&lt;p&gt;I went back to the issue and said I had been wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt three: the AI
&lt;/h2&gt;

&lt;p&gt;Later I pointed Sentry's own AI debugger, Seer, at the issue in my own Sentry project.&lt;/p&gt;

&lt;p&gt;Its diagnosis was genuinely excellent, and better than I expected. It reached past my application code into a third-party SDK, identified the &lt;code&gt;ContextVar&lt;/code&gt; strong reference, identified that &lt;code&gt;weakref.ref&lt;/code&gt; raises &lt;code&gt;TypeError&lt;/code&gt; on builtin exception types, and identified that ContextVars are per-task under asyncio. That last detail had cost me a full debugging round to work out on my own. It cited &lt;code&gt;dedupe.py&lt;/code&gt; lines as evidence, so it had gone and read the source rather than guessing from a stack trace.&lt;/p&gt;

&lt;p&gt;Then it proposed the fix:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;store a hashable identity tuple like &lt;code&gt;(type(exc), id(exc))&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;id()&lt;/code&gt; again. I ran its exact tuple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distinct errors raised         : 2000
events wrongly dropped as dupe : 1999  (100.0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent parties. Two humans and a model. Two different wrong answers that are secretly the same wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing all three of us did
&lt;/h2&gt;

&lt;p&gt;Every one of us tried to represent &lt;strong&gt;identity&lt;/strong&gt; with a &lt;strong&gt;value&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An object's identity is not a property you can read off it. It is the fact of the object existing, distinct from every other object, for as long as it lives. A fingerprint made of type and message describes what the exception &lt;em&gt;is like&lt;/em&gt;. An address describes where it &lt;em&gt;currently sits&lt;/em&gt;. Neither survives the thing that makes identity useful, which is that two objects that look the same are still two objects.&lt;/p&gt;

&lt;p&gt;Once I could say that sentence, the fix was obvious, and it is not a fingerprint at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_DedupeToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;__slots__&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;__weakref__&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;Attach one token to the exception and weakly reference the token. There is exactly one per exception object and it lives exactly as long as the exception does, so comparing tokens is comparing identity. The &lt;code&gt;ContextVar&lt;/code&gt; holds nothing that keeps a traceback alive, and deduplication behaviour does not change at all.&lt;/p&gt;

&lt;p&gt;Same 200 sessions, after the fix: 0.7 MB, nothing pinned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;There is a coda that I only found while going back through a screen recording, and it is the most interesting thing I learned all week.&lt;/p&gt;

&lt;p&gt;Seer did not only propose a plan. It also opened a pull request with generated code. &lt;strong&gt;The code is not what the plan said.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No identity tuple anywhere. It simply stops storing for builtin exceptions. That does remove the leak, and it has none of the address-reuse problem, but it silently disables deduplication for the exception types most errors actually are. It is also, precisely, the fix the maintainers had already declined on the issue months earlier.&lt;/p&gt;

&lt;p&gt;So the plan and the patch disagree with each other, and they are wrong in two different ways.&lt;/p&gt;

&lt;p&gt;I want to be fair here, because the diagnosis was the hard part and it got that right, from evidence, without my framing. But if you let an agent open pull requests: &lt;strong&gt;read the diff, not the summary.&lt;/strong&gt; They are not required to match, and here they did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The comment &lt;code&gt;# we can only weakref non builtin types&lt;/code&gt; sat directly above the line that caused the leak, for years. It described the hazard accurately and then the next line walked into it. A fallback is still a code path, and this one ran for the overwhelming majority of real exceptions.&lt;/p&gt;

&lt;p&gt;And the more useful lesson: when several competent people independently produce the same wrong answer, that is information. It usually means the obvious framing of the problem is the thing that is wrong, not the people. Three of us reached for a fingerprint because "store something small instead of the object" is a good habit. The habit was fine. The question was wrong.&lt;/p&gt;

&lt;p&gt;Everything here is reproducible, including all three failed fixes, at &lt;a href="https://github.com/JonathanSolvesProblems/sentry-dedupe-leak-repro" rel="noopener noreferrer"&gt;JonathanSolvesProblems/sentry-dedupe-leak-repro&lt;/a&gt;. &lt;code&gt;id_reuse_hazard.py&lt;/code&gt; runs the maintainer's approach and Seer's, side by side, and prints exactly how many errors each one would lose.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Python can't weakref a ValueError. Sentry's deduplication found out the expensive way.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Wed, 19 Aug 2026 23:14:14 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/python-cant-weakref-a-valueerror-sentrys-deduplication-found-out-the-expensive-way-4c5i</link>
      <guid>https://dev.to/jonathansolvesstuff/python-cant-weakref-a-valueerror-sentrys-deduplication-found-out-the-expensive-way-4c5i</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/2B5g7c9AwB0" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two and a half minutes: the leak, the two fixes that do not work, and what Sentry's Seer made of it. Everything below is the written version, with the parts the video did not have room for.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/getsentry/sentry-python" rel="noopener noreferrer"&gt;sentry-python&lt;/a&gt; is Sentry's Python SDK. One of its default integrations is &lt;code&gt;DedupeIntegration&lt;/code&gt;, which stops the same error being reported twice. The mechanism is about as simple as it gets: remember the last exception you saw, and if the next one is the same object, drop the event.&lt;/p&gt;

&lt;p&gt;Remembering an exception is where it gets interesting, because remembering it is exactly what you must not do. An exception holds its traceback, a traceback holds its frames, and a frame holds every local variable in it. Keep the exception and you keep all of that.&lt;/p&gt;

&lt;p&gt;The SDK knew this. That is why it stored a weak reference:&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="c1"&gt;# we can only weakref non builtin types
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment tells you the whole story. You cannot take a weak reference to a builtin exception. &lt;code&gt;ValueError&lt;/code&gt;, &lt;code&gt;KeyError&lt;/code&gt;, &lt;code&gt;TypeError&lt;/code&gt;, the ones almost every error actually is. So &lt;code&gt;weakref.ref(exc)&lt;/code&gt; raises &lt;code&gt;TypeError&lt;/code&gt;, and the fallback quietly does the one thing the weak reference existed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getsentry/sentry-python/issues/6094" rel="noopener noreferrer"&gt;Issue #6094&lt;/a&gt; reported it as unexplained memory growth in an asyncio web crawler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why asyncio makes it hurt
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;_last_seen&lt;/code&gt; is a &lt;code&gt;ContextVar&lt;/code&gt;. Under asyncio, every task gets its own copy of context. So this is not one retained exception process-wide. It is &lt;strong&gt;one retained exception per live task&lt;/strong&gt;, each one dragging along its traceback and every frame local reachable from it.&lt;/p&gt;

&lt;p&gt;The reporter's frames held fetched response bodies between 500 KB and 1 MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measuring it honestly
&lt;/h3&gt;

&lt;p&gt;My first instinct was to call it an unbounded leak. I built a worker pool, ran it, and it stayed flat. That was worth finding out before I wrote it down anywhere.&lt;/p&gt;

&lt;p&gt;A fixed pool does not grow, because each worker's next error overwrites its previous one. Retention is bounded at (live tasks x payload). The growth story is not errors over time, it is &lt;strong&gt;tasks over time&lt;/strong&gt;: one task per session, per subscription, per connection.&lt;/p&gt;

&lt;p&gt;So I measured against live task count instead. Each task fails once, reports it, then stays alive the way a real session handler does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  live tasks |     retained |   per task |  sessions pinned
-------------+--------------+------------+-----------------
          25 |      26.2 MB |    1047 KB |      25 / 25
          50 |      52.1 MB |    1042 KB |      50 / 50
         100 |     103.3 MB |    1033 KB |     100 / 100
         200 |     205.4 MB |    1027 KB |     200 / 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1024 KB retained per live task, dead straight, and &lt;code&gt;sessions pinned&lt;/code&gt; counts weak references to session objects that are still reachable after &lt;code&gt;gc.collect()&lt;/code&gt;. Not a sampling artifact. The garbage collector cannot touch them because a live &lt;code&gt;ContextVar&lt;/code&gt; genuinely still points at them.&lt;/p&gt;

&lt;p&gt;At 200 concurrent sessions that is 205 MB that never comes back.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix I got wrong first
&lt;/h3&gt;

&lt;p&gt;The maintainers had already rejected the obvious fix. The reporter proposed skipping dedupe for builtins, and Sentry declined, saying they wanted "a more robust fingerprinting approach that can also be used for built-in exceptions" instead.&lt;/p&gt;

&lt;p&gt;So I proposed a fingerprint: exception type, message, and the origin frame from the traceback. All immutable primitives, nothing retained. I posted it on the issue.&lt;/p&gt;

&lt;p&gt;Then the existing test suite told me I was wrong, in two different ways.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test_breadcrumbs&lt;/code&gt; calls &lt;code&gt;capture_exception(ValueError())&lt;/code&gt; twice with two distinct, never-raised exceptions. No traceback, no message, so both fingerprints are identical and the second event gets dropped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test_option_before_breadcrumb&lt;/code&gt; was worse. It calls the same function three times, each raising a separate &lt;code&gt;ValueError("aha!")&lt;/code&gt; from the same line. Same type, same message, same origin frame. Three fingerprints, all identical, two events wrongly deduplicated.&lt;/p&gt;

&lt;p&gt;That is the flaw in the whole idea. &lt;strong&gt;A value fingerprint cannot tell "the same exception object twice" apart from "the same error raised repeatedly from the same line."&lt;/strong&gt; The first must deduplicate. The second must not. No fingerprint distinguishes them, because by value they are the same.&lt;/p&gt;

&lt;p&gt;I went back to the issue and said so.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attempt that came before mine
&lt;/h3&gt;

&lt;p&gt;Late on, I went looking through the fork's branch list and found two abandoned maintainer branches from September 2025: &lt;code&gt;antonpirker/dedupe-integration-memory-usage&lt;/code&gt; and &lt;code&gt;antonpirker/make-dedupe-integration-more-memory-efficient&lt;/code&gt;. Neither was merged.&lt;/p&gt;

&lt;p&gt;The first one fingerprints on &lt;code&gt;(type_module, type_name, id(exc_value))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;id()&lt;/code&gt; is the interesting part. Once you stop retaining the exception, which is the entire point of the change, its address becomes immediately reusable, and CPython reuses addresses aggressively. Modelling that fingerprint over 2000 distinct, sequentially allocated &lt;code&gt;ValueError&lt;/code&gt;s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;distinct&lt;/span&gt; &lt;span class="err"&gt;exceptions&lt;/span&gt; &lt;span class="py"&gt;created&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000&lt;/span&gt;
&lt;span class="err"&gt;distinct&lt;/span&gt; &lt;span class="py"&gt;fingerprints&lt;/span&gt;       &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="err"&gt;address-reuse&lt;/span&gt; &lt;span class="py"&gt;collisions&lt;/span&gt;    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1998&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fingerprints for two thousand distinct errors. Each collision is a real error that would be silently dropped as a duplicate.&lt;/p&gt;

&lt;p&gt;I mention this not to dunk on an abandoned branch, which is a draft nobody shipped, but because it is the third distinct way I watched this problem punish an obvious solution. Fingerprinting on value collapses errors that came from the same line. Fingerprinting on address collapses errors that reused the same memory. The information being fingerprinted just is not sufficient to express object identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix that works
&lt;/h3&gt;

&lt;p&gt;The problem was never that identity was the wrong key. It was that holding the object was the wrong way to hold identity.&lt;/p&gt;

&lt;p&gt;So keep the identity and drop the object. Attach a small weak-referenceable token to the exception, then weakly reference the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_DedupeToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;__slots__&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;__weakref__&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;There is exactly one token per exception object, and it lives precisely as long as the exception does. &lt;code&gt;last_seen is token&lt;/code&gt; is therefore equivalent to the old &lt;code&gt;last_seen is exc&lt;/code&gt;, while the &lt;code&gt;ContextVar&lt;/code&gt; holds nothing that keeps a traceback alive.&lt;/p&gt;

&lt;p&gt;Deduplication behaviour is &lt;strong&gt;unchanged&lt;/strong&gt;, which is the part that matters given the maintainers' concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The whole change to the hot path:&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;exc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc_info&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="n"&gt;new_last_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Any&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# We can only weakref non builtin types.
&lt;/span&gt;    &lt;span class="n"&gt;new_last_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;is_duplicate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_seen&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Builtin exception. Referencing it strongly here would pin its
&lt;/span&gt;    &lt;span class="c1"&gt;# traceback and every frame local in that traceback for the
&lt;/span&gt;    &lt;span class="c1"&gt;# lifetime of the ContextVar (#6094). Weakly reference an
&lt;/span&gt;    &lt;span class="c1"&gt;# identity token carried by the exception instead, which dies
&lt;/span&gt;    &lt;span class="c1"&gt;# with it and keeps dedupe keyed on exception identity.
&lt;/span&gt;    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_identity_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&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;token&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new_last_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;is_duplicate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_seen&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new_last_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
        &lt;span class="n"&gt;is_duplicate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_seen&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the token lookup, after hardening (more on that below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_identity_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;BaseException&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;Optional[_DedupeToken]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&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;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__traceback__&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;exc_dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__dict__&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc_dict&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="n"&gt;_DEDUPE_TOKEN_ATTR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_DedupeToken&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;

        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_DedupeToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;exc_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_DEDUPE_TOKEN_ATTR&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Exceptions can define ``__dict__`` as a property returning anything,
&lt;/span&gt;        &lt;span class="c1"&gt;# or back it with a mapping that refuses mutation. None of that may
&lt;/span&gt;        &lt;span class="c1"&gt;# break event processing, so fall back to the previous behaviour.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;__traceback__ is None&lt;/code&gt; check is deliberate. An exception that was never raised has no frames to pin, so there is nothing to fix and no reason to touch it. That keeps &lt;code&gt;vars(exc)&lt;/code&gt; clean for the common case.&lt;/p&gt;

&lt;p&gt;Branch: &lt;a href="https://github.com/getsentry/sentry-python/compare/master...JonathanSolvesProblems:sentry-python:fix/dedupe-builtin-exception-retention" rel="noopener noreferrer"&gt;fix/dedupe-builtin-exception-retention&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in this post is reproducible. The harness is at &lt;a href="https://github.com/JonathanSolvesProblems/sentry-dedupe-leak-repro" rel="noopener noreferrer"&gt;JonathanSolvesProblems/sentry-dedupe-leak-repro&lt;/a&gt;: the scaling measurement, the traceback probe, the five adversarial checks against my own fix, and the script that verifies the LLM review claim by claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Five tests, and the first one fails without the fix, which is the only thing that makes it a regression test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test_dedupe_does_not_retain_builtin_exception&lt;/code&gt; puts a sentinel in the raising frame and asserts it is collectable afterwards&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test_dedupe_still_dedupes_builtin_exception&lt;/code&gt; re-raises the same object and asserts one event&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test_dedupe_distinguishes_equal_builtin_exceptions&lt;/code&gt; raises two identical-looking errors from the same line and asserts two events. This is the one fingerprinting broke&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test_dedupe_leaves_unraised_exception_untouched&lt;/code&gt; asserts &lt;code&gt;vars(exc) == {}&lt;/code&gt; for a never-raised exception&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test_dedupe_survives_exotic_exception_dict&lt;/code&gt; covers the crashes described below&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also verified what the &lt;code&gt;ContextVar&lt;/code&gt; actually holds, rather than trusting the memory numbers:&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;
&lt;code&gt;_last_seen&lt;/code&gt; holds&lt;/th&gt;
&lt;th&gt;1 MB payload after &lt;code&gt;gc.collect()&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;master&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ValueError&lt;/code&gt; (strong ref)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;alive&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ReferenceType&lt;/code&gt; (weakref)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;freed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One process note, because it cost me an hour and nearly cost me a wrong claim. Once the fix was committed to a branch, &lt;code&gt;git stash push &amp;lt;file&amp;gt;&lt;/code&gt; had nothing to stash, so my "before and after" runs were quietly comparing the fix against itself. Two measurements came back identical and I believed them for longer than I should have. The tell was a probe printing whether the new symbol was importable, which said &lt;code&gt;True&lt;/code&gt; in both columns. Baselines have to be taken with &lt;code&gt;git checkout master -- &amp;lt;file&amp;gt;&lt;/code&gt;, and the baseline needs to prove it is actually the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sentry features used:&lt;/strong&gt; Error Monitoring, Releases, custom Contexts and Tags, Issue Grouping, Seer (Autofix root cause analysis).&lt;/p&gt;

&lt;p&gt;The interesting part of instrumenting this was discovering that Sentry could not see the bug, and why.&lt;/p&gt;

&lt;p&gt;I built a small asyncio service that opens one long-lived task per session, hits one ordinary &lt;code&gt;ValueError&lt;/code&gt;, reports it, and keeps the session open. Ran it against a real Sentry project twice, tagged as two releases, &lt;code&gt;dedupe@unpatched&lt;/code&gt; and &lt;code&gt;dedupe@patched&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sentry showed a completely healthy application. One issue, &lt;code&gt;ValueError: malformed frame on session N&lt;/code&gt;, 240 events, &lt;code&gt;handled: yes&lt;/code&gt;, &lt;code&gt;mechanism: generic&lt;/code&gt;. Exactly what you would expect from a service that reports its own handled errors. Nothing in it suggests 62 MB is being retained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is the actual lesson.&lt;/strong&gt; This bug produces no error. The &lt;code&gt;ValueError&lt;/code&gt; is deliberate and correctly handled. Error monitoring alone is structurally incapable of surfacing a retention bug, because retention is not an event.&lt;/p&gt;

&lt;p&gt;There is a nice detail buried in that first issue, too. The frame local Sentry captured for the raising frame reads &lt;code&gt;session [Filtered]&lt;/code&gt;, scrubbed by default PII protection. The one object whose retention was the entire problem was the one thing redacted out of the report.&lt;/p&gt;

&lt;p&gt;So I used Sentry two other ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, as a measuring instrument.&lt;/strong&gt; Every error event carries a custom &lt;code&gt;memory&lt;/code&gt; context with the retained figure at the moment it was sent. The errors are identical across both releases; the context is not. Comparing &lt;code&gt;dedupe@unpatched&lt;/code&gt; against &lt;code&gt;dedupe@patched&lt;/code&gt; on the same issue turns an invisible bug into a visible diff, without a single new error being raised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, by making the leak raise something.&lt;/strong&gt; The demo checks its own retention after the sessions are open, and when session state is still reachable it reports a distinct &lt;code&gt;RetentionLeak&lt;/code&gt; issue carrying the diagnosis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sessions_pinned         &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60/60&lt;/span&gt;
&lt;span class="na"&gt;retained_mb             &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;62.6&lt;/span&gt;
&lt;span class="na"&gt;referrer_chain          &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Session &amp;lt;- frame(handle_message) &amp;lt;- traceback &amp;lt;- traceback &amp;lt;- exception(ValueError)&lt;/span&gt;
&lt;span class="na"&gt;dedupe_last_seen_holds  &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ValueError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the whole bug in one field, and getting it was its own small lesson. My first attempt read &lt;code&gt;DedupeIntegration._last_seen&lt;/code&gt; from the parent task and reported &lt;code&gt;nothing&lt;/code&gt;. &lt;code&gt;_last_seen&lt;/code&gt; is a &lt;code&gt;ContextVar&lt;/code&gt;, so every asyncio task has its own copy and it always reads empty from outside. Reading it from inside the capturing task gives &lt;code&gt;ValueError&lt;/code&gt; on the unpatched release and &lt;code&gt;ReferenceType&lt;/code&gt; on the patched one. The thing that made the diagnostic hard to write is precisely the thing that makes the bug scale with task count.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;release&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;_last_seen&lt;/code&gt; holds&lt;/th&gt;
&lt;th&gt;sessions pinned&lt;/th&gt;
&lt;th&gt;retained&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dedupe@unpatched&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValueError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 / 60&lt;/td&gt;
&lt;td&gt;62.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dedupe@patched&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ReferenceType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 60&lt;/td&gt;
&lt;td&gt;1.3 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What Seer made of it
&lt;/h3&gt;

&lt;p&gt;I connected the &lt;code&gt;sentry-python&lt;/code&gt; fork to the project and ran Autofix on the &lt;code&gt;RetentionLeak&lt;/code&gt; issue. I expected it to stop at my demo code, because that is where the stack trace points. It did not.&lt;/p&gt;

&lt;p&gt;Its root cause, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DedupeIntegration stores strong exception references in a ContextVar for builtin exceptions, retaining tracebacks and all frame locals (including large session buffers) for the asyncio task's lifetime.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the supporting chain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The ValueError exceptions are kept alive because &lt;code&gt;DedupeIntegration._last_seen&lt;/code&gt; ContextVar holds a strong reference to each one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;weakref.ref(exc)&lt;/code&gt; raises TypeError for builtin exception types like ValueError, so the fallback path stores the bare exception object instead of a weakref.&lt;/li&gt;
&lt;li&gt;Each asyncio task has its own copy of the ContextVar (per-task context), so the strong reference to the exception persists for the entire lifetime of each long-lived task.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pgsebefht72oxxc4e2n.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pgsebefht72oxxc4e2n.jpg" alt="Seer's root cause analysis, identifying the ContextVar strong reference, the weakref TypeError fallback, and the per-task context" width="800" height="723"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the bug, exactly, including the per-task ContextVar detail that cost me a debugging round of my own to work out. It cited &lt;code&gt;dedupe.py L1-L62&lt;/code&gt; as evidence, so it went and read the SDK source rather than guessing from the stack trace. Its five reproduction steps are accurate enough to hand to someone else.&lt;/p&gt;

&lt;p&gt;Then it proposed a fix:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In &lt;code&gt;dedupe.py&lt;/code&gt;, change the &lt;code&gt;except TypeError&lt;/code&gt; fallback from &lt;code&gt;integration._last_seen.set(exc)&lt;/code&gt; to store a hashable identity tuple like &lt;code&gt;(type(exc), id(exc))&lt;/code&gt; instead of the exception object itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7zsg7onfcsaj3st1bll.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7zsg7onfcsaj3st1bll.jpg" alt="Seer's proposed plan: replace the bare exception fallback with an identity tuple" width="800" height="723"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That fix does not work, and it fails for the same reason the abandoned maintainer branch does. Storing only an identity key is what allows the exception to be freed, and a freed address is immediately reusable. Running Seer's exact tuple over 2000 distinct errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;distinct&lt;/span&gt; &lt;span class="err"&gt;errors&lt;/span&gt; &lt;span class="py"&gt;raised&lt;/span&gt;         &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000&lt;/span&gt;
&lt;span class="err"&gt;events&lt;/span&gt; &lt;span class="err"&gt;wrongly&lt;/span&gt; &lt;span class="err"&gt;dropped&lt;/span&gt; &lt;span class="err"&gt;as&lt;/span&gt; &lt;span class="py"&gt;dupe&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1999  (100.0%)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It would trade a memory leak for silently discarding almost every error you report.&lt;/p&gt;

&lt;p&gt;There is a wrinkle I only found afterwards, and it is the most interesting thing in this whole section. Seer also opened a pull request against my fork with generated code, and &lt;strong&gt;the code is not what the plan said&lt;/strong&gt;. It does not store an identity tuple at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_seen&lt;/span&gt;&lt;span class="p"&gt;.&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;weakref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That skips storing anything for builtins. It does remove the leak, and it has none of the address-reuse problem, but it silently disables deduplication for exactly the exception types most errors actually are. It is the fix the reporter originally proposed and that the maintainers explicitly declined, for the reason quoted at the top of this post.&lt;/p&gt;

&lt;p&gt;So the plan and the patch disagree with each other, and they are wrong in two different ways. If you let an agent open pull requests, that is worth knowing: the prose it shows you for review is not necessarily the diff it writes.&lt;/p&gt;

&lt;p&gt;I want to be fair about what that means, because it is not a gotcha. &lt;strong&gt;Seer did the hard part correctly.&lt;/strong&gt; Diagnosing this required reading past the stack trace into a third-party SDK, understanding weakref support on builtin types, and knowing that ContextVars are per-task under asyncio. It got all three from one event and the source. The part it got wrong is the part that requires knowing how CPython recycles memory addresses, which is not visible anywhere in the evidence it was given.&lt;/p&gt;

&lt;p&gt;And it is genuinely interesting that three independent attempts, an abandoned branch by a Sentry maintainer, my own first attempt, and Seer, all reached for a fingerprint or an identity key, and all three are wrong for the same underlying reason. The information available to a fingerprint is not sufficient to express object identity. That the AI converged on the same wrong answer as two humans is more a statement about the problem than about the AI.&lt;/p&gt;

&lt;p&gt;What I actually got out of Seer was the thing I would have wanted from a colleague: an independent confirmation of the diagnosis, arrived at from the evidence rather than from my framing of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;I had already run my own adversarial pass on the patch: pickling, &lt;code&gt;copy&lt;/code&gt;/&lt;code&gt;deepcopy&lt;/code&gt;, payload leakage, &lt;code&gt;__slots__&lt;/code&gt; exceptions, and retention through &lt;code&gt;__context__&lt;/code&gt; chains. All five passed.&lt;/p&gt;

&lt;p&gt;So I gave the diff to Gemini 3.6 Flash, told it exactly what I had already covered, and asked only for failure modes I had missed, each with a snippet I could run. Then I ran all of them, because a review you do not verify is just a second opinion with extra steps.&lt;/p&gt;

&lt;p&gt;It returned six. The scoreboard:&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;Claim&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__dict__&lt;/code&gt; as a property returning &lt;code&gt;None&lt;/code&gt; crashes event processing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Confirmed, real crash&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;__dict__&lt;/code&gt; backed by a mapping that refuses mutation crashes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Confirmed, real crash&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;a dynamic &lt;code&gt;__dict__&lt;/code&gt; breaks token identity&lt;/td&gt;
&lt;td&gt;Confirmed, fails open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;the token is visible in &lt;code&gt;vars(exc)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Confirmed, real tradeoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;a thread race yields mismatched tokens&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Refuted&lt;/strong&gt;, 0 of 200 attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__dict__.clear()&lt;/code&gt; loses the token&lt;/td&gt;
&lt;td&gt;Confirmed, fails open&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claims 1 and 2 were genuine bugs that my own pass had missed. My &lt;code&gt;except (AttributeError, TypeError)&lt;/code&gt; was too narrow, so an exception class doing something unusual with &lt;code&gt;__dict__&lt;/code&gt; could crash Sentry's event processing. The SDK's own contributing guide says integrations must not crash applications, so those were not academic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NonDictException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__dict__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# before: AttributeError: 'NoneType' object has no attribute 'get'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claim 5 was wrong, and I only know that because I ran it 200 times with a &lt;code&gt;threading.Barrier&lt;/code&gt; instead of nodding along.&lt;/p&gt;

&lt;p&gt;Claims 3 and 6 are real but fail &lt;strong&gt;open&lt;/strong&gt;: you get a duplicate event rather than losing one. That is the correct direction to fail.&lt;/p&gt;

&lt;p&gt;Claim 4 is a fair criticism and I have not made it go away. The token lives in &lt;code&gt;exc.__dict__&lt;/code&gt;, so it shows up in &lt;code&gt;vars(exc)&lt;/code&gt; and makes &lt;code&gt;json.dumps(vars(exc))&lt;/code&gt; raise. I narrowed it to raised exceptions only, and the SDK already does the same kind of thing (&lt;code&gt;sentry_sdk/integrations/django/__init__.py&lt;/code&gt; sets &lt;code&gt;_sentry_drf_request_backref&lt;/code&gt; on a user object), but it is a real tradeoff and the PR says so rather than hiding it.&lt;/p&gt;

&lt;p&gt;Two real crashes found, one confidently wrong claim caught, in a review that took about four minutes. That is a good trade, and it only works if you treat the output as a list of hypotheses instead of a list of findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from this bug
&lt;/h2&gt;

&lt;p&gt;The comment &lt;code&gt;# we can only weakref non builtin types&lt;/code&gt; was right there, in the code, for years. It described the exact hazard and then the next line walked straight into it. A fallback path is still a code path, and this one ran for the overwhelming majority of real exceptions.&lt;/p&gt;

&lt;p&gt;The other thing: my measurements lied to me twice, in opposite directions. Once when I assumed unbounded growth and a flat graph corrected me, and once when a stash silently did nothing and two identical columns looked like a result. Both times the fix was the same, which was to make the harness prove its own setup before trusting a single number out of it.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Everyone Believes Black Dogs Get Left Behind. I Checked 99,916 Shelter Records. They Don't.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:18:08 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/everyone-believes-black-dogs-get-left-behind-i-checked-99916-shelter-records-they-dont-2ff5</link>
      <guid>https://dev.to/jonathansolvesstuff/everyone-believes-black-dogs-get-left-behind-i-checked-99916-shelter-records-they-dont-2ff5</guid>
      <description>&lt;p&gt;My family's dog died recently. He lived with my mom, I met him the day he was born, and I knew him almost his whole life. It was hard on all of us in the way that is difficult to explain to anyone who has not had it happen.&lt;/p&gt;

&lt;p&gt;A little while later my mom brought home another dog. He does not replace the first one, and nobody in my family pretends otherwise. But a house with a dog in it is a different house, and hers is whole again.&lt;/p&gt;

&lt;p&gt;That exchange happened about &lt;a href="https://www.aspca.org/helping-shelters-people-pets/us-animal-shelter-statistics" rel="noopener noreferrer"&gt;two million times last year in the United States alone&lt;/a&gt;, counting dogs only. What I had not thought about until this weekend is that there is a queue for it, that the queue is very long for some dogs and very short for others, and that the difference is not what almost everyone believes it is.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://jonathansolvesproblems.github.io/still-here/" rel="noopener noreferrer"&gt;Still Here&lt;/a&gt;&lt;/strong&gt; is a live board of every dog Austin Animal Center has an intake record for and no outcome record. As I write this there are &lt;strong&gt;510&lt;/strong&gt; of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpypr40qgzfjrc4932l5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpypr40qgzfjrc4932l5.jpg" alt="The board opens on Pancho, a white Cairn Terrier who has been waiting 449 days and has no photograph anywhere public" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The one at the top is called Pancho. He is a white Cairn Terrier, he was picked up as a stray on May 23, 2025, and he has been there &lt;strong&gt;449 days&lt;/strong&gt;. 99.7% of the dogs in his breed group were adopted in less time than he has already been waiting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every figure in this post is from the board on 16 August 2026. The site recomputes on every load, so by the time you read this the day counts will have moved and some of these dogs will have gone home.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then I went looking for the reason the dogs at the top of that board are the ones at the top.&lt;/p&gt;

&lt;p&gt;The board moves. Between two runs of the pipeline a day apart, nine dogs came off it: &lt;strong&gt;seven were adopted&lt;/strong&gt;, one was reclaimed by an owner who came looking, and one was transferred to a partner rescue. Four new dogs arrived. Baxter had been waiting 71 days and went home on the fifteenth.&lt;/p&gt;

&lt;h3&gt;
  
  
  The claim I set out to check
&lt;/h3&gt;

&lt;p&gt;If you have spent any time around animal shelters you have heard of &lt;strong&gt;black dog syndrome&lt;/strong&gt;: the belief that black dogs sit unadopted while lighter dogs go home. It is repeated in shelter training material, in local news segments, and in a great many well-meaning adoption posts every October.&lt;/p&gt;

&lt;p&gt;It is a genuinely checkable claim, and Austin has published enough records to check it.&lt;/p&gt;

&lt;p&gt;I pulled every completed dog stay the city has released since October 2013 and reconstructed how long each one lasted: &lt;strong&gt;99,916 stays&lt;/strong&gt;, of which &lt;strong&gt;51,413&lt;/strong&gt; ended in adoption. Then I took the median wait for each coat color.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;coat&lt;/th&gt;
&lt;th&gt;median days to adoption&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blue&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2,064&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brown Brindle&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2,521&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fawn&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;617&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brown&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7,085&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;White&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8,797&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chocolate&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1,163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Black&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13,922&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tan&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6,236&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cream&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;770&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Black dogs are in the fast half. Thirteen thousand nine hundred and twenty-two of them, leaving in a median of nine days, quicker than white, brown or chocolate.&lt;/p&gt;

&lt;p&gt;So the myth does not survive its first contact with the data. That much matches the published research: a &lt;a href="https://www.sciencedaily.com/releases/2016/02/160203185534.htm" rel="noopener noreferrer"&gt;2016 study by Christy Hoffman and colleagues&lt;/a&gt;, in &lt;em&gt;Animal Welfare&lt;/em&gt;, looked at nearly 16,700 records across two shelters and found black dogs left slightly faster than average at both.&lt;/p&gt;

&lt;p&gt;That study also reports the other half of what I found, which I did not know when I started looking: bully breeds stayed roughly 2.5 to 3 times longer than average. Two Pacific Northwest shelters, a decade earlier, a completely separate dataset from mine.&lt;/p&gt;

&lt;p&gt;But there is something much more interesting sitting in that same table, and it took me a second pass to see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The colors that wait are the colors pit bulls come in
&lt;/h3&gt;

&lt;p&gt;Look at the top of that list again. Blue, brown brindle, fawn. Those are not random slow colors. They are the coats you picture when you picture a pit bull.&lt;/p&gt;

&lt;p&gt;So I split every color by whether the dog was a bully-type breed, meaning pit bull, Staffordshire, or American bulldog:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo0bobz6ggka2rxmnhob.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo0bobz6ggka2rxmnhob.jpg" alt="Two charts on a shared scale: every bully-type coat lands between 25 and 32.5 days, every other coat between 7 and 11" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;coat&lt;/th&gt;
&lt;th&gt;bully-type&lt;/th&gt;
&lt;th&gt;everyone else&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brown&lt;/td&gt;
&lt;td&gt;32.5&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Black&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tan&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;White&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Red&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chocolate&lt;/td&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blue&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brown Brindle&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two columns do not overlap anywhere. Every bully-type cell is between 25 and 32.5 days. Every other cell is between 7 and 11. The &lt;em&gt;fastest&lt;/em&gt; bully-type coat is still 14 days slower than the &lt;em&gt;slowest&lt;/em&gt; coat outside the group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Within a breed group, coat color moves the median wait by at most 7.5 days. Changing the breed group moves it by 20.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Black only looked fast in the first table because black dogs are the &lt;em&gt;least&lt;/em&gt; likely to be bully-type: 12% of them, against 81% of the blue ones. The color signal was a breed signal the whole time. A black dog that is not a bully breed waits 8 days. A black pit bull waits 31.&lt;/p&gt;

&lt;p&gt;Across the whole corpus: &lt;strong&gt;bully-type dogs wait a median of 28 days, and every other dog waits 8.&lt;/strong&gt; That is 3.5 times, on 9,658 bully-type adoptions against 41,755 others.&lt;/p&gt;

&lt;p&gt;And it is not a historical curiosity. Of the 510 dogs sitting in that shelter today, &lt;strong&gt;216 are bully-type&lt;/strong&gt;. Four of the five longest waits on the board right now are pit bulls.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thing the adoption site does not tell you
&lt;/h3&gt;

&lt;p&gt;Austin publishes, in open data, exactly how long every dog has been in its care. Its adoption site shows none of it.&lt;/p&gt;

&lt;p&gt;I checked the listings the city actually serves to adopters. A listing carries breed, sex, size, age, kennel number, and a set of behavior tags. There is no intake date on it, no length of stay, and no way to sort or filter by either. There is a manual "Long-term resident" tag a staff member can tick, but nothing that gives you a number.&lt;/p&gt;

&lt;p&gt;So a person browsing Austin's adoption page can look straight at Fritz and have no idea he arrived on May 28 of last year.&lt;/p&gt;

&lt;p&gt;Both halves of that are public. They are just never printed next to each other. Still Here is the join.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/RNa5V26TXzI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Under two minutes, narrated over the live site, with the research citations on screen where the claims are made.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live board: &lt;a href="https://jonathansolvesproblems.github.io/still-here/" rel="noopener noreferrer"&gt;jonathansolvesproblems.github.io/still-here&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The board is a photographic contact sheet, one frame per dog, ordered by how long each has waited. The day count and the glow are that wait: the longer a dog has been there, the more it has burned into the plate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0m6qo969kn95n9slmcl5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0m6qo969kn95n9slmcl5.jpg" alt="The contact sheet, 510 frames of real dogs with real photographs from Austin's adoption listings" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every frame links to that dog's real adoption page, so clicking one takes you somewhere you could actually adopt it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ssvl2tdxgszr7pehhr5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ssvl2tdxgszr7pehhr5.jpg" alt="Canela, 323 days, with her photograph, an adopt button, and a play button to hear her record read aloud" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Things worth trying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search a breed.&lt;/strong&gt; Type &lt;code&gt;labrador&lt;/code&gt;, &lt;code&gt;husky&lt;/code&gt; or &lt;code&gt;brindle&lt;/code&gt;. Multiple words narrow rather than widen, so &lt;code&gt;black lab&lt;/code&gt; finds 19 dogs. Austin's own site lets you filter by breed but never tells you how long any of them has waited; this does both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Press play on any dog.&lt;/strong&gt; Each one reads its own record aloud, and each breed group opens with its own sound: a howl for the hounds, a husky's howl for the spitz breeds, a yip for the toy dogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter to "Over a year"&lt;/strong&gt; for the 18 dogs who have been there more than 365 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter to "Unnamed"&lt;/strong&gt; for the ones who arrived without a name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click any day count&lt;/strong&gt; to pin that dog to the top, then copy the link. &lt;code&gt;?dog=7950&lt;/code&gt; is Pancho.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/still-here" rel="noopener noreferrer"&gt;
        still-here
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      510 dogs have an intake record at the Austin animal shelter and no outcome record. This is the board.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Still Here&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;510 dogs have an intake record at the Austin animal shelter and no outcome record. This is the board.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-08-13" rel="nofollow"&gt;DEV Weekend Challenge: Dog Days Edition&lt;/a&gt;, August 2026.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live board: &lt;a href="https://jonathansolvesproblems.github.io/still-here/" rel="nofollow noopener noreferrer"&gt;https://jonathansolvesproblems.github.io/still-here/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;▶ Watch the demo (2 min): &lt;a href="https://www.youtube.com/watch?v=RNa5V26TXzI" rel="nofollow noopener noreferrer"&gt;https://www.youtube.com/watch?v=RNa5V26TXzI&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Write-up: &lt;a href="https://dev.to/jonathansolvesstuff/everyone-believes-black-dogs-get-left-behind-i-checked-99916-shelter-records-they-dont-2ff5" rel="nofollow"&gt;on DEV&lt;/a&gt;
and &lt;a href="https://jonathanandrei.com/blog/still-here-austin-shelter-black-dog-syndrome-99916-stays/" rel="nofollow noopener noreferrer"&gt;on my site&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The longest wait on the board belongs to Pancho, a white Cairn Terrier who was
picked up as a stray on May 23, 2025. As of the last data pull he had been there
&lt;strong&gt;449 days&lt;/strong&gt;. 99.7% of dogs in his breed group were adopted in less time than he
has already been waiting.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The finding&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Black dog syndrome, the belief that a dark coat keeps a dog in the kennel
does not appear in Austin's records. Across &lt;strong&gt;99,916 completed dog stays&lt;/strong&gt; since
2013, black dogs leave in a median of &lt;strong&gt;9 days&lt;/strong&gt;, faster than white, brown…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/still-here" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Everything is in the open, including the corpus, so every number here is checkable.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The data has two eras, and the famous one is frozen
&lt;/h3&gt;

&lt;p&gt;Austin publishes intakes and outcomes on Socrata with no API key required. The dataset IDs that every tutorial points at are &lt;strong&gt;archives frozen at 2025-05-05&lt;/strong&gt;. Query &lt;code&gt;wter-evkm&lt;/code&gt; today and you get a tidy 173,812 rows, not one of them from the last fifteen months. The live feeds are different IDs &lt;em&gt;and a different schema&lt;/em&gt;, and finding that out was most of an hour.&lt;/p&gt;

&lt;p&gt;This project reads both. The live outcome feed ships a &lt;code&gt;days_in_shelter&lt;/code&gt; column; the archive does not, so each archived outcome is paired with the most recent intake for that animal that &lt;em&gt;precedes&lt;/em&gt; it. Dogs cycle through the shelter more than once, and matching on animal ID alone would charge a repeat stray's whole history to a single stay. 652 outcomes could not be paired and were dropped rather than guessed at.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snowflake does the counting
&lt;/h3&gt;

&lt;p&gt;All 99,916 stays and the 510 current dogs load through an internal stage, and every number quoted above comes out of a view rather than out of application code. The bully-type definition lives in a single SQL function, &lt;code&gt;IS_BULLY()&lt;/code&gt;, so the one judgement call in the whole analysis is auditable in one place instead of scattered through Python.&lt;/p&gt;

&lt;p&gt;The piece that genuinely wants a warehouse is the per-dog percentile: for each of the 510 waiting dogs, rank its current wait against every completed stay in its breed group. That is 510 live values against 51,413 historical ones, and it is one view.&lt;/p&gt;

&lt;p&gt;I also kept the Python implementation and made the two argue. &lt;code&gt;verify.py&lt;/code&gt; re-runs every headline question locally and diffs it against what Snowflake returned, across the medians, the counts, the color table, the controlled split, the spreads, and the per-dog percentile for the top 25 dogs. &lt;strong&gt;102 of 102 checks agree.&lt;/strong&gt; If they ever disagree, one of them is wrong and the number does not go in the writeup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding the faces
&lt;/h3&gt;

&lt;p&gt;The open data has no photographs in it. But Austin's adoption listings run on a platform called Adopets, and its &lt;code&gt;code&lt;/code&gt; field is the same animal id the open data uses, so the two join directly.&lt;/p&gt;

&lt;p&gt;I checked the join rather than trusting it: of the matched dogs that have a name on both sides, &lt;strong&gt;97% agree&lt;/strong&gt;, and the handful that disagree are the shelter renaming a dog after intake. &lt;code&gt;Unknown&lt;/code&gt; becomes &lt;code&gt;Tinkerbelle&lt;/code&gt;. &lt;code&gt;Fat Boy&lt;/code&gt; becomes &lt;code&gt;Big Boy&lt;/code&gt;. The importer refuses to write the mapping at all if agreement drops below 90%, because a wrong face on a real animal is worse than no face.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;377 of the 510 have a real photograph&lt;/strong&gt;, embedded from the shelter's own platform rather than copied.&lt;/p&gt;

&lt;p&gt;133 have none. I nearly wrote that up as dogs being overlooked, which would have been wrong. Their median wait is 20 days against 84 for the listed ones, so most of them are simply recent arrivals. Texas shelters hold a stray for a period before it can be offered for adoption, set by local ordinance rather than by state law, and that is the likeliest explanation for the gap. I cannot verify it dog by dog from public data, so I am not going to claim it as more than the obvious reading. But the unlisted share never drops below about a sixth at any length of stay, and &lt;strong&gt;33 dogs have passed 100 days without appearing publicly at all&lt;/strong&gt;. Pancho is one of them, which is why the biggest frame on the page is empty.&lt;/p&gt;

&lt;p&gt;For those 133 I generated one illustration per breed group and used it as a CSS mask, so the alpha channel carries the drawing while the fill comes from the coat the shelter recorded. A black pit bull and a white Great Pyrenees stay visibly different animals. They illustrate a breed &lt;em&gt;type&lt;/em&gt;, never an individual: generating a portrait of a specific dog nobody has photographed would be inventing evidence about a real animal, and the "no photograph on file" caption stays on every one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Giving them a voice
&lt;/h3&gt;

&lt;p&gt;Each of the longest-waiting dogs reads its own record aloud, and every clip opens with a real generated sound for that breed group: a mournful howl for the hounds, a husky's howl for the spitz breeds, a tiny yip for the toy dogs, a single deep woof for the guardians.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing any dog says is invented.&lt;/strong&gt; Every clause maps to a column:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;They called me Pancho. I'm a white Cairn Terrier. I was picked up as a stray on May 23, 2025. That was 448 days ago, more than a year. 99.7 percent of dogs like me were already home by now. The usual wait is 8 days. Nobody has come for me yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The clips are recordings made on 2026-08-15, so a day count spoken aloud can sit a day behind the board, which recomputes on every load.&lt;/p&gt;

&lt;p&gt;Name, coat, breed, intake reason, intake date, elapsed days, cohort percentile, cohort median. Eight fields, no biography, no "loves long walks". The phrasing varies between dogs but is selected by a hash of the animal ID rather than randomly, so re-running produces byte-identical scripts.&lt;/p&gt;

&lt;p&gt;The pit bulls deliberately got the warmest, deepest voice available, because 51 of the dogs without photographs are pit bulls and a growling read would reinforce the exact assumption the other 99,916 records spend this whole page dismantling.&lt;/p&gt;

&lt;p&gt;A straight top-16 by wait length turned out to be thirteen pit bulls, which meant no howl or yip would ever have played anywhere, so the generator tops up with the longest waiter from every breed group the top-16 missed. Twenty-three clips, all ten groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things I had to get right, and four I got wrong first
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Medians, not averages.&lt;/strong&gt; Length of stay is heavily right-skewed. The mean wait for a bully-type dog is 66 days against a median of 28, because a handful of dogs stay for years. Every headline number here is a median.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The controlled table was wrong the first time.&lt;/strong&gt; It required only 30 adoptions per cell, and reported that coat color moved the wait by 28 days within bully-type dogs, which would have undercut the entire finding. The top and bottom of that table were Yellow Brindle at n=35 and Gray at n=62. Two thin cells were setting the whole spread. At a floor of 250 the same table shows 7.5 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15 of the dogs on my first board had arrived dead.&lt;/strong&gt; Animals recorded dead on intake never get an outcome row, so the anti-join that finds waiting dogs finds them too, and there they were on a page captioned &lt;em&gt;nobody has come for them yet&lt;/em&gt;. Filtering them took the count from 531 to 516.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one more after that.&lt;/strong&gt; Princess has two intake rows a day apart with no outcome between them, so she was on the board twice. The anti-join emits one row per intake, not per dog. Deduplicating by animal id took it to &lt;strong&gt;515&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The charts drew nothing at all for a while.&lt;/strong&gt; Both panels rendered a bar of identical length whatever the number said, because the fill was an inline &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; and inline elements silently ignore &lt;code&gt;width&lt;/code&gt;. The comparison the whole page is built on was showing two identical rows.&lt;/p&gt;

&lt;p&gt;Because the writeup kept drifting from the pipeline, there is now a &lt;code&gt;factcheck.py&lt;/code&gt; that re-reads the data and asserts every number quoted in this post. It caught eight stale figures the first time I ran it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Snowflake&lt;/strong&gt; and &lt;strong&gt;ElevenLabs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Snowflake holds the corpus and answers every question in the piece, with the one judgement call isolated in a SQL function and a 102-check cross-examination against an independent Python implementation.&lt;/p&gt;

&lt;p&gt;ElevenLabs gives 23 dogs a voice and gives each breed group its own animal, generated rather than sampled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;This is one municipal shelter, and an atypical one. Austin Animal Center operates under a &lt;a href="https://austintexas.gov/page/ordinance-change-faqs" rel="noopener noreferrer"&gt;City of Austin mandate of a 95% live-release rate&lt;/a&gt;, so these waits are likely shorter than the national picture rather than longer.&lt;/p&gt;

&lt;p&gt;Nothing here is causal. It shows which dogs wait, not why any individual adopter chose as they did.&lt;/p&gt;

&lt;p&gt;Breed labels deserve a warning too. They are assigned by staff from appearance, and &lt;a href="https://nationalcanineresearchcouncil.com/research_library/summary-analysis-inconsistent-identification-of-pit-bull-type-dogs-by-shelter-staff/" rel="noopener noreferrer"&gt;Olson et al. (2015)&lt;/a&gt; found that shelter staff called 52% of a sample of dogs pit bull-type while DNA put the figure at 21% (&lt;em&gt;The Veterinary Journal&lt;/em&gt; 206: 197-202). So "bully-type" here does not mean &lt;em&gt;a dog with pit bull ancestry&lt;/em&gt;. It means &lt;em&gt;a dog Austin wrote down as a pit bull&lt;/em&gt;. For a question about who gets adopted that is arguably the better variable anyway, because the label on the kennel card is what an adopter actually reads.&lt;/p&gt;

&lt;p&gt;"No outcome record" is also not identical to "in the building". Some of these dogs are in foster care, and the outcome feed lags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;The interesting thing about the black dog myth is not that it is wrong. It is that it is a &lt;em&gt;kinder&lt;/em&gt; thing to believe. If dogs are passed over for the colour of their coat, that is a superstition, and superstitions can be argued away with a good photo and a hashtag.&lt;/p&gt;

&lt;p&gt;The real pattern is not a superstition. People are avoiding a breed. 216 of the dogs in that shelter tonight are that breed, and the ones at the top of the board have been waiting since before last summer.&lt;/p&gt;

&lt;p&gt;Pancho is not one of them. He is a small white terrier and he has been there 449 days, which is the part I cannot explain and did not try to.&lt;/p&gt;

&lt;p&gt;When my mom brought that second dog home, one dog stopped waiting. I did not think about it in those terms at the time and I doubt she did either. This weekend I built the list of everyone still waiting, and it turns out to be 510 names long.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Live board:&lt;/strong&gt; &lt;a href="https://jonathansolvesproblems.github.io/still-here/" rel="noopener noreferrer"&gt;https://jonathansolvesproblems.github.io/still-here/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/JonathanSolvesProblems/still-here" rel="noopener noreferrer"&gt;https://github.com/JonathanSolvesProblems/still-here&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Blog Post:&lt;/strong&gt; &lt;a href="https://jonathanandrei.com/blog/still-here-austin-shelter-black-dog-syndrome-99916-stays/" rel="noopener noreferrer"&gt;https://jonathanandrei.com/blog/still-here-austin-shelter-black-dog-syndrome-99916-stays/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; &lt;a href="https://dev.to/challenges/weekend-2026-08-13"&gt;DEV Weekend Challenge: Dog Days Edition&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Data:&lt;/strong&gt; Austin Animal Center open data, &lt;a href="https://data.austintexas.gov/resource/pyqf-r2dc.json" rel="noopener noreferrer"&gt;live intakes&lt;/a&gt; and &lt;a href="https://data.austintexas.gov/resource/gsvs-ypi7.json" rel="noopener noreferrer"&gt;live outcomes&lt;/a&gt;. No API key needed, so every number here is checkable.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you are anywhere near Austin, the board is real and so are the dogs on it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>weekendchallenge</category>
      <category>snowflake</category>
      <category>elevenlabs</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Passion is the one thing you are not supposed to be able to fake. So I built a machine that manufactures it.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 12 Jul 2026 01:52:27 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/passion-is-the-one-thing-you-are-not-supposed-to-be-able-to-fake-so-i-built-a-machine-that-5e58</link>
      <guid>https://dev.to/jonathansolvesstuff/passion-is-the-one-thing-you-are-not-supposed-to-be-able-to-fake-so-i-built-a-machine-that-5e58</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The prompt for this challenge was passion, so I took the most literal possible&lt;br&gt;
run at it.&lt;/p&gt;

&lt;p&gt;Every World Cup app ever made is built for people who already care. Fixtures,&lt;br&gt;
scores, brackets, league tables, fantasy teams. Every one of them assumes you&lt;br&gt;
turned up with a side. Nobody builds anything for the person who wants to care&lt;br&gt;
and has nobody to care about, which is most of the planet: a few billion people&lt;br&gt;
will watch this tournament, and the overwhelming majority of them have no dog in&lt;br&gt;
any of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Your Side&lt;/strong&gt; is for them. You name any two nations. It goes and reads their&lt;br&gt;
actual history, picks the one you were always meant to love, tells you why, and a&lt;br&gt;
stadium announcer swears you in.&lt;/p&gt;

&lt;p&gt;Two nations in. One side out, and a reason to mean it.&lt;/p&gt;

&lt;p&gt;The interesting problem is that you cannot fake this with a nice adjective. If&lt;br&gt;
the app tells you Ghana and Uruguay have a bitter history and the history is&lt;br&gt;
invented, then the passion it hands you is counterfeit, and the moment you look&lt;br&gt;
it up you will feel stupid and never open it again. Manufactured passion only&lt;br&gt;
works if the thing it is built out of is true.&lt;/p&gt;

&lt;p&gt;So the whole architecture is bent around that one constraint.&lt;/p&gt;


&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;a href="https://pick-your-side.vercel.app" rel="noopener noreferrer"&gt;pick-your-side.vercel.app&lt;/a&gt;&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Turn your sound on.&lt;/strong&gt; Half of this app is audio.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/fND6klvdVqY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukxq3n6co77f29j5yzow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukxq3n6co77f29j5yzow.png" alt="The Pick Your Side landing page. Huge white type on near black reads " width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try &lt;strong&gt;Uruguay against Ghana&lt;/strong&gt;. It will side with Ghana, and it will tell you why:&lt;br&gt;
Luis Suarez's handball on the line in 2010, the penalty Asamoah Gyan hit off the&lt;br&gt;
crossbar in the last minute, the first African side ever to stand that close to a&lt;br&gt;
semi-final. It also knows Ghana beat them 2-0 in the group stage a few days ago,&lt;br&gt;
and it still argues the wound has not closed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa5km4r2f4pvpgnzvqoo7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa5km4r2f4pvpgnzvqoo7.png" alt="The reveal. The screen has flooded green and the word GHANA fills it, with the verdict " width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or try &lt;strong&gt;England against Germany&lt;/strong&gt;, if you want to watch it side with the team&lt;br&gt;
that has not won anything since 1966.&lt;/p&gt;

&lt;p&gt;Here is the part I am proudest of. Every fact above came from a live search, and&lt;br&gt;
the app will show you exactly which pages it read:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki4w8t189vlnla3ue85l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki4w8t189vlnla3ue85l.png" alt="The Verified panel, expanded. On the left, the six live searches Gemini actually ran. On the right, the six pages it actually read, each one a link you can open and check." width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/pick-your-side" rel="noopener noreferrer"&gt;
        pick-your-side
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Best Use of Google AI, DEV Weekend Challenge. Name any two nations and it picks the World Cup side you were always meant to support, then a stadium announcer swears you in. The history is real, not invented: Gemini grounded in Google Search.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Pick Your Side&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The World Cup is on and you do not have a team.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Every World Cup app ever built is for the people who already care. This one is
for everybody else. Name any two nations. It reads their real history, picks the
side you were always meant to love, and a stadium announcer swears you in.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-07-09" rel="nofollow"&gt;DEV Weekend Challenge: Passion Edition&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The interesting part is that none of the history is invented.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Gemini researches, grounded in Google Search.&lt;/strong&gt;
The first call has the Google Search tool switched on. Gemini goes and finds the
actual head to head record, the actual famous match, and where both sides
actually stand in the tournament being played right now. It comes back with the
pages it read and the searches it ran, and the app shows you both. Ask it about
Morocco and…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/pick-your-side" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gemini never gets to work from memory
&lt;/h3&gt;

&lt;p&gt;Because of that constraint, it runs twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call one goes and finds out.&lt;/strong&gt; Google Search grounding is switched on, and&lt;br&gt;
Gemini is told to report only what it can verify: every World Cup meeting between&lt;br&gt;
the two nations with year and score, the single most famous match, what each side&lt;br&gt;
has actually won, and where both of them stand in the tournament being played&lt;br&gt;
right now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;research&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Research &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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; and &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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ... Be concise and factual.
    If they have rarely or never played each other, say that plainly
    instead of inventing a rivalry.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;googleSearch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Call two writes the case&lt;/strong&gt;, and it only ever sees the facts call one came back&lt;br&gt;
with. It is held to a &lt;code&gt;responseSchema&lt;/code&gt;, so the output is always the same shape:&lt;br&gt;
a side, a verdict, the history, the stakes, one legendary moment, your reason, a&lt;br&gt;
line you can share, and a script for the announcer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`...Here is what was just researched. Every fact you use must
    come from here. Do not add matches, scores or players that are not in it:
    &amp;lt;researched-facts&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/researched-facts&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;responseMimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;revealSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why two calls, which is the bit that cost me an hour
&lt;/h3&gt;

&lt;p&gt;I did not want two calls. I wanted one: grounded and schema'd together. The&lt;br&gt;
Gemini docs say Gemini 3 supports exactly that, structured output combined with&lt;br&gt;
Google Search.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, asking for &lt;code&gt;googleSearch&lt;/code&gt; and a &lt;code&gt;responseSchema&lt;/code&gt; in the&lt;br&gt;
same call &lt;strong&gt;does not throw an error&lt;/strong&gt;. It quietly ignores the schema and hands&lt;br&gt;
you back prose.&lt;/p&gt;

&lt;p&gt;That is a genuinely nasty failure. Nothing fails, nothing warns, the call&lt;br&gt;
succeeds, and then your UI does &lt;code&gt;JSON.parse()&lt;/code&gt; on three paragraphs of essay. I&lt;br&gt;
only caught it because I wrote a throwaway script to poke at the API surface&lt;br&gt;
before I wrote any UI, and I printed the raw output instead of trusting the&lt;br&gt;
status code. If I had trusted it, the reveal would have exploded live.&lt;/p&gt;

&lt;p&gt;So: research first, then shape. Which turned out to be the better architecture&lt;br&gt;
anyway, because splitting them meant the facts arrive with sources attached.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model was thinking, and thinking was making it worse
&lt;/h3&gt;

&lt;p&gt;The whole thing was taking twenty six seconds, which is a long time to look at a&lt;br&gt;
loading screen. &lt;code&gt;gemini-2.5-flash&lt;/code&gt; reasons before it answers unless you tell it&lt;br&gt;
not to. I turned it off on both calls. Same model, same prompts, measured:&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;grounded&lt;/th&gt;
&lt;th&gt;written&lt;/th&gt;
&lt;th&gt;total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;thinking on&lt;/td&gt;
&lt;td&gt;11.6s&lt;/td&gt;
&lt;td&gt;14.7s&lt;/td&gt;
&lt;td&gt;26.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;thinking off&lt;/td&gt;
&lt;td&gt;5.1s&lt;/td&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;td&gt;6.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four times faster. And the answers got &lt;strong&gt;better&lt;/strong&gt;, which I did not expect. Asked&lt;br&gt;
for a verdict, thinking on, it reasoned its way to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;France ends Morocco's World Cup run&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a news headline. It is not a reason to love anybody. Thinking off:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unfinished business and a deeper wound&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first call is looking things up and the second is being asked to write.&lt;br&gt;
Neither one is a reasoning problem, and reasoning at them cost me twenty seconds&lt;br&gt;
and the only good line in the output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;responseMimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;revealSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;thinkingConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;thinkingBudget&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;// &amp;lt;- this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Showing the receipts
&lt;/h3&gt;

&lt;p&gt;Because the grounded call comes back with grounding metadata, I know exactly&lt;br&gt;
which pages Gemini read and which searches it ran. So the app shows you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Verified.&lt;/strong&gt; None of this was invented. Gemini ran 6 live searches and read 6&lt;br&gt;
pages before it made the case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Expand it and you get the real search strings and links to fifa.com, wikipedia,&lt;br&gt;
fotmob, whatever it actually used. You can go check it. It is the difference&lt;br&gt;
between a story and a fact, and it is the thing I would want if I were the one&lt;br&gt;
being told who to love.&lt;/p&gt;

&lt;p&gt;Here is what convinced me it was worth doing. I asked it about Morocco against&lt;br&gt;
France, and it told me about the 2026 quarter final, a match played days ago.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This was tragically repeated in the 2026 World Cup quarterfinal, with France&lt;br&gt;
again winning 2-0."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No language model knows that from training. It went and looked.&lt;/p&gt;
&lt;h3&gt;
  
  
  Telling it to pick the loser
&lt;/h3&gt;

&lt;p&gt;The instruction that made the biggest difference to how the thing feels:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Choose the side with the better story for a newcomer: the deeper wound, the&lt;br&gt;
longer wait, the thing still unfinished. &lt;strong&gt;Do not default to the more famous or&lt;br&gt;
more successful nation.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that line it picks Brazil, France, Germany, every time, because they are&lt;br&gt;
the most written about. With it, it picks Morocco over France and argues for the&lt;br&gt;
Atlas Lions. It picks the Netherlands over Senegal and leads with three lost&lt;br&gt;
finals. That single sentence is the difference between an app that reports and an&lt;br&gt;
app that has taste.&lt;/p&gt;
&lt;h3&gt;
  
  
  The voice
&lt;/h3&gt;

&lt;p&gt;Gemini writes the swear-in. ElevenLabs performs it.&lt;/p&gt;

&lt;p&gt;The reveal lands, the nation's colour floods the screen, and a voice comes in&lt;br&gt;
over the top of it. It is the moment the whole thing exists for, and it does not&lt;br&gt;
work on the page as text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textToSpeech&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VOICE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`[shouting over a stadium crowd] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;script&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="na"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eleven_v3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mp3_44100_128&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;voiceSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;similarityBoost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&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;code&gt;eleven_v3&lt;/code&gt; takes inline audio tags, so &lt;code&gt;[shouting over a stadium crowd]&lt;/code&gt; is&lt;br&gt;
direction for the read, not words that get spoken. Low stability plus high style&lt;br&gt;
is what stops it sounding like an audiobook.&lt;/p&gt;

&lt;p&gt;And then I listened to it, and the problem was obvious. A man was shouting about&lt;br&gt;
a stadium, in total silence. He sounded unhinged.&lt;/p&gt;

&lt;p&gt;So ElevenLabs builds the stadium too. Their sound effects API takes a text&lt;br&gt;
prompt the same way the voice does, and I asked it for two things: a restless&lt;br&gt;
crowd that murmurs under the scouting screen while the app is thinking, and an&lt;br&gt;
eruption for the instant the nation's name lands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textToSoundEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A packed football stadium erupting the instant a goal goes in. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A sudden enormous crowd roar, then it settles into a sustained wall &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;of cheering that slowly falls away. No music, no commentary.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;durationSeconds&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 crowd sits at a third of the volume and never competes with the announcer.&lt;br&gt;
Both cues are generated once and committed, so they cost nothing and start&lt;br&gt;
instantly. Sound is on by default, because a muted reveal is just a poster, and&lt;br&gt;
the control to turn it off is on screen the entire time.&lt;/p&gt;

&lt;p&gt;There are three layers under the voice, because I did not want a visitor to&lt;br&gt;
arrive at a silent reveal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;live text to speech of the script Gemini just wrote for that exact matchup&lt;/li&gt;
&lt;li&gt;if that fails or the free quota is spent, a pre-rendered stinger for that
nation, which ships with the app&lt;/li&gt;
&lt;li&gt;if both fail, silence, and the reveal is otherwise unharmed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same matchup, same words, same audio, so it is cached and only the first visitor&lt;br&gt;
to a given pairing costs anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The announcer kept turning up late
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;eleven_v3&lt;/code&gt; is the expressive one. It also takes 4.4 seconds, so the nation's&lt;br&gt;
name would slam onto the screen and then nothing would happen for four seconds&lt;br&gt;
and then a voice would arrive, to an empty room. Worse, I had put the dramatic&lt;br&gt;
pause &lt;strong&gt;after&lt;/strong&gt; the download instead of measuring it from the start of the&lt;br&gt;
reveal, so the network time and the pause stacked and the announcer was&lt;br&gt;
arriving 5.1 seconds late. I only found that because I scripted a browser to&lt;br&gt;
click through the live site and timestamp the moment the audio element actually&lt;br&gt;
started playing, which is not something a screenshot will ever tell you.&lt;/p&gt;

&lt;p&gt;There is also a trap in the audio tags. &lt;code&gt;[shouting over a stadium crowd]&lt;/code&gt; is&lt;br&gt;
direction, not dialogue, but &lt;strong&gt;only on v3&lt;/strong&gt;. Every other model reads it out&lt;br&gt;
loud. I nearly shipped an announcer who introduces himself by saying the words&lt;br&gt;
"shouting over a stadium crowd".&lt;/p&gt;

&lt;p&gt;So the live voice is &lt;code&gt;eleven_flash_v2_5&lt;/code&gt;, which comes back in under a second and&lt;br&gt;
lands on the beat, and the pre-rendered stingers stay on &lt;code&gt;eleven_v3&lt;/code&gt;, where&lt;br&gt;
latency does not exist because they are generated offline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The colours were a lie
&lt;/h3&gt;

&lt;p&gt;The nations carry their real kit colours. Germany's is &lt;code&gt;#111111&lt;/code&gt;. New Zealand's&lt;br&gt;
is &lt;code&gt;#0A0A0A&lt;/code&gt;. The whole design floods a near-black page with the nation's&lt;br&gt;
colour, so picking Germany flooded near-black with near-black and the giant word&lt;br&gt;
GERMANY simply was not there.&lt;/p&gt;

&lt;p&gt;Eight of the forty one nations failed to clear 4.5:1 against the background. The&lt;br&gt;
fix was not to fake the colours: it was to keep the authentic one as the source&lt;br&gt;
of truth and derive the painted one from it, lifting it toward white only as far&lt;br&gt;
as it has to go to be legible. Brazil's yellow comes out untouched. Germany's&lt;br&gt;
black comes out a graphite you can actually read.&lt;/p&gt;

&lt;h3&gt;
  
  
  The design has one idea
&lt;/h3&gt;

&lt;p&gt;The landing page has no colour in it. Near black, white type, a faint green glow&lt;br&gt;
where the pitch would be. The only colour anywhere is a slow ribbon of all 41&lt;br&gt;
nations drifting past the bottom, every side you could end up on, none of them&lt;br&gt;
yours.&lt;/p&gt;

&lt;p&gt;Then you pick, and the reveal floods the entire screen in your nation's colours.&lt;/p&gt;

&lt;p&gt;That is the whole visual argument, and it is the product in one gesture: you have&lt;br&gt;
nothing, and then you have something. Everything on the reveal is driven off the&lt;br&gt;
chosen nation's real kit colours through CSS custom properties, so Morocco is&lt;br&gt;
red, the Netherlands is orange, Argentina is that pale sky blue.&lt;/p&gt;

&lt;p&gt;Flags are images rather than emoji, incidentally, because Windows does not render&lt;br&gt;
flag emoji at all. It falls back to letter pairs. A judge on a Windows laptop&lt;br&gt;
would have seen "AR" where Argentina's flag should be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Google AI.&lt;/strong&gt; Gemini is the entire product, not a feature of it. It&lt;br&gt;
runs twice: once grounded in Google Search to establish real, current, checkable&lt;br&gt;
facts, and once under a forced &lt;code&gt;responseSchema&lt;/code&gt; to turn those facts into an&lt;br&gt;
argument. The app shows you the pages it read. It knows results from a tournament&lt;br&gt;
that is still being played.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best use of ElevenLabs.&lt;/strong&gt; The swear-in is the emotional payoff of the whole&lt;br&gt;
app, and it is built out of two ElevenLabs APIs. Text to speech is the&lt;br&gt;
announcer: Gemini writes the words for that exact matchup, ElevenLabs is the&lt;br&gt;
voice that makes them land, timed to arrive just behind the nation's name.&lt;br&gt;
Sound effects are the stadium he is shouting into: a restless crowd while the&lt;br&gt;
app is thinking, an eruption when the verdict drops. Turn your sound on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping it standing
&lt;/h2&gt;

&lt;p&gt;The Google AI Studio free tier caps a &lt;strong&gt;project&lt;/strong&gt; at a few hundred requests a&lt;br&gt;
day, across every visitor combined. Two calls per reveal means this app could&lt;br&gt;
serve about 125 people a day in total before handing everyone else an error. Fine&lt;br&gt;
for building. Useless for being read.&lt;/p&gt;

&lt;p&gt;So two things. The app runs on Vertex AI, which needs a billing account and&lt;br&gt;
therefore carries real quota. And the well known matchups are generated ahead of&lt;br&gt;
time by the same grounded pipeline, sources and all, and committed to the repo.&lt;br&gt;
Those answer in half a second and cost nothing. They are cached, not faked.&lt;/p&gt;

&lt;p&gt;When the budget does run out, the failure hands you a matchup that always works&lt;br&gt;
instead of a dead end. I would rather you get a team from a slightly stale cache&lt;br&gt;
than a stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not solve
&lt;/h2&gt;

&lt;p&gt;The nations list is curated to 41 sides, so you cannot pick your local club. A&lt;br&gt;
matchup nobody has asked for before takes about twelve seconds, which I dressed&lt;br&gt;
up as a scouting sequence rather than fixed. And the "what is at stake" text goes&lt;br&gt;
stale six hours after it is written, which is a strange thing to have to think&lt;br&gt;
about, and only a problem because the tournament is still being played while you&lt;br&gt;
read this.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing
&lt;/h2&gt;

&lt;p&gt;I should probably admit that I am the target user.&lt;/p&gt;

&lt;p&gt;The World Cup is being played in my country. Everyone I know has lost their mind&lt;br&gt;
over it, and for the whole tournament I have been standing in the middle of that&lt;br&gt;
with nothing: no nation, no shirt, no grudge, nobody to shout at. I did not build&lt;br&gt;
this as a demo of an API. I built it because I wanted in and could not find the&lt;br&gt;
door.&lt;/p&gt;

&lt;p&gt;I pointed it at Uruguay and Ghana, because I did not know anything about either&lt;br&gt;
of them.&lt;/p&gt;

&lt;p&gt;It gave me Ghana.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q2pm76sjig3ua20sb24.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q2pm76sjig3ua20sb24.png" alt="The share card in Ghana's green and gold. It reads: " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote up the longer version of the build, including the two Gemini traps that&lt;br&gt;
nearly shipped, &lt;a href="https://jonathanandrei.com/blog/pick-your-side-manufactured-passion-gemini-elevenlabs/" rel="noopener noreferrer"&gt;on my own site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Built by &lt;a href="https://jonathanandrei.com" rel="noopener noreferrer"&gt;Jonathan Andrei&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an AI agent that runs a local business's weekly marketing (Next.js + Gemini)</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sat, 11 Jul 2026 13:59:24 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/i-built-an-ai-agent-that-runs-a-local-businesss-weekly-marketing-nextjs-gemini-2i91</link>
      <guid>https://dev.to/jonathansolvesstuff/i-built-an-ai-agent-that-runs-a-local-businesss-weekly-marketing-nextjs-gemini-2i91</guid>
      <description>&lt;p&gt;For the last couple of weeks I've been building &lt;strong&gt;Bloom&lt;/strong&gt;, an AI marketing agent for local businesses like cafes, salons, and gyms. You set it up once, and every week an agent decides what to feature, writes the content in your brand voice, checks its own work, and (on the paid tier) emails your newsletter to your subscribers. On its own.&lt;/p&gt;

&lt;p&gt;It's an early work in progress and I'm sharing it to get honest feedback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;90-second demo:&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=huHCDcUhIGM" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=huHCDcUhIGM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/huHCDcUhIGM" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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

&lt;p&gt;Most local owners get under an hour a day for marketing, so it slips, and posting irregularly quietly costs them reach. It's a time-and-consistency problem, not a talent one. I wanted to build the thing that just never skips a week.&lt;/p&gt;

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

&lt;p&gt;You enter your business once (type, city, brand voice). Then every Monday a scheduled agent runs with no human trigger and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;decides the week's angle and promotion&lt;/li&gt;
&lt;li&gt;writes 3 social posts and an email newsletter in your voice&lt;/li&gt;
&lt;li&gt;scores its own draft 0-100 and rewrites it if it falls below the bar&lt;/li&gt;
&lt;li&gt;on the Pro tier, emails the newsletter to your subscribers from a verified domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every decision is logged to a public activity feed, so you can actually watch it work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that were actually interesting to build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The self-QA gate is real, not decorative.&lt;/strong&gt; After generating, a second Gemini call reviews the draft and scores it. Below the threshold, the agent rewrites once and keeps the better attempt. Making this reliable meant forcing a JSON response schema, because Gemini would occasionally return an array instead of an object and silently break the gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured generation via Vertex AI.&lt;/strong&gt; Content is Gemini 2.5 Flash through Vertex AI with a forced response schema, so the output is always a clean object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent weekly delivery.&lt;/strong&gt; The sender claims each send atomically, so a newsletter can never go out twice even if two workers fire the same Monday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs on a plain Docker/Traefik box&lt;/strong&gt; as a standalone Next.js server, with a cron sidecar driving the weekly run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Next.js, TypeScript, Gemini 2.5 Flash (Vertex AI), Prisma + Neon, Stripe, Resend, Docker + Traefik.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's at (honestly)
&lt;/h2&gt;

&lt;p&gt;It's live. I've run a real payment through it and had it generate and deliver a real newsletter end to end. But it has basically no real users yet, which is exactly why I'm posting. I'd rather hear what's wrong now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell me what's missing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free preview (no card, about a minute):&lt;/strong&gt; &lt;a href="https://bloom.jonathanandrei.com" rel="noopener noreferrer"&gt;https://bloom.jonathanandrei.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely love feedback on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Would you, or a local business you know, actually use this?&lt;/li&gt;
&lt;li&gt;Does the generated content look good enough to post as-is?&lt;/li&gt;
&lt;li&gt;What would need to be true before you'd pay for it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built for the Build with Gemini XPRIZE hackathon. Happy to answer anything in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>nextjs</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
