<?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>I held a seance for ten dead PyPI packages</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:42:11 +0000</pubDate>
      <link>https://dev.to/13owen/i-held-a-seance-for-ten-dead-pypi-packages-417b</link>
      <guid>https://dev.to/13owen/i-held-a-seance-for-ten-dead-pypi-packages-417b</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;zipfile36&lt;/code&gt; hasn't had a release since October 2016. Last month it was downloaded &lt;strong&gt;1,178,018 times&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It isn't alone. I took the 15,000 most-downloaded projects on PyPI and asked PyPI when each of them&lt;br&gt;
last uploaded a file. &lt;strong&gt;427&lt;/strong&gt; haven't uploaded anything since before 2018, and people still&lt;br&gt;
install them every day. Nobody is maintaining them, and Python keeps moving: &lt;code&gt;distutils&lt;/code&gt; is gone, &lt;code&gt;collections.MutableMapping&lt;/code&gt; is gone, &lt;code&gt;imp&lt;/code&gt; is gone,&lt;br&gt;
&lt;code&gt;nntplib&lt;/code&gt; is gone.&lt;/p&gt;

&lt;p&gt;So I went looking for the ones that died of it. A package counts as a &lt;strong&gt;ghost&lt;/strong&gt; if its own test&lt;br&gt;
suite, as shipped in its last sdist, passes on Python 3.8 and fails on Python 3.13. Of the 427, 45&lt;br&gt;
still passed their own tests on 3.8 (at least three tests each). On 3.13, 35 of those still pass.&lt;br&gt;
&lt;strong&gt;Ten don't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pip-seance&lt;/strong&gt; holds a seance for each ghost. A medium, which is a &lt;a href="https://mastra.ai" rel="noopener noreferrer"&gt;Mastra&lt;/a&gt;&lt;br&gt;
agent running an open-weight model, gets a copy of the package and six small tools. It may read&lt;br&gt;
anything and edit the library code. It may not touch the tests, which act as the judge. The&lt;br&gt;
whole seance runs as a &lt;a href="https://temporal.io" rel="noopener noreferrer"&gt;Temporal&lt;/a&gt; workflow, so if the machine dies mid-seance,&lt;br&gt;
it continues somewhere else. At the end, the original tests run on Python 3.13 &lt;em&gt;and&lt;/em&gt; on 3.8&lt;br&gt;
(raising the dead shouldn't kill the living), and the package gets an epitaph.&lt;/p&gt;

&lt;p&gt;Six of the ten rose. DeepSeek V4 Flash raised five in its first pass, for 27 cents across all ten&lt;br&gt;
attempts, and&lt;br&gt;
a sixth after I fixed a mistake in my own tooling. But the more interesting results are the ones&lt;br&gt;
in between: a revival that wasn't one, a medium that found the cure and couldn't apply it, and a&lt;br&gt;
kill switch that didn't kill.&lt;/p&gt;

&lt;p&gt;It's Día de los Muertos this weekend, the time for remembering the dead and the work they left.&lt;br&gt;
This felt like the right way to mark it as a programmer: these packages were written by people a&lt;br&gt;
decade ago, and a million installs a month still lean on that work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The graveyard: &lt;a href="https://bowen1314.github.io/pip-seance/" rel="noopener noreferrer"&gt;bowen1314.github.io/pip-seance&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%2F8h84ggs5fbv3fe5ezynr.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%2F8h84ggs5fbv3fe5ezynr.png" alt="The graveyard page: a headstone per ghost with last upload date, monthly downloads, cause of death, and one badge per seance. Gold headstones were raised." width="800" height="1117"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One headstone per ghost, generated from the results. Each badge is one seance and links to its&lt;br&gt;
epitaph: before and after test counts, whether Python 3.8 still passes, the diff, the cost, and&lt;br&gt;
the medium's last words. Here is&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/blob/main/results/runs/bench-deepseek-v4-flash-kafka/EPITAPH.md" rel="noopener noreferrer"&gt;kafka's&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%2Fldph7zxo1j9ks7ht4sbs.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%2Fldph7zxo1j9ks7ht4sbs.png" alt="Bar chart: target tests passing on Python 3.13 for each of the ten packages, before and after each model's seance." width="800" height="416"&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/pip-seance" rel="noopener noreferrer"&gt;
        pip-seance
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Raising abandoned PyPI packages from the dead with open-weight models: Mastra agent + Temporal workflow, judged by their own tests
    &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;pip-seance&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Raising abandoned PyPI packages from the dead with open-weight models.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Hundreds of packages on PyPI have not had a release in eight years and are still downloaded
hundreds of thousands of times a month. Some of them quietly died when Python moved on: their own
test suite passed on Python 3.8 and fails on Python 3.13. pip-seance finds those ghosts, then holds
a seance for each one: a &lt;a href="https://mastra.ai" rel="nofollow noopener noreferrer"&gt;Mastra&lt;/a&gt; agent running an open-weight model edits the
library code (never the tests) inside a &lt;a href="https://temporal.io" rel="nofollow noopener noreferrer"&gt;Temporal&lt;/a&gt; workflow, and the package's
untouched original tests decide whether it rose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The graveyard (live page): &lt;a href="https://bowen1314.github.io/pip-seance/" rel="nofollow noopener noreferrer"&gt;https://bowen1314.github.io/pip-seance/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Bowen1314/pip-seance/docs/assets/graveyard.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FBowen1314%2Fpip-seance%2FHEAD%2Fdocs%2Fassets%2Fgraveyard.png" alt="The graveyard page: one headstone per ghost, one badge per seance"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Results&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Run on 27 October 2026. Full table with every run: &lt;a href="https://github.com/Bowen1314/pip-seance/results/SUMMARY.md" rel="noopener noreferrer"&gt;&lt;code&gt;results/SUMMARY.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The census.&lt;/strong&gt; Of the 15,000 most-downloaded PyPI projects
(&lt;a href="https://hugovk.dev/top-pypi-packages/" rel="nofollow noopener noreferrer"&gt;hugovk/top-pypi-packages&lt;/a&gt;, 30-day window), &lt;strong&gt;427&lt;/strong&gt; have not
uploaded a file since before 2018. 245 of those don't ship their tests in the sdist, 109 couldn't
pass at…&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/pip-seance" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Everything behind this post is in the repo: the census, every seance's transcript, diff and&lt;br&gt;
epitaph, my review of each revival, the Temporal history of the chaos test, and a ledger with&lt;br&gt;
every model call priced. &lt;code&gt;seance/replay.py&lt;/code&gt; re-applies every recorded diff to a fresh copy of the&lt;br&gt;
package and re-runs the judge, so you can check every number here without calling a model.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Finding the dead.&lt;/strong&gt; The 15,000 most-downloaded projects come from&lt;br&gt;
&lt;a href="https://github.com/hugovk/top-pypi-packages" rel="noopener noreferrer"&gt;hugovk/top-pypi-packages&lt;/a&gt;, which is built from&lt;br&gt;
PyPI's public download data. For each project, PyPI's JSON API gives the newest upload time across&lt;br&gt;
all releases. Anything before 2018 is "undead": 427 packages. For each of those,&lt;br&gt;
&lt;code&gt;seance/probe.py&lt;/code&gt; downloads the latest sdist, checks its sha256, and runs the test files it&lt;br&gt;
contains, unchanged, on CPython 3.8 and 3.13, with the same pytest 8.3.5 and nothing else besides&lt;br&gt;
mock and six.&lt;/p&gt;

&lt;p&gt;Most of the 427 dropped out for dull reasons. In 245 I found no test files in the sdist, 109&lt;br&gt;
couldn't pass three of their own tests on 3.8 (77 of those stopped at an import error, usually a&lt;br&gt;
test dependency like nose or Django that I hadn't installed), 16 have C code, and 12 have no sdist&lt;br&gt;
at all. That left 45 that&lt;br&gt;
were provably alive on 3.8, and 10 of them that are dead on 3.13. The tests that passed on 3.8 are&lt;br&gt;
the &lt;strong&gt;target tests&lt;/strong&gt;. A seance succeeds when every target test passes on 3.13.&lt;/p&gt;

&lt;p&gt;One judgment call: Python 3.12 also deleted old unittest spellings like &lt;code&gt;assertEquals&lt;/code&gt;. That breaks&lt;br&gt;
the &lt;em&gt;test file&lt;/em&gt;, not the library. Since nobody may edit tests, a small pytest plugin puts those&lt;br&gt;
aliases back in every run, before and after, old Python and new. It can't make a broken library&lt;br&gt;
pass, it just stops a test's spelling from counting as a death.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The medium: Mastra over open weights.&lt;/strong&gt; Mastra already knows Nebius Token Factory as a&lt;br&gt;
provider, so the model is a string and the key comes from &lt;code&gt;NEBIUS_API_KEY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// ~20 lines: fix the cause, never the tests, keep 3.8 working&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`nebius/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// e.g. nebius/deepseek-ai/DeepSeek-V4-Flash-0731&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                 &lt;span class="c1"&gt;// list_files, read_file, search, edit_file, write_file, run_tests&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The six tools are built with &lt;code&gt;createTool&lt;/code&gt; and jailed to the package directory. &lt;code&gt;edit_file&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;write_file&lt;/code&gt; refuse anything under &lt;code&gt;test/&lt;/code&gt; or &lt;code&gt;tests/&lt;/code&gt;, any test file, &lt;code&gt;conftest.py&lt;/code&gt;, and the&lt;br&gt;
pytest/tox/setup config files. The verify step also hashes all of those against the pristine&lt;br&gt;
copy afterwards. &lt;code&gt;run_tests&lt;/code&gt; runs the original suite on 3.13 and tells the agent how many target&lt;br&gt;
tests pass, which ones still fail, and the end of pytest's output. There's no shell and no network&lt;br&gt;
tool, and the test subprocess gets an environment with the inference key and other API variables&lt;br&gt;
stripped out. It isn't a sandbox, though: code the medium writes does run when the tests run,&lt;br&gt;
which is one reason all of this ran on a batch node rather than my laptop.&lt;/p&gt;

&lt;p&gt;Every model step lands in &lt;code&gt;onStepFinish&lt;/code&gt;, where its token usage is priced from Nebius's live&lt;br&gt;
&lt;code&gt;/v1/models&lt;/code&gt; prices and appended to a ledger file before the next step starts. A run is capped at&lt;br&gt;
4 cents and the whole project at $1.00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ritual: Temporal.&lt;/strong&gt; Each seance is a workflow with four activities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;seanceWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;quick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seance&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;quick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pristine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pristine&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;quick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;countAttempts&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;quick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;epitaph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;seance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent loop is one activity with a 90-second heartbeat timeout and three attempts. Workflow&lt;br&gt;
ids are deterministic (&lt;code&gt;bench-&amp;lt;model&amp;gt;-&amp;lt;package&amp;gt;&lt;/code&gt;), so if the client crashes, re-running it attaches&lt;br&gt;
to the seances already in flight instead of paying for them twice. The dev server keeps its state&lt;br&gt;
in a SQLite file, and everything ran as Slurm jobs on a university cluster, because a seance&lt;br&gt;
executes a decade-old stranger's code and I wanted that off my laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I killed it.&lt;/strong&gt; &lt;code&gt;scripts/chaos_job.sh&lt;/code&gt; starts a worker and a seance for &lt;code&gt;money&lt;/code&gt;, waits until&lt;br&gt;
the medium has made four model calls, and sends the worker &lt;code&gt;SIGKILL&lt;/code&gt;. No cleanup, no warning. Ten&lt;br&gt;
seconds later it starts a second worker. Here's what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15:17:04  attempt 1 started (worker A)
15:17:11  agent finished step 4; SIGKILL worker A
15:17:28  worker B up
15:18:40  attempt 2 started (worker B)      &amp;lt;- Temporal: no heartbeat for 90 s, retry
15:18:55  money: REVIVED 20/20 on 3.13, 20/20 on 3.8, attempts 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ledger has both attempts' calls, 0.84 cents in total, because each step was written down&lt;br&gt;
before the kill. (The epitaph's own cost row counts only attempt 2; the ledger is the full bill.) Attempt 2 was told that an earlier attempt had been interrupted and to start with&lt;br&gt;
&lt;code&gt;run_tests&lt;/code&gt;. The full event history is&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/tree/main/results/runs/chaos-deepseek-v4-flash-money" rel="noopener noreferrer"&gt;in the repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Seances
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Medium (open weights, on Nebius)&lt;/th&gt;
&lt;th&gt;Ghosts tried&lt;/th&gt;
&lt;th&gt;Raised&lt;/th&gt;
&lt;th&gt;Sound on my review&lt;/th&gt;
&lt;th&gt;Left 3.8 broken&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4 Flash&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$0.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B-Instruct-2507&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$0.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.3-Flash&lt;/td&gt;
&lt;td&gt;4 (the hardest)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4 Flash, round 2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DeepSeek raised zipfile36, kafka, python3-ldap, null and money. Qwen raised null. Every raised&lt;br&gt;
package still passes its tests on 3.8. Here's what the epitaphs say, beyond the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Green isn't the same as alive.&lt;/strong&gt; I read every diff that turned the tests green. Three I would&lt;br&gt;
merge as they are. kafka's vendored copy of &lt;code&gt;six&lt;/code&gt; registers an import hook that only implements&lt;br&gt;
&lt;code&gt;find_module&lt;/code&gt;, which Python 3.12 stopped calling. DeepSeek added &lt;code&gt;find_spec&lt;/code&gt;, &lt;code&gt;create_module&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;exec_module&lt;/code&gt;, which is the same shape as the fix &lt;code&gt;six&lt;/code&gt; itself shipped in 1.16.0. python3-ldap and&lt;br&gt;
null just needed &lt;code&gt;collections.abc&lt;/code&gt;. The other three work, with a caveat. money's fallback for&lt;br&gt;
&lt;code&gt;distutils.version&lt;/code&gt; leans on &lt;code&gt;packaging&lt;/code&gt;, which happens to be importable only because pytest&lt;br&gt;
depends on it. And logutils' fix re-creates two private functions on the standard library's&lt;br&gt;
&lt;code&gt;logging&lt;/code&gt; module rather than in its own code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;zipfile36 was never dead.&lt;/strong&gt; Its sdist carries its own copy of CPython's &lt;code&gt;test.support&lt;/code&gt;, which&lt;br&gt;
does &lt;code&gt;import nntplib&lt;/code&gt;, and Python 3.13 removed &lt;code&gt;nntplib&lt;/code&gt;. The library was fine; its test kit had&lt;br&gt;
died. The medium first checked that the fence was real ("Let me check whether the test/support file&lt;br&gt;
is truly read-only by attempting a small edit"), then wrote a 27-line &lt;code&gt;nntplib.py&lt;/code&gt; stub so the old&lt;br&gt;
test helper could import it. 168 out of 168, for one cent. That counts as raised by the rules, and&lt;br&gt;
it's also the most honest result in the table: the most downloaded ghost wasn't a ghost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cure and the clumsy hands.&lt;/strong&gt; cookies died because one of its regexes has &lt;code&gt;(?ix)&lt;/code&gt;, a global&lt;br&gt;
flag group, in the middle of a pattern, which has been an error since Python 3.11. DeepSeek&lt;br&gt;
diagnosed it correctly and turned it into a scoped group, &lt;code&gt;(?ix:&lt;/code&gt;. Then it had to close the&lt;br&gt;
parenthesis at the other end of a multi-line pattern, and all 8 of its next edits were rejected&lt;br&gt;
because the text it quoted wasn't in the file. The indentation was off every time, and the last&lt;br&gt;
five were each exactly two spaces too deep: remove two spaces per line and every one of them&lt;br&gt;
matches. My guess is that it was counting the two-space gutter after my &lt;code&gt;read_file&lt;/code&gt; line numbers. The budget&lt;br&gt;
ran out with the parenthesis still open, and now the package didn't even import on 3.8. A failed&lt;br&gt;
seance can leave the body worse than it found it. That's why each one works on a copy and checks&lt;br&gt;
3.8 at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The medium is only as good as what it can see.&lt;/strong&gt; logutils died because Python 3.13 removed&lt;br&gt;
&lt;code&gt;logging._acquireLock&lt;/code&gt;. In the first bench no model found that, and none of them edited a single&lt;br&gt;
file. The reason was my tool: &lt;code&gt;run_tests&lt;/code&gt; led with &lt;code&gt;No module named 'redis'&lt;/code&gt;, from a test that had&lt;br&gt;
never been a target, and the real &lt;code&gt;AttributeError&lt;/code&gt; had been cut from the tail I passed back. After&lt;br&gt;
I changed &lt;code&gt;run_tests&lt;/code&gt; to list each failing target with its own error message, DeepSeek raised&lt;br&gt;
logutils in 10 steps for 1.6 cents. Because that's a different tool, I report it as round 2, not&lt;br&gt;
mixed into the bench.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The kill switch didn't kill.&lt;/strong&gt; The per-run cap originally called &lt;code&gt;abort()&lt;/code&gt; from inside&lt;br&gt;
&lt;code&gt;onStepFinish&lt;/code&gt;. The bench showed that this doesn't end Mastra's loop: one Qwen run kept going to&lt;br&gt;
$0.098 against a $0.04 cap. The cap is now a &lt;code&gt;stopWhen&lt;/code&gt; condition, and every tool refuses work once&lt;br&gt;
the run is over budget. I re-ran the same seance with the cap at $0.004 and it stopped at $0.0045,&lt;br&gt;
one step over. The project-wide $1.00 cap held throughout, because it's checked before each seance&lt;br&gt;
starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GLM never got to speak.&lt;/strong&gt; GLM-5.3-Flash is a reasoning model. In three of its four seances it&lt;br&gt;
spent the whole 4,096-token output budget thinking and ended with an empty reply. That row says&lt;br&gt;
more about my &lt;code&gt;maxOutputTokens&lt;/code&gt; than about GLM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small isn't free.&lt;/strong&gt; Qwen3-30B-A3B has only 3B active parameters, small enough to run on a good&lt;br&gt;
desktop GPU. It ran 40-step loops of &lt;code&gt;read_file&lt;/code&gt; and &lt;code&gt;search&lt;/code&gt;, and once it wrote its tool call as plain&lt;br&gt;
text instead of calling the tool. It cost more than DeepSeek and raised one package.&lt;/p&gt;

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

&lt;p&gt;This project only works because of open source, starting with the dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ghosts are open.&lt;/strong&gt; Every one of these packages can still be raised because its source&lt;br&gt;
and its tests are still sitting on PyPI, eight to thirteen years after the last release, and the&lt;br&gt;
license lets anyone fix it. A closed SDK whose vendor walked away in 2017 is just gone. The&lt;br&gt;
&lt;code&gt;nntplib&lt;/code&gt; stub, the &lt;code&gt;find_spec&lt;/code&gt; fix and the &lt;code&gt;collections.abc&lt;/code&gt; imports are all possible because&lt;br&gt;
someone published the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mediums are open, and they'll still exist.&lt;/strong&gt; I compared three open-weight models from three&lt;br&gt;
labs by changing one string, &lt;code&gt;nebius/&amp;lt;model id&amp;gt;&lt;/code&gt;. The weights for every model in my table are&lt;br&gt;
published, so this bench can be re-run against the same models in five years, or on my own&lt;br&gt;
hardware if the provider disappears. That's the point of an experiment about software that&lt;br&gt;
outlived its maintainers: I didn't want the medium to be the next thing that vanishes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap enough to try on everything.&lt;/strong&gt; DeepSeek V4 Flash attempted all ten ghosts for 27 cents,&lt;br&gt;
about 5 cents per package raised. The whole project, including smoke tests, the cap test, the&lt;br&gt;
chaos test and round 2, cost &lt;strong&gt;$0.83&lt;/strong&gt; across 523 model calls. At that price you can hold a seance&lt;br&gt;
for every dead dependency in your lockfile and only read the diffs that come back green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open frameworks let me see inside.&lt;/strong&gt; Mastra ships its documentation and provider registry&lt;br&gt;
inside the npm package. That's how I found that Nebius was already a provider and which model ids&lt;br&gt;
it knew. Temporal's dev server is a single open-source binary with no account, so the durability&lt;br&gt;
test ran inside a batch job on a shared cluster. When something misbehaved, like the cap that&lt;br&gt;
didn't stop the loop, I could read the types and fix my side instead of filing a ticket and&lt;br&gt;
waiting.&lt;/p&gt;

&lt;p&gt;What I'm not claiming: this isn't offline or private. The code went to a hosted inference&lt;br&gt;
endpoint, as it would with a closed API. I also didn't run a closed model, so I can't tell you&lt;br&gt;
open models are better at this. My claim is narrower: for this job, a model that costs about a&lt;br&gt;
cent per attempt raised more than half the dead, and every part of the pipeline, from the ghosts&lt;br&gt;
to the medium to the ritual, is something I could read, run and fix myself.&lt;/p&gt;

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

&lt;p&gt;Instead of DevRelay, every seance's full session is in the repo as &lt;code&gt;transcript.jsonl&lt;/code&gt;: each step's&lt;br&gt;
tool calls, tool results and tokens. Three worth reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/tree/main/results/runs/bench-deepseek-v4-flash-kafka" rel="noopener noreferrer"&gt;kafka, DeepSeek&lt;/a&gt;:
41 of 41 passing at step 8 for one cent. It then spent three more cents reading test files to
make sure a leftover &lt;code&gt;SyntaxError&lt;/code&gt; in a &lt;em&gt;non-target&lt;/em&gt; test wasn't its fault, until the cap stopped
it. Conscientious, and four times the price.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/tree/main/results/runs/bench-deepseek-v4-flash-zipfile36" rel="noopener noreferrer"&gt;zipfile36, DeepSeek&lt;/a&gt;:
tries the read-only fence once, accepts it, writes the stub.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/tree/main/results/runs/bench-deepseek-v4-flash-cookies" rel="noopener noreferrer"&gt;cookies, DeepSeek&lt;/a&gt;:
right diagnosis, eight rejected edits, one open parenthesis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent that built pip-seance itself was Claude Code, working autonomously from a task brief.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The moment it landed for me was kafka. 41 tests, zero passing on Python 3.13, the whole suite&lt;br&gt;
failing at import with &lt;code&gt;No module named 'kafka.vendor.six.moves'&lt;/code&gt;. Eight model calls later, all 41&lt;br&gt;
passed on 3.13 &lt;em&gt;and&lt;/em&gt; on 3.8, with the same shape of fix the &lt;code&gt;six&lt;/code&gt; maintainers shipped for their own&lt;br&gt;
library in 2021. The original authors moved on years ago, and the package is still installed&lt;br&gt;
239,184 times a month. A one-cent seance gave it back to the people still using it.&lt;/p&gt;

&lt;p&gt;None of these revivals are going upstream automatically, and they shouldn't. The epitaphs show&lt;br&gt;
why: a green run can be a stub, a leaky fallback, or a fix in the wrong place. pip-seance doesn't&lt;br&gt;
take over the job of maintaining these packages. It finds the dead, says what killed them, and&lt;br&gt;
hands you a candidate fix with the evidence attached. If you depend on one of these ten, the&lt;br&gt;
epitaph is a good place to start.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Mastra.&lt;/strong&gt; The medium is a Mastra &lt;code&gt;Agent&lt;/code&gt; with six &lt;code&gt;createTool&lt;/code&gt; tools, running&lt;br&gt;
open-weight models through Mastra's model router (&lt;code&gt;nebius/&amp;lt;model id&amp;gt;&lt;/code&gt;, with the key read from&lt;br&gt;
&lt;code&gt;NEBIUS_API_KEY&lt;/code&gt;). &lt;code&gt;onStepFinish&lt;/code&gt; drives the live cost ledger, and &lt;code&gt;stopWhen&lt;/code&gt; enforces the&lt;br&gt;
per-seance budget. The same agent ran DeepSeek V4 Flash, Qwen3-30B-A3B and GLM-5.3-Flash with no&lt;br&gt;
code changes. Notes from the build that might help the Mastra team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calling &lt;code&gt;abort()&lt;/code&gt; on the &lt;code&gt;abortSignal&lt;/code&gt; from inside &lt;code&gt;onStepFinish&lt;/code&gt; didn't end a &lt;code&gt;generate()&lt;/code&gt;
loop for me (one run went 2.4x over its cap). A &lt;code&gt;stopWhen&lt;/code&gt; predicate did.&lt;/li&gt;
&lt;li&gt;With a reasoning model and &lt;code&gt;maxOutputTokens: 4096&lt;/code&gt;, three runs ended on a step that used the
full 4,096 output tokens and returned no text, so the loop simply stopped. A warning when a
step's output is all reasoning would make that easier to spot.&lt;/li&gt;
&lt;li&gt;The bundled provider registry lists Nebius models but not every current one (no
&lt;code&gt;MiniMax-M3.1-Flash&lt;/code&gt;, for example), so I picked models that were listed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Temporal.&lt;/strong&gt; Each seance is a Temporal workflow: a heartbeating agent activity with&lt;br&gt;
retries, then verify and epitaph activities. Deterministic workflow ids keep a crashed client from&lt;br&gt;
paying twice. In the chaos test, the worker was SIGKILLed mid-seance, and Temporal handed the&lt;br&gt;
activity to a new worker after the heartbeat timeout. That worker finished the seance in the same&lt;br&gt;
workspace and raised &lt;code&gt;money&lt;/code&gt; 20/20. The event history and the exact cost of both attempts are in&lt;br&gt;
the repo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built in one day, 27 October 2026, inside the challenge window. Numbers are from&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/blob/main/results/SUMMARY.md" rel="noopener noreferrer"&gt;&lt;code&gt;results/SUMMARY.md&lt;/code&gt;&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/pip-seance/blob/main/ledger/spend.jsonl" rel="noopener noreferrer"&gt;&lt;code&gt;ledger/spend.jsonl&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
Package list: &lt;a href="https://github.com/hugovk/top-pypi-packages" rel="noopener noreferrer"&gt;hugovk/top-pypi-packages&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I hired a sloth for the night shift</title>
      <dc:creator>Bowen Rui</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:57:36 +0000</pubDate>
      <link>https://dev.to/13owen/i-hired-a-sloth-for-the-night-shift-4555</link>
      <guid>https://dev.to/13owen/i-hired-a-sloth-for-the-night-shift-4555</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Somewhere in your repo there is a comment like this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# TODO: blows up with a traceback on a fresh install (empty ledger).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It has been there for months. Fixing it would take ten minutes. You will not fix it today, because&lt;br&gt;
today there is always something more interesting. Same goes for the deprecation warning that&lt;br&gt;
prints on every run, the README that drifted three releases ago, and the function with no tests&lt;br&gt;
"because it's simple".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nightshift-sloth&lt;/strong&gt; is built to do one of those chores every night while I sleep. It picks the next open&lt;br&gt;
item from a &lt;code&gt;CHORES.md&lt;/code&gt; checklist and hands it to &lt;a href="https://github.com/Backboard-io/Backboard-R-CLI" rel="noopener noreferrer"&gt;Backboard R-CLI&lt;/a&gt;,&lt;br&gt;
the open-source terminal coding agent, running headless on a cheap open-weight model. The agent&lt;br&gt;
works on a throwaway branch with a strict time budget, then the harness runs the tests and&lt;br&gt;
writes a morning report. It never merges and never pushes. In the morning I read one page, and&lt;br&gt;
either keep the branch or delete it.&lt;/p&gt;

&lt;p&gt;To find out whether that holds up, I gave four open-weight models the same six chores on a small&lt;br&gt;
demo app, one run each. That is 24 nights' worth of work, which I ran back to back in one morning.&lt;br&gt;
All 24 together cost 39 cents. MiniMax M3.1 Flash got&lt;br&gt;
all six right. DeepSeek V4 Flash got five right for about a quarter of a cent each. And the most&lt;br&gt;
useful thing I learned was not about models at all: &lt;strong&gt;green tests are not the same as done.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Who gets to be lazier: anyone who maintains a small project and has a list of chores that are&lt;br&gt;
too boring to start and too small to delegate. The chore still gets a human review, but reviewing&lt;br&gt;
a 6-line diff with green tests takes a minute. Doing it takes a context switch I never make.&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%2F5igmzqxh36whnrxgf9ew.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%2F5igmzqxh36whnrxgf9ew.gif" alt="The sloth takes the first open chore, fixes it on its own branch and leaves a morning report" width="799" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One take, nothing mocked. Bedtime is one command. The sloth takes the first open chore, DeepSeek V4&lt;br&gt;
Flash fixes it in 9 seconds for $0.0016, the tests go from 12 to 14, and the work sits on its own&lt;br&gt;
branch while &lt;code&gt;main&lt;/code&gt; stays where it was. Here is a&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/docs/runs/20261024-112444-fix-empty-report-deepseek-v4-flash/MORNING_REPORT.md" rel="noopener noreferrer"&gt;full morning report&lt;/a&gt;&lt;br&gt;
from the bench.&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/nightshift-sloth" rel="noopener noreferrer"&gt;
        nightshift-sloth
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      One chore a night: Backboard R-CLI + open-weight models on a throwaway branch, with a morning report. Never merges, never pushes.
    &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;nightshift-sloth&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;One chore a night.&lt;/strong&gt; While you sleep, an open-weight model picks the next item off your
&lt;code&gt;CHORES.md&lt;/code&gt;, does it on a throwaway branch with a strict time budget, runs your tests, and
leaves a morning report. It never merges, never pushes, never touches &lt;code&gt;main&lt;/code&gt;. You read the
report over coffee and decide: keep it or bin it.&lt;/p&gt;
&lt;p&gt;Built on &lt;a href="https://github.com/Backboard-io/Backboard-R-CLI" rel="noopener noreferrer"&gt;Backboard R-CLI&lt;/a&gt; (MIT), the
open-source terminal coding agent, running headless against open-weight models on
&lt;a href="https://tokenfactory.nebius.com/" rel="nofollow noopener noreferrer"&gt;Nebius Token Factory&lt;/a&gt;. No Backboard account needed.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Bowen1314/nightshift-sloth/docs/assets/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%2Fnightshift-sloth%2FHEAD%2Fdocs%2Fassets%2Fdemo.gif" alt="nightshift-sloth doing one chore and leaving a morning report"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Results: four open-weight models, six chores&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each model took each of the six chores in the demo repo once: 24 nights of work, run back to back
on 24 October 2026. A chore counts as &lt;strong&gt;done&lt;/strong&gt; only if the repo's tests pass &lt;em&gt;and&lt;/em&gt; a hidden
acceptance check, which the agent never sees, passes on the sloth branch.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model (on Nebius Token Factory)&lt;/th&gt;
&lt;th&gt;Done&lt;/th&gt;
&lt;th&gt;Tests green&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Bowen1314/nightshift-sloth" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Everything the sloth did for this post is in the repo: each run's morning report, its diff, the raw&lt;br&gt;
R-CLI event stream, a readable transcript, and a spend ledger with every model call priced.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The agent: Backboard R-CLI, headless.&lt;/strong&gt; R-CLI is an MIT-licensed terminal coding agent. Its&lt;br&gt;
headless mode is what a night shift needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;backboard &lt;span class="nt"&gt;--cwd&lt;/span&gt; &amp;lt;clone&amp;gt; &lt;span class="nt"&gt;--model&lt;/span&gt; nebius/deepseek-ai/DeepSeek-V4-Flash-0731 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;the chore&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;--timer&lt;/span&gt; 600 &lt;span class="nt"&gt;--format&lt;/span&gt; json &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; bypass &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; off &lt;span class="nt"&gt;--no-lsp&lt;/span&gt; &lt;span class="nt"&gt;--excluded-tools&lt;/span&gt; WebSearch,FetchUrl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--format json&lt;/code&gt; streams every event (tool calls, results, token usage) as JSON lines. &lt;code&gt;--timer&lt;/code&gt;&lt;br&gt;
tells the model how long it has and reminds it as the clock runs down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The models: open weights on Nebius Token Factory.&lt;/strong&gt; R-CLI can talk to any OpenAI-compatible&lt;br&gt;
endpoint you define in &lt;code&gt;~/.backboard/config.json&lt;/code&gt;. The key stays in an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"providers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nebius"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai-chat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.tokenfactory.nebius.com/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"variable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NEBIUS_API_KEY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"extraArgs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stream_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"include_usage"&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="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-ai/DeepSeek-V4-Flash-0731"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"contextLimit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131072&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="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;The &lt;code&gt;include_usage&lt;/code&gt; line matters. Without it a custom provider's stream carries no token counts,&lt;br&gt;
and you can't put a price on a run.&lt;/p&gt;

&lt;p&gt;I benchmarked four models from four labs: DeepSeek V4 Flash, MiniMax M3.1 Flash,&lt;br&gt;
Qwen3-235B-A22B-Instruct-2507 and OpenAI's open-weight gpt-oss-120b. GLM-5.3-Flash also passed my&lt;br&gt;
smoke test; I left it out of the full run to keep the bench small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The harness: a short leash.&lt;/strong&gt; R-CLI runs with &lt;code&gt;--permission-mode bypass&lt;/code&gt;, because nobody is awake&lt;br&gt;
to approve prompts. That is only acceptable because of what surrounds it. nightshift-sloth is about&lt;br&gt;
1,500 lines of standard-library Python that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;makes a private clone holding only the base commit's history, on a fresh
&lt;code&gt;sloth/&amp;lt;chore&amp;gt;/&amp;lt;stamp&amp;gt;-&amp;lt;model&amp;gt;&lt;/code&gt; branch, with no remote. The agent can't see my other branches or
move my refs. When the night is over, the harness fetches that one branch back;&lt;/li&gt;
&lt;li&gt;gives R-CLI a throwaway &lt;code&gt;HOME&lt;/code&gt; whose config knows exactly one provider, and an environment
scrubbed down to &lt;code&gt;PATH&lt;/code&gt;, locale and that one key. My shell has other API keys in it; the agent
never sees them;&lt;/li&gt;
&lt;li&gt;writes R-CLI deny rules for &lt;code&gt;git push&lt;/code&gt;, &lt;code&gt;git commit&lt;/code&gt;, branch switching, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, package
installs and environment dumps. I read R-CLI's permission engine to check that deny rules outrank
bypass mode, and the bench confirmed it: one agent tried &lt;code&gt;git branch --all&lt;/code&gt; and was refused;&lt;/li&gt;
&lt;li&gt;prices every &lt;code&gt;usage&lt;/code&gt; event as it streams in, appends it to a ledger, and kills the run when it
reaches its dollar cap (25 cents). The ledger also refuses to start a run that could push the
project past its total cap (mine was $3);&lt;/li&gt;
&lt;li&gt;kills the whole process group at timer + 2 minutes, because R-CLI's &lt;code&gt;--timer&lt;/code&gt; is advisory: the
model is told about it, but nothing gets cancelled;&lt;/li&gt;
&lt;li&gt;stops a run after six tool errors in a row (more on why below);&lt;/li&gt;
&lt;li&gt;runs the tests before and after, makes the one local commit itself, and writes the morning report.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not a sandbox. The agent can still run shell commands as my user. I treat it like a junior&lt;br&gt;
colleague with no push access, not like a VM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measuring honestly.&lt;/strong&gt; "Tests pass" means the agent graded its own homework. So for the bench every&lt;br&gt;
chore also has a hidden acceptance check. It lives outside the repo, runs after the agent has gone,&lt;br&gt;
and was validated against the untouched repo (must reject) and a reference solution (must accept).&lt;br&gt;
For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test-parse-amount&lt;/code&gt; asks for unit tests without changing the function. The check confirms
&lt;code&gt;money.py&lt;/code&gt; is byte-for-byte unchanged, then runs the new tests against four mutants of
&lt;code&gt;parse_amount&lt;/code&gt;. The tests have to catch all four.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;readme-usage-drift&lt;/code&gt; imports the real argparse parser and checks that every subcommand and every
option shows up in the README's Usage section, and that the other sections were left alone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utcnow-deprecation&lt;/code&gt; builds a ledger that mixes old naive timestamps with new timezone-aware ones,
exactly as the chore describes, and runs every command on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The first wall, and the way round it.&lt;/strong&gt; I started with the R-CLI binary from the official&lt;br&gt;
download link (3.0.5 for macOS arm64, fetched on 24 October 2026). It refused my Nebius provider&lt;br&gt;
with "No credentials found". That build's bring-your-own-key list is Anthropic, OpenAI, Google and&lt;br&gt;
OpenRouter, and it has no &lt;code&gt;--timer&lt;/code&gt; flag. Because R-CLI is open source, I could go and look: custom&lt;br&gt;
providers and the timer had both landed on &lt;code&gt;main&lt;/code&gt; two weeks earlier, in&lt;br&gt;
&lt;a href="https://github.com/Backboard-io/Backboard-R-CLI/commit/51bee17e02344de5fffd89523ce2b73d5042f216" rel="noopener noreferrer"&gt;&lt;code&gt;51bee17&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
("Add per-turn timer and todo budget feedback"). I built that commit with Bun and everything&lt;br&gt;
worked. One oddity if you try it: that &lt;code&gt;main&lt;/code&gt; build calls itself 3.0.4, one below the release.&lt;/p&gt;

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

&lt;p&gt;Here is what open source made possible this week that a closed API or a closed agent would not have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the release was missing a feature, I built &lt;code&gt;main&lt;/code&gt;.&lt;/strong&gt; With a closed agent, "the shipped&lt;br&gt;
build doesn't support my provider" ends the project. Here it cost a &lt;code&gt;git checkout&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;bun run build&lt;/code&gt;. Reading the source also told me things no doc did: that &lt;code&gt;--timer&lt;/code&gt; doesn't cancel&lt;br&gt;
anything, that deny rules beat bypass mode, and which event carries token usage. The harness's&lt;br&gt;
safety and its cost meter are built on those three facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swapping models is one line.&lt;/strong&gt; The same agent, prompt and checks ran against four models from four&lt;br&gt;
labs. Nothing in the harness is model-specific, and the provider is two lines of config. When a&lt;br&gt;
better or cheaper open-weight model ships next month, I change one line and re-run the bench.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The price makes "every night" sensible.&lt;/strong&gt; DeepSeek V4 Flash spent 1.3 cents on all six chores and&lt;br&gt;
finished five of them. At a quarter of a cent per finished chore, a chore every night for a year costs&lt;br&gt;
less than a dollar. MiniMax, the only model to get all six right, works out at about $4 a year. The&lt;br&gt;
number that matters on the price page turned out to be the cached-input price: an agent re-sends the&lt;br&gt;
same growing conversation on every call, so 70% to 98% of each model's input tokens were cache hits.&lt;br&gt;
DeepSeek bills those at $0.01 per million tokens. Qwen gets no cache discount on this provider,&lt;br&gt;
which is most of why it cost seven times as much as DeepSeek for the same six chores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No account, no lock-in.&lt;/strong&gt; I never created a Backboard account for this. The agent is MIT-licensed,&lt;br&gt;
the models are open weights behind an OpenAI-compatible endpoint, and the only key is for that&lt;br&gt;
endpoint. If my provider vanished tomorrow I would change &lt;code&gt;base_url&lt;/code&gt; and keep going.&lt;/p&gt;

&lt;p&gt;What I am not claiming: this is not offline or private. The code goes to a hosted inference&lt;br&gt;
endpoint, as it would with a closed API. I also didn't run a closed model, so I can't tell you open&lt;br&gt;
models are better at this. My claim is narrower: for a small chore, a model that costs a fraction of&lt;br&gt;
a cent did the job, and when the tooling got in my way, open source let me fix it myself instead of&lt;br&gt;
waiting.&lt;/p&gt;

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

&lt;p&gt;R-CLI keeps its own session log, and nightshift-sloth saves it for every run, so instead of DevRelay&lt;br&gt;
I am linking the night-shift sessions directly. Each folder in&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/nightshift-sloth/tree/main/docs/runs" rel="noopener noreferrer"&gt;&lt;code&gt;docs/runs&lt;/code&gt;&lt;/a&gt; has a readable&lt;br&gt;
&lt;code&gt;TRANSCRIPT.md&lt;/code&gt; next to the raw &lt;code&gt;events.jsonl&lt;/code&gt;. Four are worth reading:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Green but wrong.&lt;/strong&gt; &lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/docs/runs/20261024-112721-utcnow-deprecation-deepseek-v4-flash/TRANSCRIPT.md" rel="noopener noreferrer"&gt;DeepSeek V4 Flash on &lt;code&gt;utcnow-deprecation&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
four model calls, eight seconds, a two-line diff and a green suite. The chore said that old ledgers&lt;br&gt;
hold naive timestamps and that &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt; and &lt;code&gt;export&lt;/code&gt; must keep working on a ledger mixing&lt;br&gt;
old and new entries. DeepSeek reasoned about it instead of trying it, and its summary says&lt;br&gt;
"downstream code (&lt;code&gt;list&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;) only reads &lt;code&gt;e.when&lt;/code&gt;/sorts, so mixed ledgers keep&lt;br&gt;
working." The hidden check tried it: all three commands crashed comparing naive and aware datetimes.&lt;br&gt;
Qwen made the same two-line change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow and right.&lt;/strong&gt; &lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/docs/runs/20261024-114101-utcnow-deprecation-minimax-m3.1-flash/TRANSCRIPT.md" rel="noopener noreferrer"&gt;MiniMax M3.1 Flash on the same chore&lt;/a&gt;:&lt;br&gt;
twelve calls and almost two minutes. It normalised old timestamps to UTC on load, wrote tests for a&lt;br&gt;
mixed ledger, and ran the suite a second time with &lt;code&gt;-W error::DeprecationWarning&lt;/code&gt;, which nobody asked&lt;br&gt;
it to do. It was the slowest model in the bench and the only one to finish all six chores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The loop.&lt;/strong&gt; &lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/docs/runs/20261024-112602-fix-empty-report-gpt-oss-120b/TRANSCRIPT.md" rel="noopener noreferrer"&gt;gpt-oss-120b on &lt;code&gt;fix-empty-report&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
the fix was in place after 26 seconds, and it passes the hidden check. Then the model kept going:&lt;br&gt;
92 model calls, and 79 of its 93 tool calls failed, with missing arguments, an empty tool name, and&lt;br&gt;
harmony &lt;code&gt;&amp;lt;|channel|&amp;gt;&lt;/code&gt; tokens leaking into tool names. It stopped when the provider returned an&lt;br&gt;
error, at $0.14, ten times what DeepSeek spent on all six chores. That run is why the harness now stops&lt;br&gt;
after six tool errors in a row. gpt-oss produced malformed tool calls in every run. I didn't work out&lt;br&gt;
whether the model, the provider's translation layer or the agent's OpenAI adapter is at fault, so I&lt;br&gt;
read its column as "this combination doesn't work yet", not as a verdict on gpt-oss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The peek.&lt;/strong&gt; &lt;a href="https://github.com/Bowen1314/nightshift-sloth/tree/main/docs/runs-excluded" rel="noopener noreferrer"&gt;An excluded run&lt;/a&gt;.&lt;br&gt;
The first version of the harness used &lt;code&gt;git worktree&lt;/code&gt;, which shares refs with your repo. Early in&lt;br&gt;
the bench, MiniMax ran &lt;code&gt;git log --oneline --all&lt;/code&gt;, found DeepSeek's attempt at the same chore&lt;br&gt;
from a few minutes earlier, and read it with &lt;code&gt;git show&lt;/code&gt;. A night-shift worker reading the day&lt;br&gt;
shift's notes is fine; a bench contestant reading another's answer is not. So now every night works&lt;br&gt;
in a private clone that holds only the base commit's history. I kept the original run as evidence,&lt;br&gt;
re-ran it in a clean clone (MiniMax still got it right, in half the time), and counted only the&lt;br&gt;
re-run.&lt;/p&gt;

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

&lt;p&gt;Six chores on the demo app, and here is how the night shift did:&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;Done&lt;/th&gt;
&lt;th&gt;Tests green&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Cost, all 6&lt;/th&gt;
&lt;th&gt;Per done chore&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MiniMax M3.1 Flash&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;50s&lt;/td&gt;
&lt;td&gt;$0.068&lt;/td&gt;
&lt;td&gt;$0.011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4 Flash&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;13s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.013&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.0026&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-235B-A22B-Instruct-2507&lt;/td&gt;
&lt;td&gt;3/6&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;16s&lt;/td&gt;
&lt;td&gt;$0.093&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;3/6&lt;/td&gt;
&lt;td&gt;3/6&lt;/td&gt;
&lt;td&gt;11s&lt;/td&gt;
&lt;td&gt;$0.216&lt;/td&gt;
&lt;td&gt;$0.072&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Done" means the repo's tests pass &lt;em&gt;and&lt;/em&gt; the hidden check passes. Every chore was done by at least&lt;br&gt;
one model, so every chore now has a branch I would merge. Four branches had green tests and were&lt;br&gt;
still wrong: you can see them in the gap between the "Done" and "Tests green" columns. The&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/docs/runs/SUMMARY.md" rel="noopener noreferrer"&gt;chore-by-chore table&lt;/a&gt;&lt;br&gt;
links every morning report.&lt;/p&gt;

&lt;p&gt;The whole project, including smoke tests, the bench, one aborted run, the excluded run and the demo&lt;br&gt;
recording, cost &lt;strong&gt;$0.45&lt;/strong&gt; according to the&lt;br&gt;
&lt;a href="https://github.com/Bowen1314/nightshift-sloth/blob/main/ledger/spend.jsonl" rel="noopener noreferrer"&gt;ledger&lt;/a&gt;. That's 15% of&lt;br&gt;
the $3 cap I set before starting.&lt;/p&gt;

&lt;p&gt;Two honest caveats. This is one run per model per chore, so treat it as anecdotes with receipts, not&lt;br&gt;
a benchmark. And the demo app and its chores were written for this project; the hidden checks were&lt;br&gt;
validated before any model saw the chores, but this is not a year of nights on a real codebase.&lt;/p&gt;

&lt;p&gt;What I took away is the reason the sloth never merges. If I had run only DeepSeek, five chores&lt;br&gt;
would have been done for a little over a cent, and the sixth would have arrived marked READY FOR&lt;br&gt;
REVIEW with green tests and a bug in it. The cheap model is good enough to do the work. It is not&lt;br&gt;
good enough to skip reading the diff. That suits me: reading one short diff over coffee is the kind&lt;br&gt;
of slow and steady I can keep up.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Backboard.&lt;/strong&gt; nightshift-sloth is built on Backboard R-CLI as a component, not a chat&lt;br&gt;
window: headless &lt;code&gt;--print&lt;/code&gt;, the &lt;code&gt;--format json&lt;/code&gt; event stream for live cost metering, &lt;code&gt;--timer&lt;/code&gt;,&lt;br&gt;
custom OpenAI-compatible providers and project deny rules. The bench compares four open-weight&lt;br&gt;
models through the same R-CLI build. I did that through R-CLI's custom-provider config with my own&lt;br&gt;
inference key, not a Backboard API key; I didn't create a Backboard account.&lt;/p&gt;

&lt;p&gt;Things I ran into that might be useful to the R-CLI team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The binary from the download link (3.0.5) is behind &lt;code&gt;main&lt;/code&gt;: no custom providers, no &lt;code&gt;--timer&lt;/code&gt;.
And &lt;code&gt;main&lt;/code&gt; reports itself as 3.0.4.&lt;/li&gt;
&lt;li&gt;Custom providers report no token usage unless &lt;code&gt;stream_options.include_usage&lt;/code&gt; is set in
&lt;code&gt;extraArgs&lt;/code&gt;. Turning that on by default for &lt;code&gt;openai-chat&lt;/code&gt; providers would make cost tracking work
out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--timer&lt;/code&gt; is advisory. A hard wall-clock limit or spend limit for &lt;code&gt;--print&lt;/code&gt; runs would make
unattended use safer; I had to build both outside the agent.&lt;/li&gt;
&lt;li&gt;With gpt-oss-120b, malformed tool calls repeated until the provider gave up. An option to stop
after N consecutive tool errors would have saved most of that run's cost.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;AI use: I built nightshift-sloth with an AI coding assistant (Claude Code), which wrote most of the&lt;br&gt;
harness, the demo app, the hidden checks and a first draft of this post, working to my brief. The&lt;br&gt;
night-shift work in the bench was done by R-CLI with the open-weight models named above, and every&lt;br&gt;
one of those sessions is in the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <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>
