<?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: Bowen Rui</title>
    <description>The latest articles on DEV Community by Bowen Rui (@13owen).</description>
    <link>https://dev.to/13owen</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%2F4158500%2F11edd952-11d1-4d53-aa7b-8d7cf9f7380a.jpg</url>
      <title>DEV Community: Bowen Rui</title>
      <link>https://dev.to/13owen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/13owen"/>
    <language>en</language>
    <item>
      <title>chaptermill: a podcast's back catalog to show notes for 3¢ an audio hour, on an old PC I kept crashing on purpose</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Thu, 15 Oct 2026 20:19:46 +0000</pubDate>
      <link>https://dev.to/13owen/chaptermill-a-podcasts-back-catalog-to-show-notes-for-3c-an-audio-hour-on-an-old-pc-i-kept-4gl5</link>
      <guid>https://dev.to/13owen/chaptermill-a-podcasts-back-catalog-to-show-notes-for-3c-an-audio-hour-on-an-old-pc-i-kept-4gl5</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;Most podcasts that have been running for a few years have the same problem: hundreds of old episodes with no transcript, no chapters and no show notes worth reading. That archive is what new listeners stumble on through search, and it is the part nobody wants to pay per-hour SaaS prices to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;chaptermill&lt;/strong&gt; takes a podcast's RSS feed and, on a machine you already own, turns the back catalog into the files a producer actually delivers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a timestamped transcript (SRT, VTT, JSON),&lt;/li&gt;
&lt;li&gt;chapters as a Podcasting 2.0 &lt;code&gt;chapters.json&lt;/code&gt; and as YouTube chapter lines,&lt;/li&gt;
&lt;li&gt;2–4 pull quotes, each &lt;strong&gt;checked word for word against the transcript&lt;/strong&gt; before it is accepted,&lt;/li&gt;
&lt;li&gt;Markdown show notes with a summary, topics and a credit line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Who pays for it:&lt;/strong&gt; freelance podcast producers (show notes are a standard deliverable) and independent shows with a big archive. The hosted tools price by the audio hour. On their pricing pages today, Castmagic's Hobby plan is $19/month billed annually for 30 transcribed hours &lt;em&gt;in total&lt;/em&gt;, and its Team plan, 400 hours, is $139/month billed annually; Podsqueeze's Agency Lite plan is $89/month for 600 minutes per month. A 300-episode archive of half-hour shows is 150 hours. With chaptermill, at the rate of my demo run, the same archive costs about $4.40 in open-weight model calls and a day of runtime on a 2015-era desktop. The side hustle writes itself: a flat-fee "archive pass" for small shows, where the marginal cost is cents. For scale: Hacker Public Radio, whose episodes I used for the demo, numbers its episodes past 4,700. At the average length of the nine I processed (28.5 minutes) that is roughly 2,250 hours, or about $65 of model calls at my demo run's rate. That is an estimate from a sample of nine, not a quote.&lt;/p&gt;

&lt;p&gt;To be fair to the incumbents: Apple Podcasts and Spotify now generate chapters for some shows inside their own apps. That helps listeners on those apps. It does not give the producer a file to put in the feed, on YouTube or on the show's website, and it does not touch the archive in one go.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://bowen1314.github.io/chaptermill/" rel="noopener noreferrer"&gt;https://bowen1314.github.io/chaptermill/&lt;/a&gt;&lt;/strong&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%2Fer3xsdkpmwtogmtdzmqm.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%2Fer3xsdkpmwtogmtdzmqm.png" alt="The chaptermill demo page: run numbers at the top, the episode list on the left, and one episode's player, summary and clickable chapters on the right" width="800" height="938"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nine recent episodes of &lt;a href="https://hackerpublicradio.org/" rel="noopener noreferrer"&gt;Hacker Public Radio&lt;/a&gt; (a community podcast released under CC BY-SA 4.0), processed on my home server (one run, plus a re-run of two episodes that I explain below), with nothing edited by hand. Pick an episode; press a chapter or a quote and the player jumps there; open the full transcript and the current line follows the audio. Every file is downloadable.&lt;/p&gt;

&lt;p&gt;Further down the page is the part I am proudest of: the report from a second run during which a script &lt;strong&gt;SIGKILLed the worker 9 times and the Temporal server once&lt;/strong&gt;, while the worker injected model-API 503s, cut-off downloads and failing tool calls on purpose.&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/Bowen1314" rel="noopener noreferrer"&gt;
        Bowen1314
      &lt;/a&gt; / &lt;a href="https://github.com/Bowen1314/chaptermill" rel="noopener noreferrer"&gt;
        chaptermill
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Turn a podcast back catalog into transcripts, chapters, verified pull quotes and show notes with open-weight models; Temporal makes it survive crashes.
    &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;chaptermill&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Point it at a podcast RSS feed and leave it running on an ordinary PC. It turns the back catalog into transcripts, chapters, pull quotes that are checked word for word against the transcript, and show notes ready to paste (the demo run did 4.3 hours of audio in 44 minutes on a 2015-era desktop). Open-weight models do the work. &lt;a href="https://temporal.io" rel="nofollow noopener noreferrer"&gt;Temporal&lt;/a&gt; makes it durable: a crash, a dropped connection or a rate-limited model API means it carries on from where it stopped, without redoing finished work or re-buying model replies it already has.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://bowen1314.github.io/chaptermill/" rel="nofollow noopener noreferrer"&gt;https://bowen1314.github.io/chaptermill/&lt;/a&gt; (nine Hacker Public Radio episodes, plus the report from a run where I kept killing the worker and the Temporal server)&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, week 2 (Side Hustle), Temporal category.&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;Freelance podcast producers who write show notes for clients, and independent shows sitting on…&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/Bowen1314/chaptermill" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. About 1,900 lines of Python plus 560 lines of tests, including workflow tests on Temporal's time-skipping test server.&lt;/p&gt;

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

&lt;p&gt;Three open pieces do all the work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt; &lt;code&gt;base.en&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;transcription, locally, on 2 CPU threads&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/Qwen/Qwen3-235B-A22B-Instruct-2507" rel="noopener noreferrer"&gt;Qwen3-235B-A22B-Instruct-2507&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the show-notes agent, through Nebius Token Factory's OpenAI-compatible API&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/temporalio/temporal" rel="noopener noreferrer"&gt;Temporal&lt;/a&gt; (dev server + Python SDK)&lt;/td&gt;
&lt;td&gt;durable orchestration, retries, signals, queries&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The machine is a shared home server: an Intel i3-6100 (2 cores), 3.8 GB of RAM and one spinning disk, also running a dozen other services.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shape of it
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;CatalogWorkflow&lt;/code&gt; reads the feed and starts one child &lt;code&gt;EpisodeWorkflow&lt;/code&gt; per episode, two at a time, inside a dollar budget. Each episode workflow downloads the audio, cuts it into 5-minute chunks (one activity per chunk, on its own task queue so Whisper never starves the rest), merges the transcript, then runs the agent loop and writes the files.&lt;/p&gt;

&lt;p&gt;The agent gets a one-line-per-minute outline and three tools: &lt;code&gt;read_transcript(start, end)&lt;/code&gt;, &lt;code&gt;search_transcript(query)&lt;/code&gt; and &lt;code&gt;submit_notes(...)&lt;/code&gt;. &lt;strong&gt;Every model call and every tool call is a separate Temporal activity&lt;/strong&gt;, so each one has its own timeout and retry policy, and a crash in turn 6 resumes at turn 6:&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;for&lt;/span&gt; &lt;span class="n"&gt;_turn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAX_TURNS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_wait_for_cap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;# durable wait for a raise_cap signal if over budget
&lt;/span&gt;    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_activity_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Activities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;LlmRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
        &lt;span class="n"&gt;start_to_close_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;heartbeat_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# a dead worker is noticed in 30 s
&lt;/span&gt;        &lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LLM_RETRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;# 429/5xx retried, other 4xx fail fast
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submit_notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_activity_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Activities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;validate_notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accepted.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rejected. Fix these and call submit_notes again:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# read_transcript / search_transcript: one activity each
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audio, transcripts and chunk results stay on disk; workflow history carries file paths and the agent's conversation, nothing bigger.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model is checked, not trusted
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;submit_notes&lt;/code&gt; does not end the loop. A validator compares the submission with the transcript, and if anything is off, the error goes back to the model as the tool result. In the demo run it rejected 4 submissions; the model's next submission fixed each of them. Two of them are worth showing, because they are opposite failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HPR4749&lt;/strong&gt;: the model's first quote silently cut a sentence out of the middle ("And from the website, I'm gonna quote it here.") and stitched the rest together. Rejected; the resubmission quoted the host exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HPR4747&lt;/strong&gt;: the model wrote "in place of cat", which is &lt;em&gt;what the host said&lt;/em&gt; (the Unix command). Whisper had transcribed it as "CAD". The validator rejected the correct word, and the final quote says "CAD".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second one is the honest limit of this design: quotes are guaranteed to match the transcript, not the audio. A custom vocabulary for Whisper is the obvious next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making it durable, for real
&lt;/h3&gt;

&lt;p&gt;Temporal's AI cookbook patterns map almost one to one onto this pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries in one place.&lt;/strong&gt; The HTTP client's own retries are off. Errors are classified: 408/409/429/5xx are retryable, other 4xx fail fast, and a &lt;code&gt;Retry-After&lt;/code&gt; header becomes the activity's &lt;code&gt;next_retry_delay&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent model calls.&lt;/strong&gt; Each reply is written to a ledger and to a cache keyed by a hash of the request &lt;em&gt;before&lt;/em&gt; the activity returns, so if the worker dies a millisecond later, the retry reads the cache instead of buying the reply again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunks as checkpoints.&lt;/strong&gt; A finished chunk is a file. Whisper runs at temperature 0, so a re-run chunk produces the same text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeats&lt;/strong&gt; on every long activity, including a side thread that heartbeats while the CPU activity is stuck loading the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget as a durable wait.&lt;/strong&gt; Episodes that would go over the run's budget wait in &lt;code&gt;workflow.wait_condition&lt;/code&gt; until a &lt;code&gt;raise_budget&lt;/code&gt; signal arrives, for as long as it takes; &lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; signals and a &lt;code&gt;progress&lt;/code&gt; query come for free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I tried to break it. &lt;code&gt;scripts/chaos.py&lt;/code&gt; started a three-episode catalog and, every 45 to 110 seconds, SIGKILLed the worker, kept it down for 5 to 20 seconds and started it again; on the third kill it took the Temporal server down too. On top of that, the worker failed 30% of model calls with a fake 503, cut off 50% of audio downloads and broke 15% of transcript tool calls, each only on an activity's first two attempts. The result:&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kills&lt;/td&gt;
&lt;td&gt;9 SIGKILLs of the worker at random moments, one of them together with the Temporal server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Episodes finished&lt;/td&gt;
&lt;td&gt;3 of 3, in 12.5 minutes, with no manual intervention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcripts vs. the undisturbed run&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;identical, segment for segment&lt;/strong&gt; (584 segments)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activity attempts&lt;/td&gt;
&lt;td&gt;88 for 67 activities: 8 retried transcription chunks, 8 retried model calls, 3 retried downloads, 2 retried tool calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model replies received&lt;/td&gt;
&lt;td&gt;18, &lt;strong&gt;none bought twice&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests in flight at a kill&lt;/td&gt;
&lt;td&gt;1: its reply died with the process and the retry asked again (the provider may have billed both)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake 503s absorbed&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model cost&lt;/td&gt;
&lt;td&gt;$0.021&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each kill cost at most the one chunk or model call in progress. The unluckiest chunk, in HPR4740, was killed three times and finished on its fourth attempt; nothing before it was redone.&lt;/p&gt;

&lt;p&gt;The box itself added chaos I had not planned. During the clean run, another service on the same disk was writing about 16 MB/s, and Linux reported I/O pressure between 40% and 87%. The Temporal dev server keeps everything in SQLite, so every history write queued behind that disk, and a few tasks were handed to my worker after the worker had stopped waiting for them. Temporal's answer was a timeout and a retry; the run finished anyway, with 20 workflow-task timeouts in its history. Halfway through I restarted the server with SQLite in WAL mode and &lt;code&gt;synchronous=normal&lt;/code&gt; (still safe when the process is killed, which is the failure I care about here): before the switch 19 of 78 workflow tasks timed out, after it 1 of 222. Not a controlled experiment, but a big enough difference that the chaos script now starts the server that way. I also learned not to &lt;code&gt;fsync&lt;/code&gt; inside an &lt;code&gt;async&lt;/code&gt; activity: on that disk a single &lt;code&gt;fsync&lt;/code&gt; blocked the event loop the workflows share for seconds. Disk-heavy activities now run as sync activities on a thread pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers from the demo run
&lt;/h3&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Episodes&lt;/td&gt;
&lt;td&gt;9 Hacker Public Radio episodes, 4.3 h of audio, 38,318 words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wall clock&lt;/td&gt;
&lt;td&gt;44 minutes, two episodes at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcription&lt;/td&gt;
&lt;td&gt;26 minutes for 4.3 h of audio (9.8× real time), Whisper &lt;code&gt;base.en&lt;/code&gt; on 2 CPU threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;72 turns, 91 tool calls, 4 rejected submissions, all fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model replies paid for&lt;/td&gt;
&lt;td&gt;74, none bought twice: &lt;strong&gt;$0.125 in total, $0.029 per audio hour&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Difference from HPR's own transcripts&lt;/td&gt;
&lt;td&gt;12.6% of words (HPR's are also made with Whisper and not proofread, so this is two machine transcripts disagreeing, not an error rate)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One honest miss: the first run finished 7 of 9. On the two longest interviews the agent spent all ten of its turns reading the transcript window by window and never called &lt;code&gt;submit_notes&lt;/code&gt;. I added a "three turns left, submit now" message and re-ran just those two. Every transcript chunk and the first nine model replies of each came straight from cache; only the last turn was new, at about a third of a cent each. The $0.125 above includes the wasted turns.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; The expensive half of this job, turning audio into text, costs nothing per minute when the model runs on your own CPU. OpenAI lists its Whisper API at an estimated $0.006 per minute, $0.36 per audio hour; my entire language-model bill was $0.029 per audio hour. Open weights move the dominant cost from "per minute, forever" to an old PC's spare time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; The audio never leaves the machine. Only transcript text goes to the model endpoint, and because the model is open-weight, a producer whose clients will not allow even that can point chaptermill at their own server. (I want to be precise: for the demo I used a hosted endpoint; I did not run the 235B model at home.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproducibility and no lock-in.&lt;/strong&gt; The Whisper weights are a file on my disk that nobody can update or retire under me, so running an episode again gives the same transcript; the chaos run shows it, with transcripts identical segment for segment to the undisturbed run. The agent talks to any OpenAI-compatible server, so switching model or provider is a config change, not a rewrite. I did try Gemma 3 27B first and it ignored the tool definitions on that endpoint, so "any" means any model whose tool calling works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tuning for a niche.&lt;/strong&gt; The "cat"/"CAD" miss above is exactly the kind of error open weights let you fix yourself. A producer whose shows are full of jargon (Unix commands, drug names, player names) can fine-tune &lt;code&gt;base.en&lt;/code&gt; on a few hours of their own corrected transcripts, or at least hand Whisper a vocabulary prompt, and run the result on the same CPU. A closed transcription API may let you pass a prompt, but you cannot retrain it on your show. I have not done this yet; it is next on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The orchestration is open too.&lt;/strong&gt; Temporal's dev server ran on the same old box, with a SQLite file, no account and no cloud.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Temporal.&lt;/strong&gt; The category asks for an agent that "survives failures, retries flaky tool calls, and picks up where it left off". chaptermill's agent is a Temporal workflow where every model call and tool call is an activity, and the demo page shows the evidence from a run where I killed the worker 9 times and the server once.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>temporal</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Which trees near me are turning this weekend? Forecasting fall color from 6,650 iNaturalist observations with TabPFN</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Tue, 06 Oct 2026 19:55:14 +0000</pubDate>
      <link>https://dev.to/13owen/which-trees-near-me-are-turning-this-weekend-forecasting-fall-color-from-6650-inaturalist-5cpe</link>
      <guid>https://dev.to/13owen/which-trees-near-me-are-turning-this-weekend-forecasting-fall-color-from-6650-inaturalist-5cpe</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;p&gt;&lt;strong&gt;Built with PriorLabs-TabPFN.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Fall color doesn't show up on a fixed date. It comes when the nights turn cold, and every fall turns cold on its own schedule. Foliage maps can tell you that a region is "near peak". They can't tell you whether the red maples on your usual walk will be red on Saturday, or whether you should wait another week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;peakweek&lt;/strong&gt; answers that question for the trees around you. You type a town in the US Northeast or Mid-Atlantic, wait about a minute, and get one card for the coming weekend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;for each of 8 common fall-color trees (red maple, sugar maple, sweetgum, northern red oak, American beech, black gum, sassafras, Norway maple), the chance that a tree of that species you come across will be &lt;strong&gt;green&lt;/strong&gt;, &lt;strong&gt;in color&lt;/strong&gt;, or &lt;strong&gt;bare&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a verdict per species (&lt;strong&gt;go&lt;/strong&gt;, &lt;strong&gt;starting&lt;/strong&gt;, &lt;strong&gt;wait&lt;/strong&gt;, &lt;strong&gt;late&lt;/strong&gt;) and the day in the next two weeks with the best chance of color;&lt;/li&gt;
&lt;li&gt;one line on how to recognize each tree, so you know which one you're looking at once you're out there;&lt;/li&gt;
&lt;li&gt;one plain sentence at the top that tells you what to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the headline for New Brunswick, New Jersey, from a forecast made on Tuesday, October 6, for the weekend of October 10–11:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No tree here is in full color yet this weekend, but sassafras, black gums and sugar maples are starting to turn; come back around Mon, Oct 19 for northern red oaks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And here it is for Burlington, Vermont, from the same day:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This weekend, go see red maples, sugar maples and black gums; come back around Tue, Oct 20 for northern red oaks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then the screen gets out of the way. &lt;strong&gt;Print card&lt;/strong&gt; gives you a one-page, black-and-white card to take with you. &lt;strong&gt;Done, go outside&lt;/strong&gt; shrinks the page to that one sentence. The footer asks for one thing after your walk: add what you saw to iNaturalist with the "Leaves" annotation. Those annotations are exactly what the model learns from, so every walk makes next fall's forecast a little better.&lt;/p&gt;

&lt;p&gt;It's for anyone who likes to go look at leaves and would rather spend that time outside than refreshing a foliage map.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&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%2F4o1rwq4txow5gbcvzka3.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%2F4o1rwq4txow5gbcvzka3.gif" alt="peakweek demo: search Burlington, wait for the forecast, read the card, print it, go outside" width="720" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a real run, searching Burlington, Vermont: the server runs TabPFN on a 4-core CPU with no GPU, and the 100 seconds it spent on this forecast (the first one after the server started) are sped up 10x in the GIF. &lt;a href="https://github.com/Bowen1314/peakweek/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;Full video&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On a phone, &lt;strong&gt;Use my location&lt;/strong&gt; near New Brunswick gives the "not yet" version of the card:&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%2Fjjy4z02umbo59lmnwg79.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%2Fjjy4z02umbo59lmnwg79.png" alt="The card on a phone near New Brunswick, NJ: no tree in full color yet, five species starting to turn" width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is what &lt;strong&gt;Print card&lt;/strong&gt; produces, one black-and-white 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%2Fq30i2djou7lfklfzmqys.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%2Fq30i2djou7lfklfzmqys.png" alt="The printed card for Burlington, VT" width="800" height="1035"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it: &lt;a href="https://bowen1314.github.io/peakweek/" rel="noopener noreferrer"&gt;bowen1314.github.io/peakweek&lt;/a&gt;.&lt;/strong&gt; A forecast takes about a minute of CPU, so instead of a server there's a GitHub Actions job: every morning it fetches the weather and runs TabPFN once for each 1-degree grid cell of the region that has training data (103 cells), and GitHub Pages serves the cards. The page shows the card for the cell around the place you search, made at that cell's weather point, so it's a 1° approximation (a cell is about 110 × 80 km), and every card says which cell it's for and the day it was made. Before anything is published, the build re-runs a few cells through the ordinary single-point forecast and stops unless the numbers match. For your exact spot, run it locally. The repo also includes saved real forecasts for New Brunswick, Burlington and Pittsburgh, so you can open the full UI without installing the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 server.py &lt;span class="nt"&gt;--fixture&lt;/span&gt; examples/forecast_new_brunswick.json   &lt;span class="c"&gt;# http://127.0.0.1:8770/&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; peakweek &lt;span class="nt"&gt;--fixture&lt;/span&gt; examples/forecast_burlington.json      &lt;span class="c"&gt;# the same card, in the terminal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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/Bowen1314" rel="noopener noreferrer"&gt;
        Bowen1314
      &lt;/a&gt; / &lt;a href="https://github.com/Bowen1314/peakweek" rel="noopener noreferrer"&gt;
        peakweek
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Which trees near me are turning this weekend? Fall color forecasts from iNaturalist + ERA5 weather with TabPFN
    &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;peakweek&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Which trees near you are turning color this weekend, and which day should you go out?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built with PriorLabs-TabPFN.&lt;/strong&gt; peakweek forecasts fall color for 8 common trees in the US Northeast and
Mid-Atlantic from 6,650 iNaturalist community-science observations and this season's weather, using the open
TabPFN v2 weights on a plain CPU. The answer is one card you can print, and then you go outside.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Bowen1314/peakweek/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FBowen1314%2Fpeakweek%2FHEAD%2Fdocs%2Fdemo.gif" alt="peakweek demo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The waiting part (about a minute of TabPFN on a 4-core CPU) is sped up in the GIF. Full video: &lt;a href="https://github.com/Bowen1314/peakweek/docs/demo.mp4" rel="noopener noreferrer"&gt;docs/demo.mp4&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Live site&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://bowen1314.github.io/peakweek/" rel="nofollow noopener noreferrer"&gt;https://bowen1314.github.io/peakweek/&lt;/a&gt;&lt;/strong&gt;: search a town or press &lt;strong&gt;Use my location&lt;/strong&gt;; nothing to install.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updated daily.&lt;/strong&gt; Every morning (around 10:20 UTC, about 6:20 in New York) a GitHub Actions job
(&lt;code&gt;.github/workflows/pages.yml&lt;/code&gt;, &lt;code&gt;scripts/build_site.py&lt;/code&gt;) fetches this season's weather from Open-Meteo, runs
TabPFN once on the runner's CPU for every grid cell below, and publishes the cards to GitHub Pages…&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/Bowen1314/peakweek" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The app itself (server, page, card, verdict logic) uses only the Python standard library. The model side needs &lt;code&gt;tabpfn&lt;/code&gt; and a CPU build of PyTorch. MIT licensed. The TabPFN v2 weights are licensed separately by Prior Labs.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The data: people photographing trees
&lt;/h3&gt;

&lt;p&gt;On iNaturalist, people can tag a tree photo with what its leaves are doing: &lt;em&gt;Green Leaves&lt;/em&gt;, &lt;em&gt;Colored Leaves&lt;/em&gt;, or &lt;em&gt;No Live Leaves&lt;/em&gt;. That makes it a labeled phenology dataset that nobody had to organize. I pulled every annotated September–November observation of the 8 species in a box covering the US Northeast and Mid-Atlantic (latitude 38.5–47.5, longitude −80.5 to −66.9), from 2018 to 2025.&lt;/p&gt;

&lt;p&gt;The API returned 10,877 observations, and most of the work was deciding what to throw away:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dropped&lt;/th&gt;
&lt;th&gt;observations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;planted or cultivated trees&lt;/td&gt;
&lt;td&gt;879&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"casual" quality grade&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;obscured coordinates&lt;/td&gt;
&lt;td&gt;244&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;location accurate to worse than 1 km&lt;/td&gt;
&lt;td&gt;455&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;conflicting annotations (every one of them "green" and "colored" on the same tree)&lt;/td&gt;
&lt;td&gt;856&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no usable leaf annotation&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no open license (I don't redistribute these)&lt;/td&gt;
&lt;td&gt;1,740&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kept&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,650&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That leaves 3,457 green, 2,846 colored and 347 bare observations, all in &lt;code&gt;data/observations.csv&lt;/code&gt; with their iNaturalist ids and license codes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The features: this year's weather, not just the calendar
&lt;/h3&gt;

&lt;p&gt;The calendar and latitude explain a lot of fall color. The question I cared about is whether &lt;em&gt;this&lt;/em&gt; fall's weather adds anything on top of that. So for every observation, the same code that the app uses (&lt;code&gt;peakweek/features.py&lt;/code&gt;) computes, from Open-Meteo's ERA5 archive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chilling degree days since September 1 (the sum of how far each day's mean temperature fell below 20 °C);&lt;/li&gt;
&lt;li&gt;frost nights (≤ 0 °C) and cold nights (≤ 5 °C) since September 1;&lt;/li&gt;
&lt;li&gt;last week's mean minimum temperature and the last two weeks' mean temperature;&lt;/li&gt;
&lt;li&gt;rain over the last 30 days;&lt;/li&gt;
&lt;li&gt;how much warmer or colder this fall has been than the same place's other falls;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;plus species, latitude, longitude, elevation, day of year and day length. Weather comes per 1-degree grid cell. A finer grid would have needed about 15,000 weighted calls to Open-Meteo, more than the free tier allows in a day. The whole project used about 7,000.&lt;/p&gt;

&lt;p&gt;One detail I didn't expect: Open-Meteo's forecast API can also return recent past days, which looked like an easy way to get "this season so far". But for September 2026 it returned only about 50 of the 92 past days I asked for, and its rainfall ran 2.3 mm/day below the archive. A model trained on archive weather would have been reading a different instrument. So the app takes September 1 to yesterday from the same ERA5 archive as the training data, and uses the forecast API only for today and the next 14 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model: TabPFN v2, on a CPU
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt; is a tabular foundation model from Prior Labs. You don't train it on your data. At prediction time you show it a set of labeled rows (the "context") along with the rows you want predicted, and it returns class probabilities with no training step at all. That suits this problem well: a few thousand rows, a mix of categorical and numeric features, and an answer where calibrated probabilities matter more than the top class.&lt;/p&gt;

&lt;p&gt;I used the &lt;strong&gt;TabPFN v2&lt;/strong&gt; open weights (&lt;code&gt;Prior-Labs/TabPFN-v2-clf&lt;/code&gt; on Hugging Face, 29 MB, Apache 2.0 plus an attribution requirement). They download without an account and run on a CPU. On a 4-core machine with no GPU, under a hard 800 MB memory limit, one forecast (8 species × 15 days = 120 rows) takes 51–58 seconds once the server is warm (the first forecast after it starts took 99 seconds in the demo recording), using about 670 MB of memory.&lt;/p&gt;

&lt;p&gt;The interesting choice is which rows go into the context, since on a CPU, inside 800 MB, about 1,000 rows is the practical limit. I tried four strategies on a validation year:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One context per species&lt;/strong&gt; was the obvious idea, and it broke in a TabPFN-specific way. Some species have almost no "bare" rows (sweetgum has 1 in 180), and when a class is missing from the context, TabPFN gives it near-zero probability. A single bare tree of such a species then costs a fortune in log loss: 0.577 overall, against 0.436 for one shared context. Adding a little mass to the missing classes brought it down to 0.461, still worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nearest neighbours&lt;/strong&gt; in place and week (a separate context for each place and week) was 24 times slower and not measurably better (−0.002 log loss, 95% CI −0.028 to +0.022).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One shared context&lt;/strong&gt; of 1,000 rows, sampled evenly across species × leaf state, did best.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Averaging three such contexts&lt;/strong&gt; (different random samples) beat a single one in 97% of paired bootstrap resamples. That's the final setup: 3 contexts × 4 estimators.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Is it any good? One shot at a held-out year
&lt;/h3&gt;

&lt;p&gt;I split by year, so the model never sees the fall it's being tested on. I chose every setting above on 2024 (training on 2018–2023), wrote the configuration to &lt;code&gt;eval/locked.json&lt;/code&gt;, and then scored 2025 &lt;strong&gt;once&lt;/strong&gt; (training on 2018–2024; 1,725 observations). Lower is better for log loss and Brier score:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model, 2025 test&lt;/th&gt;
&lt;th&gt;log loss&lt;/th&gt;
&lt;th&gt;Brier&lt;/th&gt;
&lt;th&gt;accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TabPFN v2, all features&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.498&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.285&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.805&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gradient-boosted trees (sklearn), all features&lt;/td&gt;
&lt;td&gt;0.505&lt;/td&gt;
&lt;td&gt;0.291&lt;/td&gt;
&lt;td&gt;0.802&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN v2, calendar only (species, place, day of year)&lt;/td&gt;
&lt;td&gt;0.516&lt;/td&gt;
&lt;td&gt;0.291&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;logistic regression, all features&lt;/td&gt;
&lt;td&gt;0.519&lt;/td&gt;
&lt;td&gt;0.289&lt;/td&gt;
&lt;td&gt;0.805&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;logistic regression, species + day of year + latitude&lt;/td&gt;
&lt;td&gt;0.552&lt;/td&gt;
&lt;td&gt;0.307&lt;/td&gt;
&lt;td&gt;0.795&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;climatology (how often each species was colored that week in past years)&lt;/td&gt;
&lt;td&gt;0.581&lt;/td&gt;
&lt;td&gt;0.345&lt;/td&gt;
&lt;td&gt;0.752&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What I take from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN beat the baseline I committed to in advance.&lt;/strong&gt; On the 2024 validation year, logistic regression on the same features was the strongest baseline, and it tied TabPFN almost exactly (0.4305 against 0.4307). On 2025, TabPFN was better by 0.021 log loss: 95% paired-bootstrap CI −0.035 to −0.007 when resampling observations, and −0.041 to −0.003 when resampling grid-cell × week clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This fall's weather matters.&lt;/strong&gt; The same TabPFN given only species, place and date was 0.018 worse (CI −0.026 to −0.011). Knowing how cold the nights have been beats knowing the date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not a clean sweep.&lt;/strong&gt; Gradient-boosted trees came within 0.007 of TabPFN, which is not a significant difference. Per species, TabPFN was best for red maple, sugar maple and American beech, while simpler models did better on northern red oak, black gum, sweetgum and sassafras (45–164 test observations each). Accuracy is about 0.80 for every decent model, so the gain is in the probabilities, not in the top guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Years differ more than models do.&lt;/strong&gt; Every model scored worse on 2025 than on 2024 (logistic regression went from 0.431 to 0.519).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also checked the live pipeline on this fall. There were 99 annotated observations from September 26 to October 5, 2026 that passed the same quality filters (77 green, 22 colored), all made after the training data ends. Logistic regression scored 0.436 and TabPFN 0.454. That's too few observations to rank the models (CI on the difference −0.011 to +0.046), but it shows the app's probabilities are sensible on data nobody has seen. Every run, including the ones that lost, is in &lt;code&gt;eval/RESULTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  From probabilities to "go outside"
&lt;/h3&gt;

&lt;p&gt;The card never changes the model's numbers. It only summarizes them, with rules simple enough to print on the card:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;verdict&lt;/th&gt;
&lt;th&gt;rule (weekend average)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;late&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;chance of bare branches ≥ 35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;go&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;chance of color ≥ 50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;late&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;color is already fading: falling since earlier in the window while bare branches rise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;starting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;chance of color ≥ 25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;wait&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;otherwise, with the day color is most likely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I also had to settle on wording. "About 4 in 10 sugar maples you find this weekend should show fall color" is honest, because that's what the model predicts: one tree you come across, not the share of the canopy.&lt;/p&gt;

&lt;p&gt;The first version of the card had a problem you'd only catch by reading it: it told Burlington, Vermont, to go see sweetgum this weekend. The weather side of the forecast wasn't the issue; the model simply has no idea where trees grow. iNaturalist has no research-grade sweetgum records at all within 50 km of Burlington. So for each place, the app now makes one more keyless call to iNaturalist and counts research-grade observations of each tree within 50 km. A tree with fewer than 5 stays on the card with its numbers, but it's listed last, dimmed, marked "Rarely recorded within 50 km", and left out of the headline. In Burlington that moves sweetgum (0 records) and sassafras (3) to the bottom. In New Brunswick, every one of the eight trees has at least 541 records nearby.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it can't do yet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's better at "not yet" than at "too late".&lt;/strong&gt; People rarely photograph bare trees: only 347 of 6,650 observations are bare. So the chance of bare branches stays small even late in the window, and in early October the best day is often the last day of the forecast. "Late" is rare on the card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The labels come from what people chose to photograph.&lt;/strong&gt; A brilliant maple gets photographed more often than a dull one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is recent.&lt;/strong&gt; 2024 and 2025 make up 65% of the rows, and 2018 has only 53.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weather is per 1-degree cell&lt;/strong&gt;, so a cold hollow and a sunny ridge in the same cell get the same weather (elevation is still a feature).&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;For peakweek, "open" isn't a slogan. Every layer of the project depends on something open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open weights mean no gatekeeper and no meter.&lt;/strong&gt; TabPFN v2 is 29 MB that downloads without an account and runs on a 4-core CPU with no GPU, inside 800 MB of memory. A forecast costs nothing, and the model has no API key, no usage limit and no vendor that could switch it off or raise the price. (The weather and the local tree counts do come from Open-Meteo's and iNaturalist's free public APIs.) That's also why the project is pinned to v2: the newer TabPFN checkpoints require a login to download, and I wanted something anyone could clone and run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data made the model possible.&lt;/strong&gt; The training set is 6,650 observations from people who stopped to photograph a tree and tagged its leaves. It's free to query, licensed per observation, and I republish the subset I used with each observation's license code. The weather comes from Open-Meteo under CC BY 4.0. &lt;code&gt;scripts/build_dataset.py&lt;/code&gt; rebuilds it from the public APIs, and every number in this post comes from files in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could look inside and fix things.&lt;/strong&gt; Because the model runs in my own process, I could test four ways of building its context, find the "missing class means zero probability" problem, and measure exact memory and timing under a hard cap. A hosted prediction endpoint would only have shown me the outputs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data in, open data out.&lt;/strong&gt; The card ends by asking you to log what you saw on iNaturalist. A closed app would keep that observation for itself. peakweek asks you to put it back into the commons it learned from, where next year's model, or anyone else's, can use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this with Claude Code, working as a small team of agents: one built the dataset, the features and the evaluation, and ran the TabPFN backtests on a small home server; another built the card, the page and the CLI. A coordinating session wrote the specs and checked the results. Three choices shaped the build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only open weights that run without an account&lt;/strong&gt;, on hardware I already have: TabPFN v2 on a small home server's CPU, under an 800 MB memory limit, with no model weights on my laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the settings before looking at the test year.&lt;/strong&gt; The configuration was written down before 2025 was scored, and the post reports that one score, including where TabPFN didn't win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the card, not just the metrics.&lt;/strong&gt; The sweetgum-in-Vermont problem above didn't show up in any metric. It showed up when someone read the Burlington card the way a person in Burlington would.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: this project was built with AI coding agents (Claude Code), which wrote the code, ran the evaluation and drafted this post.&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN.&lt;/strong&gt; TabPFN v2 (open weights, CPU) is the forecasting model: it predicts green, colored or bare for each species and day from 6,650 historical community-science observations and this season's weather, held-out year evaluation included.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>'No peanuts' became include_ingredients: ["peanuts"]: a benchmark for tool calls a validator cannot catch</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:01:51 +0000</pubDate>
      <link>https://dev.to/13owen/no-peanuts-became-includeingredients-peanuts-a-benchmark-for-tool-calls-a-validator-cannot-1pgl</link>
      <guid>https://dev.to/13owen/no-peanuts-became-includeingredients-peanuts-a-benchmark-for-tool-calls-a-validator-cannot-1pgl</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;DEV x Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;An agent I was running called a ranking tool with &lt;code&gt;limit: 8&lt;/code&gt;. The tool had no &lt;code&gt;limit&lt;/code&gt; parameter and rejected&lt;br&gt;
the call: &lt;code&gt;unknown argument "limit"&lt;/code&gt;. I wanted to know how often models do that, so I built a benchmark&lt;br&gt;
around one question: &lt;strong&gt;what does a model do when the tool cannot do what was asked?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each item is one tool, written as a JSON Schema with &lt;code&gt;additionalProperties: false&lt;/code&gt; everywhere, and one user&lt;br&gt;
request that asks for something just outside it. The model answers with one tool call as JSON text. A&lt;br&gt;
web-search tool with only &lt;code&gt;query&lt;/code&gt; and &lt;code&gt;safe_search&lt;/code&gt;, and a user who wants "the top 5 results", is the&lt;br&gt;
classic case: does the model invent &lt;code&gt;max_results&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;That was the plan. The item the post is really about looks like this instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thai dinner recipes that take 30 minutes or less and have no peanuts in them. My son is allergic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tool has &lt;code&gt;cuisine&lt;/code&gt;, &lt;code&gt;max_minutes&lt;/code&gt; and &lt;code&gt;include_ingredients&lt;/code&gt;. It has no way to exclude anything. A model&lt;br&gt;
that reaches for the closest parameter sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"cuisine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_minutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"include_ingredients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"peanuts"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call is valid against the schema. A validator passes it. It asks for the one thing the user said&lt;br&gt;
could hurt their child.&lt;/p&gt;

&lt;p&gt;The benchmark has 202 items over 75 invented tools, in eight families:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;conventional&lt;/strong&gt; (31): the request invites a parameter most APIs have and this tool lacks (&lt;code&gt;limit&lt;/code&gt;,
&lt;code&gt;page&lt;/code&gt;, &lt;code&gt;sort&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;near_miss&lt;/strong&gt; (23): the usual name for a parameter that exists under another name (&lt;code&gt;max_results&lt;/code&gt; when
the tool has &lt;code&gt;pageSize&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;inexpressible&lt;/strong&gt; (30): a filter the tool cannot express at all ("published after 2020").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;enum_pressure&lt;/strong&gt; (25): a value missing from an enum (Kelvin when the enum is metric/imperial).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nested&lt;/strong&gt; (21): an unknown key inside an object or array item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;repurposing&lt;/strong&gt; (28): a parameter that exists and means something else (&lt;code&gt;cc&lt;/code&gt; for a blind copy,
&lt;code&gt;older_than_days&lt;/code&gt; for "modified in the last 7 days", &lt;code&gt;include_ingredients&lt;/code&gt; for an allergy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;control&lt;/strong&gt; (32) and &lt;strong&gt;matched_control&lt;/strong&gt; (12): nothing is missing; the right parameter must be used.
The matched controls use the same tools as the repurposing items, with a request where &lt;code&gt;cc&lt;/code&gt; or
&lt;code&gt;author&lt;/code&gt; really is the right parameter. They are there so that a detector that fires on "used &lt;code&gt;cc&lt;/code&gt;"
instead of "misused &lt;code&gt;cc&lt;/code&gt;" would show up as false alarms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three prompt conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;neutral&lt;/code&gt;: answer with exactly one call.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instructed&lt;/code&gt;: plus "Use only the parameters defined in the tool's schema; do not add any parameter that
is not listed there."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;may_decline&lt;/code&gt;: plus permission to reply &lt;code&gt;{"cannot_do": "&amp;lt;one sentence&amp;gt;"}&lt;/code&gt; instead of calling. A decline
is correct where the tool cannot serve the request and wrong where it can, so declining everything does
not pay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scoring is JSON Schema validation plus a fixed check per item, written before the run (for example: on the&lt;br&gt;
peanut item, &lt;code&gt;include_ingredients&lt;/code&gt; containing "peanut" is a misuse). No model grades any reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On Kaggle (the leaderboard run).&lt;/strong&gt; Eleven models from Kaggle's list, each on all 202 items in all three&lt;br&gt;
conditions, temperature 0, one sample per item, reasoning left at the provider default. Kaggle does not&lt;br&gt;
return reasoning text, so "reasons" below is inferred from output tokens: a model that reports several&lt;br&gt;
times more output tokens than its visible reply is reasoning before it answers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model (Kaggle slug)&lt;/th&gt;
&lt;th&gt;Lab&lt;/th&gt;
&lt;th&gt;Reasons by default&lt;/th&gt;
&lt;th&gt;Mean output tokens (neutral)&lt;/th&gt;
&lt;th&gt;Cost, 606 calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5-default&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;unclear (about 2x the visible reply)&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;$2.3730&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-5-default&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;unclear (about 2.7x)&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;$1.0657&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.5-2026-04-23&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;unclear (about 2.4x)&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;$2.2980&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.7-flash&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;yes (hidden)&lt;/td&gt;
&lt;td&gt;231&lt;/td&gt;
&lt;td&gt;$0.7804&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash-lite&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;$0.1129&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-31b-it&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;yes (hidden)&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;$0.1461&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-26b-a4b-it&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;yes (hidden)&lt;/td&gt;
&lt;td&gt;317&lt;/td&gt;
&lt;td&gt;$0.1636&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-mini-2026-03-17&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;$0.2246&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-nano-2026-03-17&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;$0.0615&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;yes (hidden)&lt;/td&gt;
&lt;td&gt;152&lt;/td&gt;
&lt;td&gt;$0.0295&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5-20251001&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;no (its tokens are visible prose)&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;$0.4172&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In total: 6,666 calls, $7.67 of Kaggle's free model quota (costs as reported per call by Kaggle's model&lt;br&gt;
proxy). Counting test tasks and superseded runs, the project used $14.43 of the quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locally, before Kaggle (labelled "local" below).&lt;/strong&gt; Two runs on Nebius Token Factory, temperature 0, one&lt;br&gt;
sample per item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Pilot (v1.0):&lt;/em&gt; every text model Nebius listed on 2026-10-02, 24 open-weight models (Qwen, DeepSeek,
Kimi, GLM, Nemotron, gpt-oss, Gemma 3, Hermes, MiniMax and others), the first six families, &lt;code&gt;neutral&lt;/code&gt; and
&lt;code&gt;instructed&lt;/code&gt;. 6,552 calls, $3.57.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Validation (v1.1):&lt;/em&gt; the 11 cheapest of those, 5 that answer directly and 6 that reason first, on the
repurposing family, the matched controls and the 60 core items, all three conditions. 1,848 calls, $0.31.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The failure I built the benchmark for is almost absent
&lt;/h3&gt;

&lt;p&gt;Local pilot: models invented a parameter name in &lt;strong&gt;0.9%&lt;/strong&gt; of replies (20 of 2,127) on the families built to&lt;br&gt;
provoke it. 19 of 24 models never did it. A missing &lt;code&gt;limit&lt;/code&gt;, &lt;code&gt;page&lt;/code&gt; or &lt;code&gt;sort&lt;/code&gt; drew 3 phantom arguments in&lt;br&gt;
618 replies, all from one model. The four NVIDIA Nemotron models, the family from the original incident,&lt;br&gt;
produced none in 838 replies.&lt;/p&gt;

&lt;p&gt;Kaggle, neutral: it is near zero for every model. &lt;strong&gt;2 of 1,148&lt;/strong&gt; replies (0.2%) on those families named a&lt;br&gt;
parameter the schema does not have, and nine of the eleven models never did. The two: gpt-5.4-mini added&lt;br&gt;
&lt;code&gt;min_rating: 4&lt;/code&gt; to a product search that has no rating filter, and gpt-oss-20b answered a request to list&lt;br&gt;
files with &lt;code&gt;{"path": "/", "depth": 2}&lt;/code&gt;, addressed to &lt;code&gt;repo_browser.print_tree&lt;/code&gt;, a tool it was not given.&lt;/p&gt;

&lt;p&gt;This is a text-format test (the schema is in the prompt, the call is JSON text), so it says nothing about&lt;br&gt;
native tool-calling APIs. In this format, the obvious failure is mostly gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. What replaces it is a call that passes validation and does something else
&lt;/h3&gt;

&lt;p&gt;Local validation, 28 repurposing items, 11 models, must call: &lt;strong&gt;29.9%&lt;/strong&gt; of replies (92 of 308, 95% CI&lt;br&gt;
25-35%) were schema-valid calls that put the request into a parameter that means something else. The same&lt;br&gt;
tools with a request where that parameter is right: 132 of 132 correct, no false alarms.&lt;/p&gt;

&lt;p&gt;The cases that stayed with me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Commits reviewed by sam-lee", the tool has &lt;code&gt;author&lt;/code&gt;: 9 of 11 models sent &lt;code&gt;"author": "sam-lee"&lt;/code&gt;. One
reasoned first: "we can only filter by author. Perhaps we assume reviewed means authored? ... We'll use
author."&lt;/li&gt;
&lt;li&gt;"Files modified within the last 7 days", the tool has &lt;code&gt;older_than_days&lt;/code&gt;: all five models that answer
without reasoning sent &lt;code&gt;older_than_days: 7&lt;/code&gt;, which returns exactly the files that were not asked for.&lt;/li&gt;
&lt;li&gt;"Subscriptions currently on pause", statuses &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;trialing&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;: 4 of 11 sent &lt;code&gt;canceled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The peanut call above: one model in all three conditions, another in two. Rare (5 of 33 replies), and the
one I would least want in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the pilot this showed up on four items before I knew to look for it. Asked to BCC an address with a tool&lt;br&gt;
that has only &lt;code&gt;cc&lt;/code&gt;, 14 of 24 models put it in &lt;code&gt;cc&lt;/code&gt;. Of the 18 reasoning models, 11 did it, and all 11 had&lt;br&gt;
written in their reasoning that the tool has no BCC. One explained: "There is no way to indicate&lt;br&gt;
limitation. Since must call tool, use cc."&lt;/p&gt;

&lt;p&gt;Models that reason first do much better (12.5% against 50.7% for those that do not), but they are not&lt;br&gt;
immune: 5 of 6 still sent reviewed-by as &lt;code&gt;author&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Kaggle, neutral, 28 repurposing items per model: the spread runs from &lt;strong&gt;0 to 13&lt;/strong&gt;. gemma-4-31b and&lt;br&gt;
claude-opus-5 repurposed nothing; gpt-5.4-nano repurposed 13 of 28 (46.4%). The three frontier models are&lt;br&gt;
near zero but not all at it: claude-opus-5 0, gpt-5.5 1, claude-sonnet-5 2. Pooled over the eleven models,&lt;br&gt;
46 of 308 replies (14.9%). The matched controls again drew no false alarms: 396 replies over all runs,&lt;br&gt;
none flagged.&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;Repurposed, neutral (of 28)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-31b&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.7-flash&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-26b-a4b&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash-lite&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-mini&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-nano&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same items did the damage. "Commits reviewed by sam-lee" became &lt;code&gt;author&lt;/code&gt; for 4 of 11, and so did&lt;br&gt;
"modified within the last 7 days" as &lt;code&gt;older_than_days: 7&lt;/code&gt;. "Subscriptions on pause" became &lt;code&gt;canceled&lt;/code&gt; for 3.&lt;br&gt;
The peanut item caught one model, gpt-5.4-mini, which sent &lt;code&gt;"include_ingredients": ["no peanuts"]&lt;/code&gt;: the&lt;br&gt;
negation went into the inclusion filter as text. The item that caught the most was "songs from 2019 that&lt;br&gt;
Dana Okafor wrote for other singers", with a tool that filters only by performing &lt;code&gt;artist&lt;/code&gt;: 7 of 11 sent&lt;br&gt;
&lt;code&gt;"artist": "Dana Okafor"&lt;/code&gt;, which returns the songs she sang herself. That was one of claude-sonnet-5's two&lt;br&gt;
misses and gemini-3.7-flash's only wrong answer in 202. Sonnet's other miss was the BCC item: the blind copy&lt;br&gt;
went into &lt;code&gt;cc&lt;/code&gt;, as it did for four of the smaller models. gpt-5.5's one miss: "contacts at Umbra Labs that&lt;br&gt;
have no owner assigned" became &lt;code&gt;owner_email: ""&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The four models that reason by default repurposed 10 of 112 (8.9%); the four that clearly do not, 33 of 112&lt;br&gt;
(29.5%); the three frontier models, where output tokens do not settle it, 3 of 84. Same direction as&lt;br&gt;
locally, with the same caveat (section 5).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let the model say no, and half of it goes away
&lt;/h3&gt;

&lt;p&gt;Local validation. One added sentence (you may reply &lt;code&gt;{"cannot_do": ...}&lt;/code&gt;) took repurposed replies from&lt;br&gt;
&lt;strong&gt;29.9% to 15.9%&lt;/strong&gt; (92 to 49 of 308). Same model and item: 44 got better, 1 got worse (exact McNemar&lt;br&gt;
p &amp;lt; 0.0001). For models that reason first: 12.5% to 2.4%.&lt;/p&gt;

&lt;p&gt;Nobody abused the exit: 0 declines in 352 replies where the tool could serve the request.&lt;/p&gt;

&lt;p&gt;It only helps a model that takes it. Two of the eleven declined twice in 56 chances and repurposed as often&lt;br&gt;
as before. Hermes-4-405B, which also answers without reasoning, declined 22 of 28 and went from 13&lt;br&gt;
repurposed replies to 3.&lt;/p&gt;

&lt;p&gt;Kaggle, all eleven models: repurposed replies went from &lt;strong&gt;14.9% to 6.8%&lt;/strong&gt; (46 to 21 of 308). Same model and&lt;br&gt;
item: 28 got better, 3 got worse (exact McNemar p &amp;lt; 0.0001). Eight of the eleven repurposed nothing with the&lt;br&gt;
option to decline, including all three frontier models. The three that did not get there are three of the&lt;br&gt;
four that answer without reasoning: claude-haiku-4-5 went from 5 to 3, gpt-5.4-mini from 9 to 5.&lt;/p&gt;

&lt;p&gt;Declines where the tool could not serve the request: 571 of 1,485 (38.5%); on the repurposing items, 183&lt;br&gt;
of 308, with claude-sonnet-5 declining 26 of 28 and gemini-3.7-flash 25. Declines where it could: 2 of 737&lt;br&gt;
(0 of 132 matched controls, 0 of 253 near misses, 2 of 352 controls). Both were the Gemma models on the&lt;br&gt;
same item, news from September 2026 with a tool described as covering the last 12 months. gemma-4-31b&lt;br&gt;
explained that the tool "cannot search for news articles from the future". The prompt gives no date.&lt;/p&gt;

&lt;p&gt;The same split as locally: gpt-5.4-nano declined 3 times in 135 chances and repurposed 13 of 28 in both&lt;br&gt;
conditions.&lt;/p&gt;

&lt;p&gt;The "only use parameters in the schema" sentence, which is what most people add when tool calls go wrong,&lt;br&gt;
did nothing locally: 92 to 91. It forbids inventing parameters, and these calls use only real ones. On&lt;br&gt;
Kaggle it did something, less than the decline option: 46 to 34 of 308 (18 better, 6 worse, exact McNemar&lt;br&gt;
p = 0.023), four of the twelve from gpt-oss-20b alone.&lt;/p&gt;

&lt;p&gt;The part I cannot settle: 123 of the 168 declines replaced a call that was already acceptable (the request&lt;br&gt;
minus the part the tool cannot express). Is "the tool can't do that" better than a wider result the caller&lt;br&gt;
can filter? The benchmark scores both as correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Where the schema does break, it breaks at the enum
&lt;/h3&gt;

&lt;p&gt;Local pilot: &lt;strong&gt;10.3%&lt;/strong&gt; of replies sent a value not on the enum list (52 of 505), against 0.5% for a missing&lt;br&gt;
&lt;code&gt;limit&lt;/code&gt; or &lt;code&gt;page&lt;/code&gt;. Portuguese for a language enum without it (9 of 18 models), a "legal" department that&lt;br&gt;
does not exist (7 of 23). One model's reasoning: "Since "neurology" is not in the enum, I should still&lt;br&gt;
attempt the call - the system will handle the validation."&lt;/p&gt;

&lt;p&gt;When no listed value fits, models split: leave the parameter out (51%), send some other listed value&lt;br&gt;
(35%), send the unlisted one (14%). "Which orders were returned?" became &lt;code&gt;status: "cancelled"&lt;/code&gt; for 5 of 18.&lt;/p&gt;

&lt;p&gt;Kaggle, neutral: 7 of 260 parsed replies (2.7%) sent a value off the list, and only one model stands out.&lt;br&gt;
gpt-5.4-mini did it 3 times in 25: a library branch "northgate", a "legal" department, a "winter" term.&lt;br&gt;
gemini-3.5-flash-lite and claude-sonnet-5 each sent "pt" for Portuguese once, gpt-5.4-nano sent "northgate"&lt;br&gt;
once, gpt-oss-20b sent an empty department once, and the other six never did.&lt;/p&gt;

&lt;p&gt;claude-haiku-4-5 handled the enum items differently: on 12 of 25 it did not call at all and explained in&lt;br&gt;
prose that the tool could not do it, although the prompt asks for exactly one call. Those 12, and 3 more on&lt;br&gt;
repurposing items, are 15 of its 23 wrong answers in &lt;code&gt;neutral&lt;/code&gt; (88.6%). gpt-oss-20b did the same on 5&lt;br&gt;
items and returned an empty reply on 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. What the data does not support
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;That reasoning causes the gap. The models that do not reason are different models, mostly older. Running
one model with reasoning on and off is the obvious next experiment. The Kaggle run has the same problem:
its reasoning and non-reasoning groups are different models, no model was run both ways, and for the
three frontier models the token counts do not say whether they reasoned.&lt;/li&gt;
&lt;li&gt;Rankings between models one or two items apart. Per-model cells are 28 items on the repurposing family.&lt;/li&gt;
&lt;li&gt;Anything about native tool calling.&lt;/li&gt;
&lt;li&gt;The detectors on the new family were tightened after I read every local reply: several fired on
&lt;code&gt;min_amount: 0&lt;/code&gt;, a value that changes nothing. That took the local figure from 109 to 92. The Kaggle run
is the first scored with detectors fixed in advance.&lt;/li&gt;
&lt;li&gt;Six of the 28 repurposing items caught no model locally, and none of the six caught any of the eleven
Kaggle models in any condition either.&lt;/li&gt;
&lt;li&gt;On the original 162 items the score saturates: 13 of 24 local models scored 99% or above. The new family
is what separates models. That held on Kaggle: in &lt;code&gt;neutral&lt;/code&gt;, five of the eleven models scored 162 of 162
on the original items and three more scored 160 or 161, while on the 28 repurposing items the same eleven
ranged from 0 to 13 repurposed replies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.kaggle.com/benchmarks/tasks/bowen1314/phantom-args-neutral" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/tasks/bowen1314/phantom-args-neutral&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The public task is the &lt;code&gt;neutral&lt;/code&gt; condition; &lt;code&gt;instructed&lt;/code&gt; and &lt;code&gt;may_decline&lt;/code&gt; ran as separate private tasks&lt;br&gt;
from the same notebook, and every reply from all three is in the repository&lt;br&gt;
(&lt;code&gt;results/kaggle_cli/kaggle_raw.jsonl&lt;/code&gt;). The task reports one number per model: the share of requests&lt;br&gt;
answered correctly, meaning a schema-valid call that does not misuse a parameter, or, in &lt;code&gt;may_decline&lt;/code&gt;, a&lt;br&gt;
decline where declining is right. 100% means every call would pass a strict validator and none of them quietly asked for&lt;br&gt;
the wrong thing. It does not mean the model asks the user a clarifying question when it should; the format&lt;br&gt;
does not allow one.&lt;/p&gt;

&lt;p&gt;Limits, in short: text-format calls only; temperature 0 and one sample per item; per-item checks are&lt;br&gt;
narrow by design (they catch the specific misuse, not every bad call); a decline is scored by its&lt;br&gt;
presence, not by whether its sentence is accurate.&lt;/p&gt;

&lt;p&gt;How it got here: v1.0 had six families and was built to catch invented parameters. The pilot found almost&lt;br&gt;
none, and four items that happened to allow a parameter swap showed the bigger effect. v1.1 turned those&lt;br&gt;
four into a family of 28 with matched controls and added the option to decline. The v1.0 items, prompts&lt;br&gt;
and scoring are frozen by a hash of every prompt, so v1.0 numbers stay comparable.&lt;/p&gt;

&lt;p&gt;Code, items, every reply and both local write-ups: &lt;a href="https://github.com/Bowen1314/phantom-args" rel="noopener noreferrer"&gt;https://github.com/Bowen1314/phantom-args&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI assistance: Claude (Anthropic) did essentially all of the work: the items and tools, the scoring code&lt;br&gt;
and its tests, the local and Kaggle runs, the analysis, and this post. I chose to enter, set up the&lt;br&gt;
accounts and the Kaggle token, and approved publishing. Every number comes from code in the repository,&lt;br&gt;
and Claude checked each one against the raw reply files.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The steak weighed less than we ordered: settling a shared grocery order with Gemma</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Sat, 03 Oct 2026 00:49:34 +0000</pubDate>
      <link>https://dev.to/13owen/the-steak-weighed-less-than-we-ordered-settling-a-shared-grocery-order-with-gemma-1pf6</link>
      <guid>https://dev.to/13owen/the-steak-weighed-less-than-we-ordered-settling-a-shared-grocery-order-with-gemma-1pf6</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;My friend and I share Walmart grocery orders. In September my friend sent me 13 product links, I added 8 things of my own, and the order went on my card. When it arrived, we had to settle up from the receipt. It was more fiddly than it should have been:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sirloin steak is sold by weight. I ordered it at $13.45; it weighed in at &lt;strong&gt;$11.79&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Three items were on Rollback (the detergent, the tuna and the chicken wings), so they cost less than their listed prices.&lt;/li&gt;
&lt;li&gt;The tuna shipped separately from the rest of the order.&lt;/li&gt;
&lt;li&gt;New Jersey doesn't tax groceries, so &lt;strong&gt;all $1.19 of tax came from one item&lt;/strong&gt;: the Tide detergent ($17.94 × 6.625%), which was my friend's.&lt;/li&gt;
&lt;li&gt;We split the $5 tip evenly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order total was $130.94. To get from that number to "who owes what", I had to match my friend's links to lines on the receipt, find the one item the tax came from, and remember that the steak's price had changed. The answer:&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;My friend&lt;/th&gt;
&lt;th&gt;Me&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Items&lt;/td&gt;
&lt;td&gt;13 items, $90.58&lt;/td&gt;
&lt;td&gt;8 items, $34.17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax&lt;/td&gt;
&lt;td&gt;$1.19 (the Tide)&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tip&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Share&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$94.27&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$36.67&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So my friend owed me $94.27. Working that out was fiddly, and we'll be doing it again after the next order, so I built &lt;strong&gt;splitcart&lt;/strong&gt;. You paste the group chat where we said what we wanted, paste the receipt (or a screenshot of it), check what Gemma read, and get a plain message to send back to the chat.&lt;/p&gt;

&lt;p&gt;I built it for my friend and me, and for the way we actually order: links in a chat, one card, one receipt. It can handle more people, but that's who it's for.&lt;/p&gt;

&lt;p&gt;Before writing this, I ran it once more on our September order, the way we'd actually use it: paste the chat, paste the receipt, press the button. Gemma took 37 seconds and cost $0.0011. All four checks came up green: items match the subtotal, the total adds up, the steak's weighed price, the tax on the Tide. All 21 items went to the right person without a single edit, and the message at the end said the same thing we settled on in September: my friend owes me $94.27.&lt;/p&gt;

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

&lt;p&gt;There's no public deployment. It's a small server you run on your own laptop with your own API key, because the receipt and the chat should stay on your machine until the personal details are stripped out. Here is the real app running on a synthetic copy of our September order (same 21 items and prices, fake address, card and order number):&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%2F9yu4wd0sviinmsomco7s.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%2F9yu4wd0sviinmsomco7s.gif" alt="splitcart demo: paste, cover, check, review, settle" width="600" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The wait while Gemma reads the screenshot is sped up 8x in the GIF; in reality it takes about 40 seconds. &lt;a href="https://github.com/Bowen1314/splitcart/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;Full video&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Paste.&lt;/strong&gt; The chat goes on the left. The receipt goes on the right, as pasted text or a screenshot. On a screenshot, you drag black boxes over your address, card digits and order number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what gets sent.&lt;/strong&gt; Names become &lt;em&gt;Person A&lt;/em&gt; and &lt;em&gt;Person B&lt;/em&gt;. Code removes the address, card digits, order number, phone numbers and link tracking. This screen shows exactly what will leave the laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; You see Gemma's reading of every line, with the weighed steak and the Rollback prices flagged. Code checks that the items add up to the subtotal, and that subtotal + tax + tip equals the total. Every owner, price and "taxed" box can be changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settle up.&lt;/strong&gt; You get each person's share with a short explanation, and a message ready to paste:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Walmart order, Sep 6: total $130.94, paid by Me.
Friend owes Me $94.27.

Friend: $94.27
  - 13 items $90.58
  - 3 Rollback prices already included (saved $6.76)
  - tax $1.19 (on Tide Ultra Odor Defense Plus)
  - half the tip $2.50
Me: $36.67
  - 8 items $34.17
  - Sirloin Tender Steak was sold by weight: estimated $13.45, charged $11.79
  - half the tip $2.50

Tax $1.19 is on Tide Ultra Odor Defense Plus (the only taxed item, $17.94): $17.94 x 6.625% = $1.19, which matches the receipt.
Tip $5.00, split evenly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F6jz2x8qg6mzf6zasftef.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%2F6jz2x8qg6mzf6zasftef.png" alt="What gets sent: names replaced, screenshot covered and cut into tiles" width="800" height="500"&gt;&lt;/a&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%2Fyf6t5oym3cg3mtteswjw.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%2Fyf6t5oym3cg3mtteswjw.png" alt="Review: weighed steak and Rollback prices flagged, the words Gemma matched on, owners editable" width="800" height="500"&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/Bowen1314" rel="noopener noreferrer"&gt;
        Bowen1314
      &lt;/a&gt; / &lt;a href="https://github.com/Bowen1314/splitcart" rel="noopener noreferrer"&gt;
        splitcart
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Settle a shared grocery order from the group chat and the receipt. Gemma reads, code does the math.
    &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;splitcart&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Settle a shared grocery order from the group chat and the receipt.&lt;/p&gt;

&lt;p&gt;My friend and I split Walmart orders. After the September order we had to work out who owed what from the receipt
a steak sold by weight that came in cheaper than ordered, three Rollback prices, tuna that shipped separately
sales tax on exactly one item (detergent; groceries aren't taxed in New Jersey), and a $5 tip split evenly.
splitcart does that settle-up: paste the chat where people said what they wanted, paste (or screenshot) the receipt,
check the result, copy the message.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Bowen1314/splitcart/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FBowen1314%2Fsplitcart%2FHEAD%2Fdocs%2Fdemo.gif" alt="demo"&gt;&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;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt; chat text ─┐                       ┌─&amp;gt; Gemma 3 27B: receipt -&amp;gt; JSON items      ┐
 receipt  ──┼─&amp;gt; redact + alias  ────┤   (text, or screenshot tiles)             ├─&amp;gt; validate -&amp;gt; checks -&amp;gt; review -&amp;gt; settle
 screenshot ┘   (local, in code)    └─&amp;gt; Gemma 3 27B: item -&amp;gt; who asked for it   ┘   (local)     (local)            (local, integer cents)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ol&gt;&lt;li&gt;…&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Bowen1314/splitcart" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The server uses only the Python standard library, the UI is plain JavaScript, and Pillow cuts up screenshots. MIT licensed.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The model: Gemma 3 27B, open weights.&lt;/strong&gt; splitcart calls &lt;code&gt;google/gemma-3-27b-it&lt;/code&gt; on Nebius Token Factory through the standard OpenAI-compatible chat API. I picked it because it reads images, it follows a JSON schema, and it's an open-weight model that can run anywhere, not only behind one company's API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma reads, code does the math.&lt;/strong&gt; The model has exactly two jobs, and both answers must match a JSON schema.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 1: transcribe the receipt.&lt;/em&gt; For each line, Gemma returns the name, quantity, final price, the estimate for weighed items, the "was" price, and flags for weighed, discounted, substituted, out of stock and taxable. This is its actual answer for the steak:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sirloin Tender Steak, Choice Angus Beef, 3-4 per Tray, Fresh, 0.6 - 1.62 lb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"11.79"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"estimated_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"13.45"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"was_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"weighed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"discounted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"substituted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"out_of_stock"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"taxable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Job 2: match each item to the person who asked for it.&lt;/em&gt; Gemma must quote the words in the chat it relied on, or answer &lt;code&gt;unknown&lt;/code&gt; instead of guessing. It only ever sees &lt;em&gt;Person A&lt;/em&gt; and &lt;em&gt;Person B&lt;/em&gt;. Its answer for the steak was &lt;code&gt;{"item": 14, "person": "Person B", "evidence": "a sirloin"}&lt;/code&gt;, which the app maps back to "Me" locally.&lt;/p&gt;

&lt;p&gt;Everything with a dollar sign happens in Python, in whole cents. Each item goes to its owner, and the tax goes to whoever owns the taxed items. The tip can be split evenly, by subtotal, or by custom amounts. Every split uses the largest-remainder method, so the shares always add up to the receipt total, to the cent:&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;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Split `total` cents in proportion to `weights`; the parts always sum to `total`.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;remainders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;leftover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))[:&lt;/span&gt;&lt;span class="n"&gt;leftover&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;shares&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model never adds anything up, not even the fees: it lists each fee line, and the code sums them. A model that's right about arithmetic 99% of the time is still wrong about money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing from the model is trusted until it's validated.&lt;/strong&gt; Prices must be clean two-decimal amounts, so "1.785" is rejected, not rounded. Every field must be present, and every owner must be one of the people in the chat. If the JSON fails, Gemma gets one retry listing the exact problems (&lt;code&gt;items[4].price: more than two decimals&lt;/code&gt;). Then the code checks the receipt against itself: do the items add up to the subtotal, and does subtotal + tax + tip + fees equal the total? If you enter the tax rate, it checks the tax too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working around how Gemma 3 sees images.&lt;/strong&gt; Gemma 3's vision encoder takes a fixed 896×896 image. A phone screenshot of a 21-item order is about 1170×7600 pixels, and squashed into one square it's unreadable. So splitcart cuts the screenshot into square tiles. It cuts only through blank rows, so no line of text is split, and pads each tile so nothing is stretched. The September screenshot becomes 7 tiles, and reading them costs about 2,400 input tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy before the model call.&lt;/strong&gt; A receipt carries your home address, the last four digits of your card and an order number. None of them are needed to split a bill. Before anything is sent, &lt;code&gt;redact.py&lt;/code&gt; removes them, plus phone numbers, emails and tracking parameters on links. Chat names are replaced with &lt;em&gt;Person A&lt;/em&gt;, &lt;em&gt;Person B&lt;/em&gt;. Screenshots can't be scanned without sending them somewhere, so you cover them yourself, and the boxes are flattened into the pixels before upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How well it works.&lt;/strong&gt; I rebuilt our September order as a synthetic test case: the same 21 items and prices, with a fake address, card and order number. I also wrote a small made-up stress case with three people, a shared item, a substitution, an out-of-stock item, a quantity of 2, two fees, and one item nobody asked for. Then I scored every field Gemma returned against the right answers (final prompt, temperature 0):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What Gemma read&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Items, prices, flags, owners&lt;/th&gt;
&lt;th&gt;Receipt totals&lt;/th&gt;
&lt;th&gt;Settlement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Our order, pasted as text&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;21/21, every field right&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;right: my friend owes me $94.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot with my details covered (7 tiles)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;21/21, every field right&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blurred, tilted, low-quality copy of that screenshot (6 tiles)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;21/21, every field right&lt;/td&gt;
&lt;td&gt;5/6 (delivery fee)&lt;/td&gt;
&lt;td&gt;$5.00 off until the flagged fee is fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Made-up stress case, 3 people, pasted text&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10/10, every field right&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The blurred copy is the interesting failure. Both runs read all 21 items, prices and owners correctly, but read the delivery fee, printed as "Free", as $5.00 (the $5.00 tip is on the next line). The arithmetic check catches it: the review screen shows the total check in red, "$135.94; receipt total $130.94. Without the $5.00 fees it adds up", and fixing it is one edit. This is exactly why the code checks the model. The stress case caught a different problem in an earlier version of the prompt: Gemma marked "Ground Beef, 1 lb" as sold by weight. One added sentence (a size in the product name is a package size, not weighing) fixed it.&lt;/p&gt;

&lt;p&gt;A settlement from pasted text is two model calls, about 2,600 input and 3,000 output tokens. At Nebius's published prices for Gemma 3 27B ($0.10 and $0.30 per million tokens), that's &lt;strong&gt;about $0.0011 per settlement&lt;/strong&gt;; from a screenshot it's about $0.0012. Everything I ran while building and testing this project, every eval run and the demo recordings, cost $0.0275 in model calls, under three cents.&lt;/p&gt;

&lt;p&gt;There are 67 tests. They cover the arithmetic (2,000 random splits and 300 random orders, all of which must sum exactly), the September numbers, JSON validation, redaction, and the full pipeline with a scripted model, which checks that no personal detail ends up in the request.&lt;/p&gt;

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

&lt;p&gt;For a two-person grocery app, "open" comes down to four concrete things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No lock-in.&lt;/strong&gt; splitcart speaks the plain OpenAI-compatible API and points at Gemma 3 27B, whose weights are published. If Nebius raised its prices or disappeared, I'd change one setting, &lt;code&gt;SPLITCART_BASE_URL&lt;/code&gt;, and point it at another provider or at a machine running the same weights. Today it calls a hosted copy instead of running the model on my laptop. That's a choice I can revisit, not a contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs almost nothing.&lt;/strong&gt; About a tenth of a cent per settlement. If my friend and I settled an order every week for a year, that would be about six cents. Splitting groceries shouldn't need a subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can look inside it and change it.&lt;/strong&gt; If Walmart changes its order page, or we start shopping somewhere else, I can fine-tune the same open model on our own receipts and keep the rest of the app as it is. With a closed model, I'd be waiting on someone else's roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest privacy.&lt;/strong&gt; A hosted model means data leaves my laptop, so the app sends as little as possible: personal details are stripped in code, names are swapped for labels, and you see exactly what's sent before you press the button. Open weights also keep the fully local option real. On a machine with enough memory, the same app could run with nothing leaving it at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this with an AI coding assistant (Claude Code). I described the problem and the rules: the model reads, the code does the math, nothing personal gets sent. I gave it our real receipt numbers, and it wrote the code, the tests and the evaluation script. Two of my calls shaped the build. Its first plan was to run a model on my laptop; I told it not to, so splitcart calls hosted Gemma, and that is why the redaction step exists. And at one point it had two different versions of our split in its notes, one with the tip divided by spend; I set it straight (we split the tip evenly), which is why "Split tip" is an explicit setting on the review screen instead of a hidden assumption.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: this project was built with an AI coding assistant, which wrote the code and drafted this post. The problem, the receipt, the numbers and the decisions are mine.&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 3 27B (open weights) reads the receipt, as text or as tiled screenshots, and matches items to people through schema-constrained JSON. Code does all the money math.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
