<?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: Edy Cu</title>
    <description>The latest articles on DEV Community by Edy Cu (@edycutjong).</description>
    <link>https://dev.to/edycutjong</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%2F3911089%2Fb24966e4-839d-4c19-8e2d-16c6a5c8838c.jpeg</url>
      <title>DEV Community: Edy Cu</title>
      <link>https://dev.to/edycutjong</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edycutjong"/>
    <language>en</language>
    <item>
      <title>My codec benchmark failed my own gate. The cause was two quiet recordings.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 17 Oct 2026 06:30:27 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-codec-benchmark-failed-my-own-gate-the-cause-was-two-quiet-recordings-1dmn</link>
      <guid>https://dev.to/edycutjong/my-codec-benchmark-failed-my-own-gate-the-cause-was-two-quiet-recordings-1dmn</guid>
      <description>&lt;p&gt;A parent's Hindi voice note hides the ask at 0:15: "send ₹5,000 before Sunday". The note is megabytes of audio nobody can search, and when a phone fills up, voice notes are what gets deleted. I wanted to keep the voice small and the ask impossible to miss, so I built &lt;a href="https://hearth.edycu.dev/judge" rel="noopener noreferrer"&gt;Hearth&lt;/a&gt; for the Nebius × NVIDIA Global AI Hackathon.&lt;/p&gt;

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

&lt;p&gt;The idea rests on one number I had not measured yet: how much does a 1.78 kbps neural codec hurt Hindi speech? The codec's model card has no Hindi result. So before anything else I wrote a gate: transcribe the original, encode it with NVIDIA's &lt;code&gt;nemo-nano-codec-22khz-1.78kbps-12.5fps&lt;/code&gt;, decode it, transcribe it again, and require the round trip to cost no more than 5 word-error-rate points on 20 FLEURS &lt;code&gt;hi_in&lt;/code&gt; clips.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first run failed
&lt;/h2&gt;

&lt;p&gt;Run 1, on a Nebius Serverless AI Job (CPU, &lt;code&gt;4vcpu-16gb&lt;/code&gt;): &lt;strong&gt;8.74 % → 14.68 % WER, +5.94 points&lt;/strong&gt;. The gate said +5. I missed it by 0.94.&lt;/p&gt;

&lt;p&gt;Averages hide the cause, so I looked at the clips. Two of the twenty were very quiet (their loudest frames sat around −46 and −47 dBFS). After the round trip their word error went from 8.3 % to 62.5 % and from 38.9 % to 72.2 %: whole sentences gone. My guess for the first-word slips is that the causal decoder starts cold; lead frames alone did not help (+4.01 vs +2.61 points on the dev set). Thirteen of the twenty clips came back with no change in word error, five others added 6–17 points, and the two quiet ones added 33 and 54.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one line of audio engineering
&lt;/h2&gt;

&lt;p&gt;My guess is that the codec expects speech at a normal level (its card does not say). So I measure the speech-active level of the input, bring it to −20 dBFS before encoding, store the gain in the file header, and divide it out on decode:&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;codec_gain_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;551&lt;/span&gt;                                   &lt;span class="c1"&gt;# 25 ms at 22.05 kHz
&lt;/span&gt;    &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x22&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;x22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1e-12&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&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;# speech frames only
&lt;/span&gt;    &lt;span class="n"&gt;rms_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_GAIN_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MIN_GAIN_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TARGET_DBFS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;rms_db&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x22&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;1e-9&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PEAK&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# never clip
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not choose the setting on the benchmark clips. I ran an ablation on a disjoint dev set of 20 clips, with the rule fixed before the run (lowest round-trip WER delta): always-normalise plus three lead frames won with +1.60 points; the "only if quiet" variant was +2.20, and the run-1 pipeline (no gain, no lead frames) +2.61 on that set.&lt;/p&gt;

&lt;p&gt;Run 2 on the 20 benchmark clips: &lt;strong&gt;8.74 % → 12.83 %, +4.09 points&lt;/strong&gt;. The gate passed. On a second held-out set (&lt;code&gt;fleurs20b&lt;/code&gt;), never used for any decision: 12.42 % → 14.46 %.&lt;/p&gt;

&lt;p&gt;It is a better average, not a fix for every recording. One normal-level clip got &lt;em&gt;worse&lt;/em&gt; (13.0 % → 47.8 %), and I left that in the README. The first run's JSON is committed beside the passing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers buy
&lt;/h2&gt;

&lt;p&gt;Stored size: &lt;strong&gt;815,423 bytes an hour&lt;/strong&gt; of voice (13 codebooks × 12.5 frames a second, packed 11 bits each), 195× smaller than 22.05 kHz 16-bit PCM. ASR real-time factor 0.16 on 4 vCPU, peak memory 6.9 GB, $0.1345 per audio-hour of compute (the whole-pipeline real-time factor 1.02 × the &lt;code&gt;cpu-d3&lt;/code&gt; list price, with start-up and model load excluded, which is most of the wall time: a note takes about 6.6 minutes from upload to kept at the median, cold start included).&lt;/p&gt;

&lt;p&gt;The gate is not only a benchmark. In the product, the worker re-transcribes every note's reconstruction, and the app deletes the raw upload only if word drift is ≤ 5 % (and no 8-word-or-longer segment is over 30 %). Then it asks the bucket for the original and shows the &lt;code&gt;HEAD … 404&lt;/code&gt;. The 60-second Opus note I use for the live runs drifts 14.7 %, so its original is kept. That is the gate working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more walls, briefly
&lt;/h2&gt;

&lt;p&gt;NanoCodec's checkpoint config also builds training-only parts: discriminators (one downloads &lt;code&gt;microsoft/wavlm-base-plus&lt;/code&gt;) and a speaker encoder. Overriding the config drops them without changing a weight, and a fresh load goes from 45–50 s to 26 s with bit-identical codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discriminator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_scl_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_slm_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;codec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;codec_cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restore_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;override_config_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="n"&gt;map_location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Jobs API's 64 KiB limit applies to all injected files &lt;em&gt;together&lt;/em&gt; (base64), not per file as the docs say. Three files that were each far under the limit were refused at 65,804 bytes of base64 in total. I gzip the worker and &lt;code&gt;run.sh&lt;/code&gt; unpacks it. There is more of this in the repo's &lt;code&gt;FEEDBACK.md&lt;/code&gt;, each item with a Job or request ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model proposes, code decides
&lt;/h2&gt;

&lt;p&gt;Reminders come from Nemotron 3 Super on Nebius Token Factory with a strict &lt;code&gt;json_schema&lt;/code&gt;. Its card does not list Hindi, so nothing it says is shown unless code agrees: the quote must be a verbatim substring of the transcript, the amount is parsed from the quote's own number words, and the date is resolved from the quote and the note's own timestamp. In the live check, one proposal ("Thursday") whose quote never said Thursday had its date removed by code.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;quoteGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&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="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Segment&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;QuoteGate&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeHi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_QUOTE_TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote_too_short&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// try the claimed segment, then its neighbours, then every segment&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote_not_found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;CPU speed is ours to measure; neither NVIDIA card states CPU support. Time to kept is minutes. The round trip is lossy. Reminders are Hindi-only. The calendar feed is untested on a real phone, and the judge sample is FLEURS read speech, not a family's voice: no volunteer has handed me real notes yet.&lt;/p&gt;

&lt;p&gt;Try it without an account: &lt;a href="https://hearth.edycu.dev/judge" rel="noopener noreferrer"&gt;https://hearth.edycu.dev/judge&lt;/a&gt; · proof (Job IDs, the raw-audio 404): &lt;a href="https://hearth.edycu.dev/proof" rel="noopener noreferrer"&gt;https://hearth.edycu.dev/proof&lt;/a&gt; · code (MIT): &lt;a href="https://github.com/edycutjong/hearth" rel="noopener noreferrer"&gt;https://github.com/edycutjong/hearth&lt;/a&gt; · Devpost entry: &lt;a href="https://devpost.com/software/hearth-4n0pw5" rel="noopener noreferrer"&gt;https://devpost.com/software/hearth-4n0pw5&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>python</category>
      <category>nvidia</category>
    </item>
    <item>
      <title>A regex matched my LLM pipeline on clean text. Then synthetic speech broke both.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:47:32 +0000</pubDate>
      <link>https://dev.to/edycutjong/a-regex-matched-my-llm-pipeline-on-clean-text-then-synthetic-speech-broke-both-5bee</link>
      <guid>https://dev.to/edycutjong/a-regex-matched-my-llm-pipeline-on-clean-text-then-synthetic-speech-broke-both-5bee</guid>
      <description>&lt;p&gt;An operator on a plant radio says &lt;em&gt;"Close valve two-oh-three, line Bravo."&lt;/em&gt; The field technician answers &lt;em&gt;"Copy, closing two-oh-eight, line Bravo."&lt;/em&gt; Valve 208 is on a different line. The read-back exists so somebody catches that, and sometimes nobody on the channel does.&lt;/p&gt;

&lt;p&gt;I spent the AMD Developer Hackathon (ACT III, lablab.ai) building &lt;strong&gt;SayAgain&lt;/strong&gt;, a checker for exactly that moment. This post is not the launch tour. It is the part I didn't expect: on clean text a plain regex did as well as the language model, and when I fed the pipeline synthetic speech, both of them got worse in a way that a headline number hides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live replay:&lt;/strong&gt; &lt;a href="https://sayagain.edycu.dev/replay/" rel="noopener noreferrer"&gt;https://sayagain.edycu.dev/replay/&lt;/a&gt; · &lt;strong&gt;Repo (MIT):&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/sayagain" rel="noopener noreferrer"&gt;https://github.com/edycutjong/sayagain&lt;/a&gt; · &lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://youtu.be/6a4IF-iJmxg" rel="noopener noreferrer"&gt;https://youtu.be/6a4IF-iJmxg&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: the model points, code decides
&lt;/h2&gt;

&lt;p&gt;The obvious build is "ask an LLM whether the read-back matches the order." I didn't want a model making a safety verdict, so the model's only job is to &lt;strong&gt;point at words&lt;/strong&gt;: which span of the transcript is the action, which is the equipment tag, which is the line. It runs on the AMD Token Factory (an OpenAI-compatible endpoint, JSON mode), and every call writes a receipt.&lt;/p&gt;

&lt;p&gt;Then plain Python takes over. The spans must be words really present in the transcript (grounding). Spoken digits and NATO letters become plant tags through alias tables. The plant registry decides whether a tag exists and which line it is on. And a pure function returns the verdict:&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;verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;instr&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="n"&gt;CanonCommand&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;readback&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="n"&gt;CanonCommand&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;span_conf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;disagreement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;VERIFIED · MISMATCH · NO_READBACK · ABSTAIN for one paired exchange (canonical inputs only).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_instruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instr&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;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABSTAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instruction: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instruction: &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="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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; line &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;registry_line&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;instr&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; (registry ok)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;readback&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NO_READBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pairing: no read-back inside the window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ambiguous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;readback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABSTAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;R_AMBIGUOUS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read-back: bare number shared by several tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;disagreement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABSTAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;R_AUDIO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio: prompted and unprompted transcripts disagree&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;span_conf&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;span_conf&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABSTAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;R_AUDIO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio: span confidence &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;span_conf&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &amp;lt; floor &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;diffs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;readback&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;diffs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MISMATCH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compare: &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="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="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VERIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compare: every field equal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;referee/verdict.py&lt;/code&gt; — the module imports no model code.)&lt;/p&gt;

&lt;p&gt;Notice how many exits lead to &lt;code&gt;ABSTAIN&lt;/code&gt;. If the system can't read something, it says so and hands the card to a human. A failed model call is &lt;code&gt;NOT_AUDITED&lt;/code&gt;, never a guess. Every card a supervisor sees ends with Confirm or Dismiss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I froze before the first model call
&lt;/h2&gt;

&lt;p&gt;Before calling any model, I committed an evaluation protocol and tagged it (&lt;code&gt;prereg-v1&lt;/code&gt;). The ladder rule: walk up from the cheapest extractor (a regex, no model) through larger models, and pick the &lt;strong&gt;smallest rung that lets zero wrong read-backs through as VERIFIED&lt;/strong&gt; while keeping false alarms within a budget a shift can absorb.&lt;/p&gt;

&lt;p&gt;That rule was written for the audio row: real recordings, real radio. I didn't have recordings during the hackathon, so I applied it to the recording cards' written scripts and logged that as a deviation. Everything below says which condition it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: on clean words, the regex was enough
&lt;/h2&gt;

&lt;p&gt;On 89 held-out scripted exchanges (41 with a seeded error, 48 correct):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No rung passed a wrong read-back as VERIFIED.&lt;/strong&gt; 0 for the regex and 0 for all four models on the AMD Token Factory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plain regex met the rule outright:&lt;/strong&gt; 41/41 seeded errors flagged, 0/48 false alarms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma-4-31B-IT was the only model that met it:&lt;/strong&gt; 0 passed, 2/48 false alarms — after one re-audit of 118 transcripts whose first call failed at the gateway (108 HTTP 500s, 10 time-outs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An LLM-as-judge baseline&lt;/strong&gt; (given the whole exchange, no registry) passed &lt;strong&gt;2&lt;/strong&gt; wrong read-backs as VERIFIED, both missing the action word, and also cleared an order for a valve the plant doesn't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest reading: by my own pre-registered rule, on scripted words &lt;strong&gt;no language model is needed&lt;/strong&gt;. I put that in the README in those words. It's the kind of sentence a project write-up usually leaves out, and it is the most useful thing the benchmark told me: clean text is not where the model earns its place.&lt;/p&gt;

&lt;p&gt;One result did favour the design. When the same model returned &lt;em&gt;normalised values&lt;/em&gt; instead of copying words from the transcript, false alarms went from 2/48 to 6/48; in 4 of those it turned a spoken pump or motor-valve tag into a valve tag the registry doesn't have. Making the model copy words, and making code check them, matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: synthetic speech broke both
&lt;/h2&gt;

&lt;p&gt;To exercise the speech half, I ran an exploratory pass: ElevenLabs text-to-speech of the 145 card scripts (two voices), through a simulated radio path, transcribed by local Whisper-large-v3, then the same frozen referee. This was added after the text rows, it is not pre-registered, and it is &lt;strong&gt;not&lt;/strong&gt; human speech. Same 89 held-out cards.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Still 0 wrong read-backs passed as VERIFIED&lt;/strong&gt; in every row that ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Radio, no added noise:&lt;/strong&gt; Whisper's word error rate was 0.87%. The regex flagged 39/41 errors, but false alarms went from &lt;strong&gt;0/48 to 16/48&lt;/strong&gt;; Gemma went from 2/48 to &lt;strong&gt;17/48&lt;/strong&gt;. Neither meets the false-alarm budget anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors that vanish:&lt;/strong&gt; at no added noise, 2 of 41 seeded errors raised &lt;strong&gt;no card at all&lt;/strong&gt;. Whisper wrote "for three Bravo" and "tutu, hotel" for the tag &lt;em&gt;in the instruction&lt;/em&gt;, so no order was heard and the wrong read-back had nothing to be compared against. At −6 dB machine noise, &lt;strong&gt;26 of 41&lt;/strong&gt; seeded errors raised no card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third bullet is the finding. "0 wrong read-backs passed" stayed true the whole time, and it would make a clean headline. But an error that produces no card is not caught either; it just never reaches a human. The stricter form of my own rule counts those as failures, and the README now says so.&lt;/p&gt;

&lt;p&gt;Where the false alarms came from is also worth knowing, because none of it is the model being clever or dumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whisper writes spoken digit runs as "two, four, three", and the referee abstains on an unknown tag.&lt;/li&gt;
&lt;li&gt;I run Whisper twice (with and without a vocabulary prompt) and abstain when they disagree; the unprompted pass mishears plant words, so the gate fires on correct read-backs.&lt;/li&gt;
&lt;li&gt;On short or noisy turns Whisper invents text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these fail toward a human, which is the right direction. They still cost the supervisor's attention, which is the budget the rule protects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the selection rule down before the first model call, and keep the boring baseline in it.&lt;/strong&gt; Without the regex rung I would have reported "Gemma meets the rule" and stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Zero false accepts" is necessary, not sufficient.&lt;/strong&gt; Track the errors that never form a decision at all. A pipeline can be perfectly safe on what it judges and still miss what it never saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean-text evals flatter speech systems.&lt;/strong&gt; The 0/48 → 16/48 jump came entirely from transcription, before any model judged anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipts make retries honest.&lt;/strong&gt; The gateway failed most first-pass calls in one run (265 of 312), so re-audits were routine. Because every call is receipted and every result re-derives offline from stored outputs (&lt;code&gt;scripts/eval.py --rescore&lt;/code&gt;), a re-audit is visible, not quietly merged.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;No human recording has been evaluated. Every number here is written scripts or synthetic voices on a fictional plant registry.&lt;/li&gt;
&lt;li&gt;The misheard-instruction failure needs a referee change, which means a new pre-registration, not a quiet patch.&lt;/li&gt;
&lt;li&gt;The live link is a static replay of stored runs; the live queue (FastAPI + server-sent events) runs locally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next step is the row the rule was written for: real people, real radios, real plant noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try the refused one:&lt;/strong&gt; &lt;a href="https://sayagain.edycu.dev/replay/" rel="noopener noreferrer"&gt;https://sayagain.edycu.dev/replay/&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/sayagain" rel="noopener noreferrer"&gt;https://github.com/edycutjong/sayagain&lt;/a&gt; · &lt;strong&gt;Video:&lt;/strong&gt; &lt;a href="https://youtu.be/6a4IF-iJmxg" rel="noopener noreferrer"&gt;https://youtu.be/6a4IF-iJmxg&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you work with plant or field radio, I'd value one sentence on whether operators actually read back tag numbers and NATO letters like this.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>git bisect run named the wrong commit 10 out of 10 times — so I gave every commit its own clean room</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:42:26 +0000</pubDate>
      <link>https://dev.to/edycutjong/git-bisect-run-named-the-wrong-commit-10-out-of-10-times-so-i-gave-every-commit-its-own-clean-room-7ao</link>
      <guid>https://dev.to/edycutjong/git-bisect-run-named-the-wrong-commit-10-out-of-10-times-so-i-gave-every-commit-its-own-clean-room-7ao</guid>
      <description>&lt;p&gt;&lt;code&gt;git bisect run&lt;/code&gt; is the right algorithm. What I did not expect is how confidently it lies when you leave it running unattended.&lt;/p&gt;

&lt;p&gt;I generated a 255-commit Python history in which a dependency pin (&lt;code&gt;pytz&lt;/code&gt;) moves from 2022.1 to 2023.3 at commit 150, together with the golden value of the failing test. Each commit's test is right under its own pin and wrong under the other. A real bug is planted at commit 173. Then I ran &lt;code&gt;git bisect run&lt;/code&gt; on it ten times, in one reused virtualenv:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Named&lt;/th&gt;
&lt;th&gt;Right&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;env built at the known-good ref&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;pytz&lt;/code&gt; bump&lt;/td&gt;
&lt;td&gt;0 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;env built at the failing ref&lt;/td&gt;
&lt;td&gt;a changelog edit&lt;/td&gt;
&lt;td&gt;0 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fresh venv at every step&lt;/td&gt;
&lt;td&gt;the planted commit&lt;/td&gt;
&lt;td&gt;5 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Ten of ten wrong, both flavours.&lt;/strong&gt; The fixture is built to drift, so that is by construction — what it shows is the mechanism: bisect trusts one environment for every commit, and a moving pin makes some verdicts false, so the search walks into the wrong half and convicts an innocent commit with total confidence. (On a real regression with no drift — &lt;code&gt;python-attrs/attrs&lt;/code&gt; 22.1.0 → 22.2.0, 135 commits — every method was right.)&lt;/p&gt;

&lt;p&gt;The fix is not clever: give every suspect its own clean room. &lt;a href="https://faultline-edy-cus-projects.vercel.app" rel="noopener noreferrer"&gt;Faultline&lt;/a&gt; does that on Nebius Serverless AI Jobs — one terminating job per commit, on the stock &lt;code&gt;python:3.11&lt;/code&gt; image, installing that commit's own declared dependencies from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest column
&lt;/h2&gt;

&lt;p&gt;On the drifting history, Faultline named the planted commit in 3 rounds, 19 jobs, 957 seconds, $0.0948. A fresh-venv &lt;code&gt;git bisect run&lt;/code&gt; did it in 57 seconds on a laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faultline is slower.&lt;/strong&gt; A probe's VM takes about 4 minutes 20 seconds to start for about 5 seconds of work, and Nebius throttles job creation (HTTP 429, undocumented), so a round costs minutes where a local step costs seconds. It is not a faster bisect; it is one you can leave running without knowing about the drift or writing the fresh-environment harness. On a suite that takes minutes to build per commit, parallel rounds should start to pay for their cold starts — I have not measured such a repository, so I don't claim it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three walls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A job cannot tell you what happened.&lt;/strong&gt; The Jobs REST API has no exit code and no logs, so a probe cannot report a test result to a caller. My first workaround was a read-write bucket mounted into every probe, with the container writing a verdict file. Then I remembered what runs inside a probe: a stranger's &lt;code&gt;conftest.py&lt;/code&gt;. Anything mounted into the job belongs to the stranger — including every other hunt's records. Now each probe gets no mount and no key, only one presigned PUT URL for its own verdict object:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;presignPut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NebiusConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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="nx"&gt;expiresSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;objectUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;presignUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* SigV4, method PUT, one key, expires */&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 worst a hostile test suite can do with it is rewrite its own verdict — which it already controls by choosing its exit code. A side effect: a &lt;code&gt;uk-south1&lt;/code&gt; probe writes into an &lt;code&gt;eu-west1&lt;/code&gt; bucket over plain HTTPS, so regions are interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two jobs per region, and the third is rejected, not queued.&lt;/strong&gt; A new tenant's &lt;code&gt;compute.instance.count&lt;/code&gt; is 2; a third job returns &lt;code&gt;QuotaExceeded&lt;/code&gt;. So concurrency is a parameter of the backend, not a constant: 2 slots × 4 regions = 8 probes. Each round tests the two ends of the range plus as many suspects as there are free slots, and N probes cut the range into N+1 segments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No daemon, no database.&lt;/strong&gt; The control loop is a tick that every finishing probe pings. Two ticks can race, so the hunt record is replaced only with &lt;code&gt;If-Match&lt;/code&gt;, and every dispatch is claimed first with &lt;code&gt;If-None-Match: "*"&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;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ifNoneMatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;condition&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;If-None-Match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ifNoneMatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;If-Match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ifMatch&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;s3Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nx"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;412&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I measured it on the real bucket before building on it: 20 races of 2–8 concurrent creators on one key, exactly one winner every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model comes in — and where it doesn't
&lt;/h2&gt;

&lt;p&gt;When test execution has named the culprit, one call goes to NVIDIA Nemotron 3 Super on Nebius Token Factory with the diff and the failing output, and it writes the root-cause paragraph. It is a reasoning model: with a small token budget it can return its reasoning and no answer. So the call asks for low reasoning effort with room to answer, reads &lt;code&gt;content&lt;/code&gt; only, and treats an empty answer as an error rather than inventing one. The UI labels the paragraph as generated, next to a culprit labelled as proven by executed tests. The model explains; it never decides. (On the hero hunt it also got a detail wrong — it called UTC−6 "DST-adjusted" for Mexico City, which abolished DST in 2022 — and the project's DEMO.md says so.)&lt;/p&gt;

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

&lt;p&gt;Python test suites and public GitHub repositories only. One live hunt at a time on the shared slots, with a daily cap. Slower than a careful local bisect on a fast suite.&lt;/p&gt;

&lt;p&gt;You can watch a recorded real hunt (real job IDs, no cost) or run a live one, no login: &lt;a href="https://faultline-edy-cus-projects.vercel.app" rel="noopener noreferrer"&gt;https://faultline-edy-cus-projects.vercel.app&lt;/a&gt;. Code (MIT): &lt;a href="https://github.com/edycutjong/faultline" rel="noopener noreferrer"&gt;https://github.com/edycutjong/faultline&lt;/a&gt;. Built for the Nebius Global AI Hackathon.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>python</category>
      <category>git</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A CNN on every frame caught 2 of 54 wildfire windows at a fixed alert budget. One 20-line baseline made it 18.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:11:39 +0000</pubDate>
      <link>https://dev.to/edycutjong/a-cnn-on-every-frame-caught-2-of-54-wildfire-windows-at-a-fixed-alert-budget-one-20-line-baseline-393f</link>
      <guid>https://dev.to/edycutjong/a-cnn-on-every-frame-caught-2-of-54-wildfire-windows-at-a-fixed-alert-budget-one-20-line-baseline-393f</guid>
      <description>&lt;p&gt;Wildfire lookout networks have far more camera feeds than anyone can watch. The useful question isn't "is there smoke in this frame?" It's "which of these dozens of tiles should a person open next?", asked without burying them in alerts until they mute the tool.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;PlumeRank&lt;/strong&gt; around that question for the OpenCV AI Competition 2026. The most useful thing I learned wasn't in my own pipeline. It was what happened when I gave a baseline the same treatment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live, no login:&lt;/strong&gt; &lt;a href="https://live.plumerank.edycu.dev" rel="noopener noreferrer"&gt;https://live.plumerank.edycu.dev&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/plumerank" rel="noopener noreferrer"&gt;https://github.com/edycutjong/plumerank&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the budget first, then compare
&lt;/h2&gt;

&lt;p&gt;Every system in the comparison gets the same allowance: &lt;strong&gt;2 alerts per camera per 81-minute window&lt;/strong&gt;, which is the length of one FIgLib sequence. Each one is calibrated to spend that allowance on a fire-free &lt;em&gt;confusion set&lt;/em&gt; of 33 windows (low cloud, cloud shadow, overcast, cameras hunting exposure), and only then scored on fire footage.&lt;/p&gt;

&lt;p&gt;The allowance is enforced by a small, deliberately strict object:&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;spend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;camera_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Spend one alert on this camera. False when its allowance is already gone.&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;camera_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_spent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;camera_id&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; is not on the roster this budget was built for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_spent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;camera_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_per_camera&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_spent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;camera_id&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="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unknown camera raises instead of being auto-registered. A typo that silently created an extra camera would inflate the denominator that the whole comparison rests on.&lt;/p&gt;

&lt;p&gt;The threshold isn't chosen by hand. &lt;code&gt;solve_threshold&lt;/code&gt; finds the lowest value whose realised alert rate on the confusion set stays within the budget. A system can't look fast by alerting more, because it isn't allowed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result that surprised me: 2 → 18
&lt;/h2&gt;

&lt;p&gt;My first baseline was the obvious one: run the same small CNN on &lt;strong&gt;every frame&lt;/strong&gt; and alert when its probability crosses the solved threshold.&lt;/p&gt;

&lt;p&gt;At the fixed budget it detected &lt;strong&gt;2 of 54&lt;/strong&gt; held-out fire windows.&lt;/p&gt;

&lt;p&gt;The reason is in the table footnote: the solved threshold landed within 1e-6 of 1.0. On a fire-free set full of cloud and haze, the CNN's probabilities saturate. The only way to keep it to 2 alerts per camera-window was to demand near-certainty, and almost no real fire reached that.&lt;/p&gt;

&lt;p&gt;PlumeRank doesn't rank a camera by its absolute score. It ranks it by how far the camera has moved from &lt;strong&gt;its own recent past&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rebase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hazard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;past&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASELINE_WARMUP_FRAMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;sd_floor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASELINE_SD_FLOOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z_scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASELINE_Z_SCALE&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One camera&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s hazard this minute, relative to the hazards in ``past``.

    ``past`` is the trailing window and must NOT contain ``hazard`` itself.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;past&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;mu&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;past&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;var&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;v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;past&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sd_floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hazard&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;z_scale&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window is 15 frames (15 minutes) per camera. A camera staring into haze all afternoon competes with its own hazy afternoon, not with a camera under clear sky.&lt;/p&gt;

&lt;p&gt;For a fair comparison I put &lt;strong&gt;both baselines through that same function&lt;/strong&gt;. The CNN-on-every-frame went from &lt;strong&gt;2/54 to 18/54&lt;/strong&gt;. Frame differencing went from 4/54 to 20/54.&lt;/p&gt;

&lt;p&gt;If you run any detector on fixed cameras, comparing each camera with its own recent past is probably the cheapest improvement available. It is about twenty lines and needs no training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline (OpenCV 5)
&lt;/h2&gt;

&lt;p&gt;Per camera, per frame:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stabilise&lt;/strong&gt;: Hanning-windowed &lt;code&gt;cv2.phaseCorrelate&lt;/code&gt; for the coarse shift, refined with &lt;code&gt;cv2.phaseCorrelateIterative&lt;/code&gt;, and an &lt;code&gt;estimateAffine2D&lt;/code&gt; fallback when the phase peak is weak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment&lt;/strong&gt;: MOG2 background subtraction finds what moved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Describe&lt;/strong&gt;: four cheap plume statistics on the moving region, fused into one hazard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebase&lt;/strong&gt;: the function above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate&lt;/strong&gt;: a small classifier on &lt;code&gt;cv2.dnn&lt;/code&gt;, only where it can change the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend&lt;/strong&gt;: one solved threshold, one budget.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is where the cost goes, so the gate is two conditions in one line:&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;should_escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&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;hazard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;ESCALATION_TOP_K&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hazard&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;ESCALATION_HAZARD_MIN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ESCALATION_TOP_K&lt;/code&gt; is 2, so at most two cameras per tick can ever reach the network, whatever the hazards do. The hazard floor is solved on the fire-free set and never tuned against fire windows. In practice the network runs on &lt;strong&gt;1.76% of frames&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Everything runs on one AWS &lt;code&gt;t4g.small&lt;/code&gt; (Graviton/ARM), replaying real HPWREN footage through the real pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The held-out run
&lt;/h2&gt;

&lt;p&gt;The constants were frozen in their own commit and hashed, and the bench refuses to read a held-out frame unless the hash matches. The held-out set was read once.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System (same budget, same per-camera baseline)&lt;/th&gt;
&lt;th&gt;Detected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PlumeRank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27 / 54&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frame differencing&lt;/td&gt;
&lt;td&gt;20 / 54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNN on every frame&lt;/td&gt;
&lt;td&gt;18 / 54&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; 1.4 neural-net calls per camera-window for PlumeRank, against 3,645 for the CNN on every frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't hold up
&lt;/h2&gt;

&lt;p&gt;These are on the README's first screen too, because I'd rather say them than have them found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The detection lead isn't significant.&lt;/strong&gt; Paired McNemar p = 0.18 and 0.25 against the two rebased baselines, and approximate, because 22 of the 54 windows come from fires that contribute more than one window. The cost gap is the decisive result; the detection count is a lead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half the held-out fire windows are missed&lt;/strong&gt; (27 of 54).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The median is on a knife edge.&lt;/strong&gt; 38.0 min is a nearest-rank median: the 27th and slowest detection. Shift the ignition clock a few minutes later and there's no median at all. Detected fires themselves are found sooner (median 10.0 min).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daylight only.&lt;/strong&gt; No IR, no night detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The statistic I designed the project around is anti-predictive on its own&lt;/strong&gt; (AUC 0.44 on development frames). The per-camera baseline carries the ranking, which is part of why the 2 → 18 result matters more to me than my own number.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live wall:&lt;/strong&gt; &lt;a href="https://live.plumerank.edycu.dev" rel="noopener noreferrer"&gt;https://live.plumerank.edycu.dev&lt;/a&gt; (30-second judge path: &lt;code&gt;/judge&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/plumerank" rel="noopener noreferrer"&gt;https://github.com/edycutjong/plumerank&lt;/a&gt;. 833 tests; &lt;code&gt;make verify&lt;/code&gt; re-checks the micro-corpus metrics byte-for-byte in about 3 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walkthrough video (3.5 min):&lt;/strong&gt; &lt;a href="https://youtu.be/QqX85JuNfVM" rel="noopener noreferrer"&gt;https://youtu.be/QqX85JuNfVM&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks to HPWREN and the FIgLib authors. Their imagery is what made any of this measurable.&lt;/p&gt;

&lt;p&gt;If you've worked with fire cameras, FIgLib or SmokeyNet, I'd like to hear where this reasoning breaks.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opencv</category>
      <category>computervision</category>
      <category>python</category>
    </item>
    <item>
      <title>Amazon's own BIF file stores a timestamp unit of 0xFFFFD8F0, and the obvious fix would break it</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:11:33 +0000</pubDate>
      <link>https://dev.to/edycutjong/amazons-own-bif-file-stores-a-timestamp-unit-of-0xffffd8f0-and-the-obvious-fix-would-break-it-9ab</link>
      <guid>https://dev.to/edycutjong/amazons-own-bif-file-stores-a-timestamp-unit-of-0xffffd8f0-and-the-obvious-fix-would-break-it-9ab</guid>
      <description>&lt;p&gt;I wanted a seek bar that knows where the goals are, without a feed and without the scan decoding any video. (Disclosure: I directed Claude Code agents that wrote most of this code; I reviewed and decided.) A Vega player that shows BIF scrub previews, like Amazon's sample, already downloads a &lt;strong&gt;BIF&lt;/strong&gt; file, one small JPEG about every ten seconds of the whole asset. So I treated that file as a contact sheet of the match, and wrote a detector that watches the score graphic across the thumbnails.&lt;/p&gt;

&lt;p&gt;The detector works, on one development match. The more useful thing I found was in the BIF itself: &lt;strong&gt;Amazon's own hosted sample file has a timestamp header that the sample parser only survives by accident&lt;/strong&gt;, and the "obvious" fix would have broken it.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/edycutjong/flipbook" rel="noopener noreferrer"&gt;Flipbook&lt;/a&gt;, built for the Amazon Developer Hackathon (Fire TV track) on the Vega Virtual Device. It starts from a copy of Amazon's &lt;code&gt;vega-video-sample&lt;/code&gt;. The 2:54 demo, recorded on the virtual device:&lt;/p&gt;

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

&lt;p&gt;Landing page and pitch deck: &lt;a href="https://flipbook.edycu.dev/" rel="noopener noreferrer"&gt;flipbook.edycu.dev&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header that says four billion milliseconds
&lt;/h2&gt;

&lt;p&gt;A BIF is a 64-byte header, an index of &lt;code&gt;(timestamp, offset)&lt;/code&gt; pairs, then JPEGs. The header stores a "timestamp multiplier": milliseconds per timestamp unit. I fetched the first 32 bytes of the file the sample streams, &lt;code&gt;tv-webinar-hd.bif&lt;/code&gt;, with an HTTP Range request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00000000: 8942 4946 0d0a 1a0a 0000 0000 ac01 0000  .BIF............
00000010: f0d8 ffff 0000 0000 0000 0000 0000 0000  ................
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;428 frames (&lt;code&gt;0x1AC&lt;/code&gt;), and a multiplier of &lt;code&gt;0xFFFFD8F0&lt;/code&gt;. Read as an unsigned 32-bit number that is 4,294,957,296. The sample's parser multiplies every timestamp by it, so each frame is "about 4.29 billion milliseconds" after the last. The player still shows sensible previews because the sample's lookup ignores the parsed times and assumes one frame per ten seconds.&lt;/p&gt;

&lt;p&gt;Look at it as a signed 32-bit integer and it is &lt;strong&gt;−10,000&lt;/strong&gt;: ten seconds, with the sign wrong. That is my inference. I cannot see how Amazon's file was made, and I am not claiming to know. But it matches the ten-second spacing the file actually has.&lt;/p&gt;

&lt;p&gt;The catalogue entry for that title also says &lt;code&gt;duration: 190&lt;/code&gt;, for a 71-minute video. The friction log has the details (&lt;a href="https://github.com/edycutjong/flipbook/blob/main/FRICTION_LOG.md" rel="noopener noreferrer"&gt;entry 6&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fix is wrong
&lt;/h2&gt;

&lt;p&gt;My plan for an upstream fix was to make the sample's thumbnail lookup binary-search the parsed &lt;code&gt;positionMillis&lt;/code&gt;. That is how a BIF is supposed to work. On &lt;code&gt;tv-webinar-hd.bif&lt;/code&gt; the parsed times are the 4.29-billion-millisecond values above, so a search by position cannot work, and the fix would break the one file Amazon's own sample ships. A fix that works on the spec and fails on the reference asset is not a fix you can send upstream, so the reader has to decide, per file, whether the header can be trusted.&lt;/p&gt;

&lt;p&gt;Flipbook's reader (&lt;code&gt;src/flipbook/core/bifIndex.ts&lt;/code&gt;) does that (the snippet is abridged from it). A multiplier is trusted only if it is between 1 ms and 60 s; otherwise the unit comes from the asset's duration, and failing that from the sample's own ten-second assumption:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawMultiplier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;unitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Roku spec: 0 means 1000 ms&lt;/span&gt;
  &lt;span class="nx"&gt;intervalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSaneMultiplier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawMultiplier&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;unitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawMultiplier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;intervalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationMs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;frameCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;unitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;frameCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;intervalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;unitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_UNIT_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 10 s&lt;/span&gt;
  &lt;span class="nx"&gt;intervalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback-10s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same function refuses a file with no &lt;code&gt;0xffffffff&lt;/code&gt; terminator entry. The sample logs a warning for that and then trusts the trailing offset, so the last frame is read from a corrupt range (&lt;a href="https://github.com/edycutjong/flipbook/blob/main/FRICTION_LOG.md" rel="noopener noreferrer"&gt;entry 7&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The detector, in one paragraph
&lt;/h2&gt;

&lt;p&gt;It is arithmetic, no ML. The viewer draws a box around the broadcaster's score graphic once. For each thumbnail Flipbook decodes the JPEG on the device with &lt;code&gt;jpeg-js&lt;/code&gt;, takes a 64×5 luma grid of the box, and flags a change only if the new state holds for most of a three-frame window, so a replay or a flicker does not count. A first pass reads every eighth frame and bisects where something changed, to put the first ticks up quickly; a second pass reads all frames. Pressing fast-forward jumps to the marker minus 30 seconds, anchored on the last frame that still shows the &lt;strong&gt;old&lt;/strong&gt; score, so you land before the play. By default the seek bar shows no ticks and the thumbnails are masked over the score box, so the result is not spoiled while you hunt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and what I did not
&lt;/h2&gt;

&lt;p&gt;On a development match (a CC0 high-school soccer broadcast, 517 thumbnails), labelled by the detector's author (an AI agent working under my direction) — so not independent ground truth:&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;Changes found&lt;/td&gt;
&lt;td&gt;2 of 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False markers&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jumps that landed before the play&lt;/td&gt;
&lt;td&gt;2 of 2 (28 s and 30 s early)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frames read in pass 1&lt;/td&gt;
&lt;td&gt;77 of 517 (14.9 %)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score box vs. noise&lt;/td&gt;
&lt;td&gt;2.64× at 320 px, 2.04× at 240×180 letterboxed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A plain frame differ finds both changes only at a threshold of 30 or lower (of the 8–80 I swept), and at 30, the best of those, it still fires 237 false markers. My first signature, a 16×4 mean, could not separate a goal from noise on that match, which is why there is a version 2.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;Vega Virtual Device on an Apple M-series Mac&lt;/strong&gt;, the same development match, calibrated with the D-pad and relaunched 19 times: pass 1 (77 of 517 frames) p50 2,771 ms, p95 3,422 ms; pass 2 p50 14,294 ms; the same two markers as the Node run, and the calibration box restored from storage after every relaunch. Earlier, the first detector version read 203 of 428 frames of Amazon's BIF in 4,085 ms, about 20 ms per decoded frame. I have no Fire TV hardware, so there is no real-device timing here, and the scan decoded no video bytes (the player still plays the video).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, 2026-10-15:&lt;/strong&gt; the held-out result now exists, with a caveat. No person labelled the two test matches in time, so the labels come from two separate AI sessions that were instructed to use only the video (not the code, the fixtures or any detector output). They were committed before the detector ever ran on those matches, and the change of labeller is recorded in the pre-registration file. On held-out match B, 2 of 3 goal jumps landed before the play (95 % CI 0.094–0.992), with 0 false markers; the third goal's score change scored below the threshold and was missed. Three goals is too few to say how well it generalises, and these are AI labels, not a person's — the README's honest limit 2 has the details.&lt;/p&gt;

&lt;p&gt;When I first wrote this, I did not have a held-out result. Two more matches are frozen behind a pre-registration file, and &lt;code&gt;bench&lt;/code&gt; refuses to run on them until a filled-in ground-truth file that names its labeller is committed. The plan is that a person other than the detector's author does the labelling; the guard checks that the file is committed, filled in, for the right match and names its labeller, but it cannot check who that person is. Until those labels exist, I am not claiming a headline number, and the README says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/edycutjong/flipbook &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;flipbook
npm ci &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt;   &lt;span class="c"&gt;# 448 tests, 97 of them Flipbook's (skips the Shaka build only the device build needs)&lt;/span&gt;
npm run bench                    &lt;span class="c"&gt;# the development-match numbers above, 20 runs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would ask Amazon for
&lt;/h2&gt;

&lt;p&gt;A frame-grab or pixel read-back API on the player surface, so apps are not limited to the frames inside a BIF; a BIF lookup that uses the file's own interval; and a corrected hosted &lt;code&gt;tv-webinar-hd.bif&lt;/code&gt;. The full friction log is in the repo, 18 entries.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>reactnative</category>
      <category>typescript</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>An agent that refuses: building a payout agent with neatlogs, Entire and cfo.ai</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 11 Oct 2026 10:15:42 +0000</pubDate>
      <link>https://dev.to/edycutjong/an-agent-that-refuses-building-a-payout-agent-with-neatlogs-entire-and-cfoai-oo3</link>
      <guid>https://dev.to/edycutjong/an-agent-that-refuses-building-a-payout-agent-with-neatlogs-entire-and-cfoai-oo3</guid>
      <description>&lt;p&gt;I won a $7,000 hackathon prize. The payout form offered me Wise. Wise's own help page says that personal accounts with an address in Indonesia "can no longer hold money or currencies in their account."&lt;/p&gt;

&lt;p&gt;That is the kind of mistake I built &lt;strong&gt;&lt;a href="https://app.cair.edycu.dev" rel="noopener noreferrer"&gt;Cair&lt;/a&gt;&lt;/strong&gt; to prevent: an agent that plans how a prize or client payment reaches your bank account, and &lt;em&gt;refuses&lt;/em&gt; routes that cannot deliver. It was built for neatHack, a 48-hour agent hackathon, with neatlogs (traces), Entire (a local code graph) and cfo.ai (the business plan). Code: &lt;a href="https://github.com/edycutjong/cair" rel="noopener noreferrer"&gt;github.com/edycutjong/cair&lt;/a&gt;. Live: &lt;a href="https://app.cair.edycu.dev" rel="noopener noreferrer"&gt;app.cair.edycu.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is about the part I did not expect to matter most: &lt;strong&gt;how little the model is allowed to decide.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline is a confident paragraph
&lt;/h2&gt;

&lt;p&gt;I started by measuring the obvious answer: one call to the model, no tools. I froze 48 scenarios (3 countries x 4 payers x 3 amounts, plus 12 leading prompts) and computed the correct labels from a sourced rules sheet &lt;em&gt;before&lt;/em&gt; writing any planner code. Then I ran the one-call baseline three times.&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;v1: one call&lt;/th&gt;
&lt;th&gt;v2: Cair&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recommends a route its own provider rules out&lt;/td&gt;
&lt;td&gt;18 / 48, every run&lt;/td&gt;
&lt;td&gt;0 / 48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradicts a sourced tax fact&lt;/td&gt;
&lt;td&gt;8 / 48, every run&lt;/td&gt;
&lt;td&gt;0 / 48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Says UNCLEAR where no primary source exists&lt;/td&gt;
&lt;td&gt;11-23 / 143 fields&lt;/td&gt;
&lt;td&gt;143 / 143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Figures traceable to a tool output&lt;/td&gt;
&lt;td&gt;n/a, no tools&lt;/td&gt;
&lt;td&gt;359 / 359&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The baseline recommended Wise for every Indonesian Devpost winner in all three runs. A second baseline that gets the entire rules sheet pasted into its prompt still recommended a dead route twice and stated an unsupported tax position 11 times. The model had the facts in front of it and still wrote a confident answer around the gaps.&lt;/p&gt;

&lt;p&gt;(The caveat that matters: this measures fidelity to the &lt;em&gt;sourced sheet&lt;/em&gt;, not tax correctness. The labels and the agent read the same sheet.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So the code decides, and the model only words it
&lt;/h2&gt;

&lt;p&gt;Cair runs a fixed evidence plan before any prose: what the payer offers, whether each offered option can receive in your country, the US treaty position, the ECB reference rate, then Decimal arithmetic. Each step is a neatlogs span, so every plan is one trace:&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="nd"&gt;@neatlogs.span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&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="n"&gt;tool_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fx_quote&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ECB daily reference rate USD -&amp;gt; local currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fx_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_ccy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;_maybe_fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fx_quote&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_ccy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route and W-8BEN line 10 come from sourced rows, never from the model. The model writes the explanation, and then a provenance guardrail checks it: any money amount, rate or treaty article that is not in a tool output blocks the answer, which gets one retry and then a template built from the tool outputs.&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;with&lt;/span&gt; &lt;span class="n"&gt;neatlogs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provenance_guardrail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GUARDRAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_outputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neatlogs.guardrail.passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neatlogs.guardrail.score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the guardrail is a span, every blocked answer is searchable in neatlogs. That turned out to be the debugging tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  What trace search found
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search_traces "provenance_guardrail BLOCKED"&lt;/code&gt; over the neatlogs MCP server returned the v2 plans whose explanation had been rejected. &lt;code&gt;get_trace_context&lt;/code&gt; showed the model's text next to the guardrail verdict. Two real bugs came out of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A correct answer was blocked because my own figure rule read &lt;code&gt;2000.00 USD&lt;/code&gt; as a &lt;em&gt;year&lt;/em&gt;. Any amount from 1,900 to 2,099 written without a thousands separator was affected. A false block, not a false pass, but I would not have seen it without the span.&lt;/li&gt;
&lt;li&gt;Refusals that repeated the user's own "0%" ("I can't say the withholding is 0%") were blocked twice and fell back to the template. I considered allowing figures that appear in the question inside negated sentences, and rejected it: it would let "the withholding is not 30%, it is 0%" through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are fixed, each pinned by a test named for the defect, and re-run. The write-up with the trace ids is in the repo (&lt;code&gt;evidence/rca-01.md&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Entire's local code graph was the second tool in that loop: after the year fix, &lt;code&gt;entire graph search&lt;/code&gt; given the bug in plain words ranked the corrected rule first and its test second. It was also wrong in an instructive way: &lt;code&gt;impact&lt;/code&gt; on &lt;code&gt;rail_eligibility&lt;/code&gt; reported 0 callers, probably because the planner passes tools as function values (&lt;code&gt;att.call("fx_quote", tools.fx_quote, ...)&lt;/code&gt;), which the graph does not follow. I recorded that as found rather than hiding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a tool fails
&lt;/h2&gt;

&lt;p&gt;The hackathon asked for a failed-action recovery run, so I made one on purpose. &lt;code&gt;scripts/eval.py --planner v2 --fail fx_quote&lt;/code&gt; makes the FX tool raise on every call:&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;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# the failed span is already marked ERROR by the SDK
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&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;fallback&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent retries once, then falls back to the last ECB rate it fetched, stamped stale with its date, and finishes. In the 24 USD-payer scenarios that meant 24 fallbacks; all 48 plans completed, 0 undeliverable routes, 350 / 350 figures sourced. The neatlogs trace shows two ERROR spans and the fallback span, and the dashboard footer says "ended with error" because it counts the errored tool spans, while the workflow and agent spans succeeded. I kept that on the page instead of tidying it. The agent never asks the model for a rate.&lt;/p&gt;

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

&lt;p&gt;Measured tokens at list price: $0.0033 of model cost per plan, p50 5.50 s and p95 10.31 s on my laptop. In cfo.ai the base scenario (assumed prices, planned and not built) costs $18.887 a month to run, and the $25 AWS credit lasts 1.35 months before any revenue. The model is shared here: &lt;a href="https://cfo.ai/s/PQ2HUGmiJl3P0RQB" rel="noopener noreferrer"&gt;cfo.ai/s/PQ2HUGmiJl3P0RQB&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Three countries are fully sourced (Indonesia, India, the Philippines). Everything else is UNCLEAR by design.&lt;/li&gt;
&lt;li&gt;The guardrail is rule-based. On a fresh held-out set it blocked 62 / 66 violations and 3 / 66 honest sentences, before I fixed its misses. Expect new phrasings to slip through at roughly that rate, which is why the route and every amount are decided by code.&lt;/li&gt;
&lt;li&gt;Payoneer's receiving fee is not published as one number, so "what lands" is an upper bound.&lt;/li&gt;
&lt;li&gt;Receipts so far are my own: one replay of a known payout (at most $4,900 promised, $4,897 arrived) and one test receipt. Forward receipts from real users: none yet.&lt;/li&gt;
&lt;li&gt;Cair explains forms; it never files them. Not tax advice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo, evidence and the commands to reproduce every number: &lt;a href="https://github.com/edycutjong/cair" rel="noopener noreferrer"&gt;github.com/edycutjong/cair&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>My memory app marks 'Vitamin D' wrong for 'Vitamin C'. Unless you say it out loud.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Mon, 05 Oct 2026 06:31:46 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-memory-app-marks-vitamin-d-wrong-for-vitamin-c-unless-you-say-it-out-loud-29cd</link>
      <guid>https://dev.to/edycutjong/my-memory-app-marks-vitamin-d-wrong-for-vitamin-c-unless-you-say-it-out-loud-29cd</guid>
      <description>&lt;p&gt;You're learning the vitamins for an exam. At the stop for Vitamin C, you say "Vitamin D". The app turns the stop green. You just practised the wrong answer.&lt;/p&gt;

&lt;p&gt;That app is mine. It's called Loci, and I found this while writing this post. Type "Vitamin D" at that stop and Loci marks it wrong, as it should. Say it out loud and the stop lights up.&lt;/p&gt;

&lt;p&gt;Loci's answer checker has one rule: forgive the typo, never the wrong answer. This post is how that rule was built and tested, and how the voice path got around it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://loci.edycu.dev" rel="noopener noreferrer"&gt;https://loci.edycu.dev&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/loci" rel="noopener noreferrer"&gt;https://github.com/edycutjong/loci&lt;/a&gt;&lt;/strong&gt; (MIT)&lt;/li&gt;
&lt;li&gt;Demo video (2:30): &lt;strong&gt;&lt;a href="https://youtu.be/I5sx5C28xR0" rel="noopener noreferrer"&gt;https://youtu.be/I5sx5C28xR0&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Loci is the memory palace, with your own room as the palace. Take a photo of your room and paste a list of 3 to 12 items, in order. An AI finds the objects in the photo. Plain code picks one object per item and joins them into one route. A second AI call writes a short, strange scene for each stop.&lt;/p&gt;

&lt;p&gt;Then the lights go out. At each stop you say or type the item. A right answer turns the pin green and lights that part of the room again.&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%2Ffi33mtr1uxh7olcoff6a.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%2Ffi33mtr1uxh7olcoff6a.gif" alt="The Loci app in an AI-generated student room: four stops of Learn with a scene each, then lights out, and each typed answer turns its stop green" width="720" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The real app, recorded from the production build. The room is AI-generated, and a script types the answers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI proposes. Plain code decides. Whether you remembered is decided by &lt;a href="https://github.com/edycutjong/loci/blob/main/shared/score.ts" rel="noopener noreferrer"&gt;&lt;code&gt;shared/score.ts&lt;/code&gt;&lt;/a&gt;, plain TypeScript, never by the AI.&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%2Fl4i16krmzpq5wl5daxji.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%2Fl4i16krmzpq5wl5daxji.png" alt="How Loci works, in eight steps: photo and list on your device, the AI finds objects, code chooses the route, the AI writes scenes, you learn, lights out, code checks the answer, the stop turns green" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The first rule: one typo budget for the whole answer
&lt;/h2&gt;

&lt;p&gt;The first rule in my planning notes was simple. Allow typos up to 20% of the answer's length, at most 2, anywhere in it. The repo keeps that rule in a regression test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/regressions.test.ts: the planning notes' first rule&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wholeAnswerRule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;said&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="nx"&gt;target&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;said&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"vitamin c" is 9 characters with the space. 20% of 9, rounded down, is 1 typo. "vitamin d" is exactly 1 typo away, so it passes. "Henry VII" passes for "Henry VIII" the same way.&lt;/p&gt;

&lt;p&gt;That rule never shipped. Before any checker code existed, the plan listed pairs that must be wrong, and these two broke the rule on paper. The plan was made with the Devpost Learn skill pack, and it's in the repo's &lt;a href="https://github.com/edycutjong/loci/tree/main/devpost" rel="noopener noreferrer"&gt;&lt;code&gt;devpost/&lt;/code&gt;&lt;/a&gt; folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: the same 20%, per word
&lt;/h2&gt;

&lt;p&gt;The fix kept the 20%. It moved it from the answer to each word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/score.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** Typos forgiven in one word: 0 for words of up to 4 letters, 1 for 5–9 letters, 2 for 10 or more. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wordLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;wordLength&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="cm"&gt;/** At most this many typos across a whole answer. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOTAL_TYPOS&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="cm"&gt;/** Is `answer` close enough to one target text? */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;closeEnough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&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="nx"&gt;target&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// an empty answer is never right&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;aWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aWords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;tWords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "oatmilk" = "oat milk"&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tWords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;typos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aWords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;tWords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;typos&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;allowance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tWords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "vitamin c" ≠ "vitamin d": short words must be exact&lt;/span&gt;
    &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;typos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOTAL_TYPOS&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Word&lt;/th&gt;
&lt;th&gt;Typos forgiven&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;up to 4 letters&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 to 9 letters&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 letters or more&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the whole answer&lt;/td&gt;
&lt;td&gt;3 at most&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Vitamin" may have one typo. "C" may have none. So "occulomotor" counts for Oculomotor, and "Vitamin D" never counts for "Vitamin C".&lt;/p&gt;

&lt;p&gt;Before comparing, &lt;code&gt;normalize&lt;/code&gt; lowercases both texts and drops accents, punctuation and numbering like "1.". &lt;code&gt;distance&lt;/code&gt; counts two swapped letters as one typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  60,000 answers with known verdicts
&lt;/h2&gt;

&lt;p&gt;Unit tests pin the edges: a word at its allowance passes, and one more typo fails. On top of that, &lt;a href="https://fast-check.dev" rel="noopener noreferrer"&gt;fast-check&lt;/a&gt; generates 60,000 answers per run, across 6 properties.&lt;/p&gt;

&lt;p&gt;Each answer is built so its verdict is known without asking the checker. Change, drop or add one letter in a word of up to 4 letters, and the answer must be wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/score.property.test.ts (itemWith, said and replace are small helpers at the top of the file)&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a word of up to 4 letters must be exact: one letter changed, missing or added is never right ('Vitamin D' for 'Vitamin C')&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constantFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="na"&gt;letter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constantFrom&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;ALPHA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;itemWith&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;letter&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offByOne&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ALPHA&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;ALPHA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;letter&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;closeEnough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;said&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offByOne&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;said&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numRuns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RUNS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those 60,000 answers goes through &lt;code&gt;closeEnough&lt;/code&gt;. Keep that in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice needs more ways to say yes
&lt;/h2&gt;

&lt;p&gt;Speech is messier than typing, in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split words.&lt;/strong&gt; Speech-to-text can break a long word in two: "vestibular cochlear" for Vestibulocochlear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound-alikes.&lt;/strong&gt; Loci's scenes use them on purpose, as memory hooks: "trochlear" sounds like "truck-lear".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Echoes.&lt;/strong&gt; When Chrome's on-device speech model is installed, Loci passes it your list as hints. While recording the demo (recorded audio, not a live microphone), its final answers echoed them about once in a hundred words: "Cambrian Jurassic Quaternary" when only "Quaternary" was said.&lt;/li&gt;
&lt;/ul&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%2Fityeqynw18u8rs28qrne.webp" 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%2Fityeqynw18u8rs28qrne.webp" alt="Stop 4 of the 12 cranial nerves in Loci: Trochlear on the wall clock. The scene reads " width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A scene with its sound-alike. The room is AI-generated.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So for each stretch of speech, the voice path tries the typing checker first, then two more rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/voice.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** Is this stretch of speech the item (or one of its accepted answers)? */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;spokenMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spoken&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;closeEnough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spoken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spoken&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;allowance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "vestibular cochlear"&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;soundsAlike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "truck lear"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sound-alike rule compares consonant skeletons. It drops the vowels, h, w and y, and merges letters that sound close, so "truck lear" and "trochlear" both become &lt;code&gt;trklr&lt;/code&gt;. Keys shorter than 4 letters never count. For echoes, a second reading skips leading words that name stops you already answered.&lt;/p&gt;

&lt;p&gt;Voice is forgiving in one more, safe way. A phrase that matches nothing in your list shows "didn't catch that", and it costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old rule came back
&lt;/h2&gt;

&lt;p&gt;Look at the second rule again. Join the words, then allow typos by the length of the whole thing. That's the first rule, the one the plan threw out. It came back in the voice slice of the build, the same day, under a comment about "vestibular cochlear".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the two functions return at commit d0e5df9&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;closeEnough&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./shared/score&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;spokenMatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./shared/voice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;closeEnough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Vitamin D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Vitamin C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                         &lt;span class="c1"&gt;// false: typed, the stop stays dark&lt;/span&gt;
&lt;span class="nf"&gt;spokenMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vitamin d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Vitamin C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// true: said, the stop turns green&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"vitamind" against "vitaminc" is 8 letters, so 1 typo is allowed, and 1 is used. The full voice interpreter agrees: at the Vitamin C stop, hearing "vitamin d" returns &lt;code&gt;{ kind: "right", stop: 0 }&lt;/code&gt;. "Henry VII" and "Henry VIII" pass for each other the same way, in both directions. So would two numbered items one digit apart, like "Type 1" and "Type 2". The sound-alike key drops digits too.&lt;/p&gt;

&lt;p&gt;Step 7 of the diagram above says "short words exact" and names two files. Only &lt;code&gt;shared/score.ts&lt;/code&gt; keeps that promise.&lt;/p&gt;

&lt;p&gt;Why didn't the tests catch it?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 60,000 answers only test &lt;code&gt;closeEnough&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;spokenMatch&lt;/code&gt; calls it first, then has two more ways to say yes. No property runs through those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The voice tests use the example lists.&lt;/strong&gt; They check that no two items in the same list match each other. That passes, because no example list has two items that close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The echo fix's regression test even uses Vitamin C and Vitamin D.&lt;/strong&gt; It checks that "vitamin d" still counts at the Vitamin D stop. It never asks what happens at the Vitamin C stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A list of pairs that must fail only protects the functions you run it through. Every path that can say yes needs the whole list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026-10-05):&lt;/strong&gt; fixed in &lt;a href="https://github.com/edycutjong/loci/commit/60f6fd4" rel="noopener noreferrer"&gt;commit &lt;code&gt;60f6fd4&lt;/code&gt;&lt;/a&gt;. The two extra voice rules now run only when every short word of the item was heard exactly, and the must-fail pairs run through &lt;code&gt;spokenMatch&lt;/code&gt; and the interpreter in their own test. The price: voice is now as strict as typing on short words. "World War 2" no longer counts for "World War II" unless you add it as an accepted answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI half, briefly
&lt;/h2&gt;

&lt;p&gt;The AI does two jobs: find the objects in your photo (Gemini first, with fallbacks) and write the scenes (DeepSeek first). Code checks both answers before anything uses them. Malformed boxes and empty scenes are dropped, never repaired.&lt;/p&gt;

&lt;p&gt;One prompt fix from the build: with only a rule telling it to spell the item exactly, DeepSeek dropped the item's exact spelling in 8 of 12 scenes that used a sound-alike. One worked example in the prompt fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Write the item itself, spelled exactly as given, in every scene, even when the image rests on a sound-alike. Example for the item "Trochlear" on a wall clock: "Your wall clock turns into a little truck-lear, honking 'Trochlear!' as it circles the room."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the live site, a script built 9 palaces the way a person would: 3 AI-generated rooms, each uploaded as if it were your own photo, times 3 lists. Nothing was stubbed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;9 / 9&lt;/strong&gt; palaces built&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;102 / 102&lt;/strong&gt; scenes spell their item&lt;/li&gt;
&lt;li&gt;median wait &lt;strong&gt;14.45 s&lt;/strong&gt; from "Build my palace" to the first scene, slowest 30.02 s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$0.0115&lt;/strong&gt; billed for all nine ($0.1151 at list prices; the Gemini calls ran on a free-tier key)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full record, with every model's answer, is in &lt;a href="https://github.com/edycutjong/loci/blob/main/DEMO.md" rel="noopener noreferrer"&gt;DEMO.md&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One sitting only.&lt;/strong&gt; Loci shows a first-try score, learning time and recall time. It makes no claim about next week and has no spaced repetition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice is Chrome and Edge only, in US English.&lt;/strong&gt; It's checked with a stand-in recognizer and with recorded audio, never with a person at a microphone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The example rooms are AI-generated.&lt;/strong&gt; Real rooms are messier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your photo goes to an AI once&lt;/strong&gt; (Gemini, or DeepSeek if Gemini is busy). A free-tier Gemini key lets Google use what it receives, so leave people and papers out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typing is strict about extra words.&lt;/strong&gt; "optic nerve" stays dark for "Optic". Add it as an accepted answer: &lt;code&gt;Optic / Optic nerve&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Open &lt;strong&gt;&lt;a href="https://loci.edycu.dev" rel="noopener noreferrer"&gt;https://loci.edycu.dev&lt;/a&gt;&lt;/strong&gt; and tap &lt;strong&gt;Try it: 12 cranial nerves&lt;/strong&gt;. No photo, no sign-up, no AI call. Tap &lt;strong&gt;Next stop&lt;/strong&gt; a few times, then &lt;strong&gt;Lights out&lt;/strong&gt;, and type what you remember. In Chrome or Edge, &lt;strong&gt;Say it instead&lt;/strong&gt; switches to voice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/loci" rel="noopener noreferrer"&gt;https://github.com/edycutjong/loci&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The 30-second path for judges: &lt;a href="https://loci.edycu.dev/judge/" rel="noopener noreferrer"&gt;https://loci.edycu.dev/judge/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Story page: &lt;a href="https://loci.edycu.dev/story/" rel="noopener noreferrer"&gt;https://loci.edycu.dev/story/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Devpost: &lt;a href="https://devpost.com/software/loci-4isou6" rel="noopener noreferrer"&gt;https://devpost.com/software/loci-4isou6&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Loci was built for Build With AI: Basics on Devpost.&lt;/p&gt;

&lt;p&gt;If you write a fuzzy matcher, write the pairs that must fail before you write the rule. Then run them through every function that can say yes, not just the first one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with help from an AI assistant, working from the repo's code and docs. The rooms in the images are AI-generated, and the demo video uses an AI voice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>typescript</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My first-choice AI model said no to 5 of 6 room photos. The app still answered each one in about 4 seconds.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:20:25 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-first-choice-ai-model-said-no-to-5-of-6-room-photos-the-app-still-answered-each-one-in-about-4-5cj7</link>
      <guid>https://dev.to/edycutjong/my-first-choice-ai-model-said-no-to-5-of-6-room-photos-the-app-still-answered-each-one-in-about-4-5cj7</guid>
      <description>&lt;p&gt;After a stroke, some people with aphasia understand everything but can't find the words.&lt;/p&gt;

&lt;p&gt;Hospital picture cards (EAT, DRINK, TOILET) look nothing like their life. Another kind of board is made from a photo of the person's own surroundings. In AAC (tools that speak for people who can't), it's called a visual scene display. Someone has to build it by hand, object by object.&lt;/p&gt;

&lt;p&gt;Room to Speak does that part with AI. A caregiver takes one photo of the room where a parent with aphasia spends the day. A vision model finds up to 8 things he'd want to talk about and writes a short first-person phrase for each. Rings appear on the photo. He taps the kettle, and the phone speaks for him, with a phrase like &lt;em&gt;"Aku mau kopi"&lt;/em&gt; (I'd like coffee).&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%2Fvgwyevqzwpal0w2chjny.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%2Fvgwyevqzwpal0w2chjny.gif" alt="Room to Speak in use: an example bedroom is picked, rings appear on its objects, and tapping the walker lights it up while the phone speaks " width="600" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Real AI output on an AI-generated example bedroom, answered by &lt;code&gt;deepseek-flash&lt;/code&gt; (step 3 of the ladder below). The wait is trimmed and the GIF has no sound.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://roomspeak.edycu.dev" rel="noopener noreferrer"&gt;roomspeak.edycu.dev&lt;/a&gt; (tap an example room, no login), read the code on &lt;a href="https://github.com/edycutjong/roomspeak" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (MIT), or watch the &lt;a href="https://youtu.be/ijFneakCODk" rel="noopener noreferrer"&gt;1:48 demo&lt;/a&gt;. All room photos in this post are AI-generated.&lt;/p&gt;

&lt;p&gt;Everything he does every day runs on the device. Only setup needs the internet, and setup is one AI call. On 2026-10-04 I sent six room photos to the live function. My first-choice model said no to five of them. All six still came back with spots, with a median wait of 4.03 s, for $0.00.&lt;/p&gt;

&lt;p&gt;This post is about the code behind that, the bug that taught me to write it, and what happens to the answer once it arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  One call does all the AI work
&lt;/h2&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%2F7bib1r9gawm7v9cv1amq.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%2F7bib1r9gawm7v9cv1amq.png" alt="How Room to Speak works: setup runs once and is the only step that needs the internet (photo, shrink, POST /api/detect, a three-model ladder with a 55 s budget, validate, box to ring, caregiver review, save on the device). Every day, he taps, the nearest ring wins, and the device voice speaks." width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The browser shrinks the photo to 1600 px at most and posts it to one Vercel function, &lt;code&gt;api/detect.ts&lt;/code&gt;. It's the only server code, and the API keys live there, so they never reach the browser. (A test builds the app with canary keys and checks that no built file contains them.)&lt;/p&gt;

&lt;p&gt;The prompt in &lt;code&gt;shared/prompt.ts&lt;/code&gt; asks for structure, not prose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pick up to 8 objects he would most plausibly want to talk about for daily needs, comfort,
people, or activities (e.g. kettle/cup -&amp;gt; a drink, TV/radio -&amp;gt; turn it on, window -&amp;gt; fresh air,
bed/chair -&amp;gt; rest, family photo -&amp;gt; a person, medicine, fan/AC, door, phone).
Skip structural or trivial things: walls, floor, ceiling, ceiling lamps, outlets, cables, generic clutter.
Only include objects that are clearly visible; fewer good spots beats 8 weak ones.

For each object return:
- label: short object name in ${name}
- phrase: what HE says, first person, natural everyday spoken ${name} (casual, not formal), max 6 words
- box_2d: [ymin, xmin, ymax, xmax] normalized to 0-1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini also gets a JSON response schema, so the answer comes back as an array. Each box is &lt;code&gt;[ymin, xmin, ymax, xmax]&lt;/code&gt; on a 0–1000 grid, whatever the photo size.&lt;/p&gt;

&lt;p&gt;The line I'd keep is &lt;em&gt;fewer good spots beats 8 weak ones&lt;/em&gt;. Here a wrong spot isn't noise. It's a button that says the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three models, one 55-second budget
&lt;/h2&gt;

&lt;p&gt;The function tries &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, then &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt;, then &lt;code&gt;deepseek-flash&lt;/code&gt; on DeepSeek's own API:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findSpots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&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="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Lang&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;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TOTAL_BUDGET_MS&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;tried&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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;ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;spots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;validateSpots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="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;name&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;tried&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tried&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;tried&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no provider key configured&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each step has its own time limit&lt;/strong&gt; (&lt;code&gt;a.ms&lt;/code&gt;): 12, 10 and 25 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All steps share one 55 s budget,&lt;/strong&gt; which keeps the function under the browser's 70 s wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With less than 4 s left, it stops&lt;/strong&gt; instead of starting a call it can't finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A provider without a key is skipped,&lt;/strong&gt; so the same code runs with one key or two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The timeout that skipped the fallback
&lt;/h2&gt;

&lt;p&gt;The first version only fell back on error responses, like the 503 "high demand" the very first real call got. Then a real run timed out on the main model.&lt;/p&gt;

&lt;p&gt;A timeout is not a response. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; makes &lt;code&gt;fetch&lt;/code&gt; throw a &lt;code&gt;TimeoutError&lt;/code&gt;, and my loop only looked at &lt;code&gt;res.status&lt;/code&gt;. So the error flew past the fallback and failed the whole request. The next model was never asked.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; around each step (&lt;a href="https://github.com/edycutjong/roomspeak/commit/863498a" rel="noopener noreferrer"&gt;863498a&lt;/a&gt;). A regression test now fakes that exact error and checks that the next model still answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What fetch throws when AbortSignal.timeout() fires.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The operation was aborted due to timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TimeoutError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The receipt, and how to check it
&lt;/h2&gt;

&lt;p&gt;The run from the top of the post: six AI-generated room photos, sent to the production function on 2026-10-04, encoded the way the app does it. Nothing mocked.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Answered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-3.8-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 of 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 of 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 of 6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;6/6 rooms answered · 43 spots · p50 4.03 s · max 7.38 s · $0.00 (Gemini API free tier)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The five rooms step 1 turned down still finished in 3.38 to 4.28 s, end to end. Step 1 said no fast, and step 2 picked them up. The one room step 1 did answer was the slowest of the six, at 7.38 s.&lt;/p&gt;

&lt;p&gt;You can make one of these calls yourself, with no key and no clone. This sends the app's example bedroom to the live function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"lang":"en","image":"'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://roomspeak.edycu.dev/samples/bedroom.jpg | &lt;span class="nb"&gt;base64&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://roomspeak.edycu.dev/api/detect &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n%{time_total} s\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2026-10-04 it returned 8 spots (bed, walker, radio, water glass, medicine, window, prayer rug, thermos) from &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt; in 6.9 s. The full six-room run, every spot and phrase, is in &lt;a href="https://github.com/edycutjong/roomspeak/blob/main/DEMO.md" rel="noopener noreferrer"&gt;DEMO.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  An answer is not a safe answer yet
&lt;/h2&gt;

&lt;p&gt;The answer becomes buttons, so &lt;code&gt;validateSpots&lt;/code&gt; checks every item first. Anything malformed is dropped, never repaired. The checks, from &lt;code&gt;shared/validate.ts&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_AI_SPOTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box_2d&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;phrase&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box_2d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;box_2d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;box_2d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ymin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ymax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;box_2d&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ymin&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;ymax&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;xmin&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not repair? A swapped box could be swapped back, but that's a guess about what the model meant. The caregiver can add a missed spot by hand. A wrong spot speaks.&lt;/p&gt;

&lt;p&gt;To test it, I didn't hand-write answers. A &lt;a href="https://fast-check.dev" rel="noopener noreferrer"&gt;fast-check&lt;/a&gt; property test builds them from spots that are valid or invalid &lt;em&gt;by construction&lt;/em&gt;. Each bad spot breaks exactly one rule: a value outside 0–1000, min and max swapped, zero height, the wrong number of values, a blank phrase, no box. So the right result is known without re-implementing the validator.&lt;/p&gt;

&lt;p&gt;Over 100,000 generated answers, the validator never returns a box outside 0–1000, a box with min ≥ max, a blank phrase or more than 8 spots, and never drops a valid spot before the cap. The test also asserts that the answers really do go over the 8-spot cap, so that branch is exercised, not just present. I wrote about why that check matters in &lt;a href="https://dev.to/edycutjong/50000-property-tests-passed-while-my-app-crowned-an-impostor-2bbo"&gt;an earlier post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rings a hand can hit
&lt;/h2&gt;

&lt;p&gt;Each box becomes a ring at its center, sized to the object but kept within calm bounds:&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;boxW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxHInWidthUnits&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overlapping rings get nudged apart a little. In Speak mode, no ring is smaller than 64 px across.&lt;/p&gt;

&lt;p&gt;Then the tap. It counts when the finger lifts, and only inside the same ring it started in. A second finger cancels it. So a resting palm, or a slide across the photo, never speaks:&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;function&lt;/span&gt; &lt;span class="nf"&gt;down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PointerEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;interactive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;press&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;press&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;press&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pointerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;locate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PointerEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;press&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pointerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;press&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;locate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;onActivate&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;hit&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;When rings overlap, &lt;code&gt;locate&lt;/code&gt; picks the one whose center is nearest. The rings are still real &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;s labelled with their phrase, so a keyboard and a screen reader reach them too. A &lt;code&gt;click&lt;/code&gt; from Enter or Space has &lt;code&gt;detail === 0&lt;/code&gt;, so the button's own handler only fires for the keyboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;onActivate&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The voice is the browser's own &lt;code&gt;speechSynthesis&lt;/code&gt;. Each new phrase calls &lt;code&gt;cancel()&lt;/code&gt; before &lt;code&gt;speak()&lt;/code&gt;, so a second tap never waits behind the first. With no voice for the chosen language, the phrase shows as large text instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more wall: fine everywhere except Vercel
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;import { validateSpots } from "../shared/validate"&lt;/code&gt; worked in Vite, Vitest and &lt;code&gt;tsc&lt;/code&gt;. Vercel runs &lt;code&gt;api/*.ts&lt;/code&gt; as Node ESM, which needs the &lt;code&gt;.js&lt;/code&gt; extension, so only the deploy broke (&lt;a href="https://github.com/edycutjong/roomspeak/commit/cf62a94" rel="noopener noreferrer"&gt;cf62a94&lt;/a&gt;). A test now walks every relative import reachable from &lt;code&gt;api/&lt;/code&gt; and fails on any without &lt;code&gt;.js&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry over
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A timeout is a thrown error, not a status code.&lt;/strong&gt; A fallback that only reads &lt;code&gt;res.status&lt;/code&gt; doesn't cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each step its own time limit and one shared budget,&lt;/strong&gt; so a slow model can't eat the next one's time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a model's JSON like user input.&lt;/strong&gt; Check it, drop what fails, never repair it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build test inputs whose right answer you know by construction,&lt;/strong&gt; and check that the edge you care about actually gets hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No private photos on the free tier.&lt;/strong&gt; Google's &lt;a href="https://ai.google.dev/gemini-api/terms" rel="noopener noreferrer"&gt;terms&lt;/a&gt; for unpaid use let it use submitted images to improve its products, and let human reviewers read them. Fine for the AI-generated example rooms; a family's real room needs the paid tier first. If you try the app, use the example rooms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured on AI-generated rooms only.&lt;/strong&gt; There is no benchmark on real family photos yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A proof of concept, not a medical device.&lt;/strong&gt; No clinical claims. One room, one device, the device's own voice. It still needs testing with real families and speech therapists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ladder doesn't say why.&lt;/strong&gt; The function reports which step answered, not why the earlier ones said no.&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;p&gt;&lt;strong&gt;App:&lt;/strong&gt; &lt;a href="https://roomspeak.edycu.dev" rel="noopener noreferrer"&gt;roomspeak.edycu.dev&lt;/a&gt; (tap an example room, no login) · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/roomspeak" rel="noopener noreferrer"&gt;github.com/edycutjong/roomspeak&lt;/a&gt; (MIT) · &lt;strong&gt;30-second path:&lt;/strong&gt; &lt;a href="https://roomspeak.edycu.dev/judge/" rel="noopener noreferrer"&gt;roomspeak.edycu.dev/judge&lt;/a&gt; · &lt;strong&gt;Story:&lt;/strong&gt; &lt;a href="https://roomspeak.edycu.dev/story/" rel="noopener noreferrer"&gt;roomspeak.edycu.dev/story&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Planned and built with the Devpost Learn skill pack for Build With AI: Basics. The planning docs are in the repo's &lt;a href="https://github.com/edycutjong/roomspeak/tree/main/devpost" rel="noopener noreferrer"&gt;&lt;code&gt;devpost/&lt;/code&gt;&lt;/a&gt; folder.&lt;/p&gt;

&lt;p&gt;If you work in AAC, or care for someone after a stroke, I'd like to hear what this gets wrong.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>a11y</category>
      <category>gemini</category>
    </item>
    <item>
      <title>I guessed Stylus would be 8x cheaper. The chain said 2.87x. Then I found 41% of that was my own Solidity.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:48:16 +0000</pubDate>
      <link>https://dev.to/edycutjong/i-guessed-stylus-would-be-8x-cheaper-the-chain-said-287x-then-i-found-41-of-that-was-my-own-19f4</link>
      <guid>https://dev.to/edycutjong/i-guessed-stylus-would-be-8x-cheaper-the-chain-said-287x-then-i-found-41-of-that-was-my-own-19f4</guid>
      <description>&lt;p&gt;In December, Maya's tokenized-equity position is down $3,140 on paper and she does nothing about it. Banking that loss properly means picking which of her 64 tax lots to sell (highest cost basis first, HIFO), then policing a 30-day calendar so a rebuy doesn't trip the wash-sale rule and void the deduction.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;HarvestBot&lt;/strong&gt; for the Arbitrum Open House Singapore buildathon: an agent that does that job on Robinhood Chain testnet. The contracts bound it: the lot selection is recomputed on chain, a rebuy inside the window reverts, and an off-map trade gets its bond slashed. The HIFO ledger is a Rust contract compiled to WASM with Arbitrum Stylus.&lt;/p&gt;

&lt;p&gt;This post covers the one number I published, and the part of it I had to take back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt; &lt;a href="https://harvestbot.edycu.dev" rel="noopener noreferrer"&gt;live site&lt;/a&gt; · &lt;a href="https://harvestbot.edycu.dev/verify/" rel="noopener noreferrer"&gt;verify on chain in your browser, no wallet&lt;/a&gt; · &lt;a href="https://harvestbot.edycu.dev/judge/" rel="noopener noreferrer"&gt;30-second judge path&lt;/a&gt; · &lt;a href="https://github.com/edycutjong/harvestbot" rel="noopener noreferrer"&gt;repo&lt;/a&gt; · &lt;a href="https://youtu.be/Q9FVTu7iuCM" rel="noopener noreferrer"&gt;demo video, 2:39&lt;/a&gt; · &lt;a href="https://github.com/edycutjong/harvestbot/blob/main/bench/RESULTS.md" rel="noopener noreferrer"&gt;bench results&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HIFO belongs in WASM
&lt;/h2&gt;

&lt;p&gt;HIFO selection is a nested loop. Each pass scans every open lot for the highest basis-per-unit, takes it, and prorates the last lot when the sell quantity runs out partway through one. With 64 lots and 9 picks, that's 9 full scans, and every comparison is a cross-multiplication on 256-bit integers (&lt;code&gt;basis_i · qty_b &amp;gt; basis_b · qty_i&lt;/code&gt;, so there's no division and no rounding drift).&lt;/p&gt;

&lt;p&gt;Stylus runs WASM next to the EVM on Arbitrum chains, and WASM costs less per instruction than EVM opcodes. A loop full of comparisons seemed like the textbook case for it. Before writing any code, I estimated about &lt;strong&gt;8×&lt;/strong&gt; cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the chain said: 2.87×
&lt;/h2&gt;

&lt;p&gt;I kept an ABI-identical Solidity twin of the ledger. It has the same integer semantics and the same tie-break (lowest lot id wins). It serves as the benchmark control and as the fallback for chains without Stylus. I deployed both to Robinhood Chain testnet (chain 46630), seeded them with the same lots, and measured &lt;code&gt;computeHarvest&lt;/code&gt; using Arbitrum's own &lt;code&gt;NodeInterface.gasEstimateComponents&lt;/code&gt;. That call splits the L1 calldata share from L2 compute, and L2 compute is where the two engines actually differ.&lt;/p&gt;

&lt;p&gt;At 64 lots it came out to &lt;strong&gt;1,456,905 L2 gas for Solidity and 507,993 for Stylus: 2.87×&lt;/strong&gt;. That's a lot less than 8×, but I reported what the chain measured. The 2.87× went into the README, the demo video, the OG images and an X thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: my Solidity was reading storage in the loop
&lt;/h2&gt;

&lt;p&gt;Two weeks later I put the two inner loops side by side. This is the Solidity twin I shipped (&lt;code&gt;contracts/src/TaxLotLedger.sol&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lot[] storage lots = _lots[owner][asset];
// ...
while (remaining &amp;gt; 0) {
    // select the open, untaken lot with the highest basis/qty
    uint256 best = type(uint256).max;
    for (uint256 i = 0; i &amp;lt; n; i++) {
        if (!lots[i].open || taken[i]) continue;
        if (best == type(uint256).max) {
            best = i;
            continue;
        }
        // lots[i].basis/qty &amp;gt; lots[best].basis/qty  ⇔  basis_i·qty_best &amp;gt; basis_best·qty_i
        if (lots[i].costBasis * lots[best].qty &amp;gt; lots[best].costBasis * lots[i].qty) {
            best = i;
        }
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lots&lt;/code&gt; is a &lt;code&gt;storage&lt;/code&gt; pointer, so every &lt;code&gt;lots[i].open&lt;/code&gt;, &lt;code&gt;.costBasis&lt;/code&gt; and &lt;code&gt;.qty&lt;/code&gt; is a storage read, repeated on each of the 9 selection passes. The Rust ledger (&lt;code&gt;stylus/ledger/src/lib.rs&lt;/code&gt;) reads storage once and runs the rest in memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// one pass over storage → memory&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lots_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.lots&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lots_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lots_s&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lots_s&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MemLot&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lots_s&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"index &amp;lt; len"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MemLot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="py"&gt;.qty&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;basis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="py"&gt;.cost_basis&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="py"&gt;.open&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="nf"&gt;.is_zero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.enumerate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="py"&gt;.open&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;taken&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&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="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// basis_i/qty_i &amp;gt; basis_b/qty_b  ⇔  basis_i·qty_b &amp;gt; basis_b·qty_i&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="py"&gt;.basis&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.qty&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.basis&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="py"&gt;.qty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&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="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I hadn't compared engines. I'd compared two data paths. Part of the 2.87× came from the Rust code having a better structure, and that structure isn't specific to WASM. Solidity can copy to memory too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a fair baseline
&lt;/h2&gt;

&lt;p&gt;I wrote a third ledger, &lt;code&gt;contracts/src/bench/TaxLotLedgerMemCopy.sol&lt;/code&gt;, for the benchmark only. It uses the same selection rule, the same integer semantics and the same tie-break. The only change is the data path: one pass from storage into memory, and every later selection pass reads memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/// @dev One pass over storage → memory (the Rust `mem: Vec&amp;lt;MemLot&amp;gt;`).
function _load(Lot[] storage lots) internal view returns (MemLot[] memory mem) {
    uint256 n = lots.length;
    mem = new MemLot[](n);
    for (uint256 i = 0; i &amp;lt; n; i++) {
        Lot storage s = lots[i];
        mem[i] = MemLot({qty: s.qty, basis: s.costBasis, open: s.open});
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Foundry differential test checks that it picks the same lots and returns the same realized loss as the shipped twin on random books, both before and after &lt;code&gt;realize&lt;/code&gt;. I deployed it next to the other two on chain 46630 and reran all three engines together, with 50 &lt;code&gt;eth_call&lt;/code&gt; samples per cell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three engines, one chain
&lt;/h2&gt;

&lt;p&gt;L2 compute gas for &lt;code&gt;computeHarvest&lt;/code&gt; (HIFO, up to 9 picks, 4 at 8 lots), from &lt;a href="https://github.com/edycutjong/harvestbot/blob/main/bench/RESULTS.md" rel="noopener noreferrer"&gt;&lt;code&gt;bench/RESULTS.md&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Open lots&lt;/th&gt;
&lt;th&gt;Solidity shipped&lt;/th&gt;
&lt;th&gt;Solidity memory-copy&lt;/th&gt;
&lt;th&gt;Stylus&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Fair: memory-copy ÷ Stylus&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;Shipped ÷ Stylus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;132,363&lt;/td&gt;
&lt;td&gt;108,713&lt;/td&gt;
&lt;td&gt;125,807&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.86×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.05×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;352,160&lt;/td&gt;
&lt;td&gt;231,323&lt;/td&gt;
&lt;td&gt;179,982&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.29×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.96×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;720,396&lt;/td&gt;
&lt;td&gt;440,005&lt;/td&gt;
&lt;td&gt;288,999&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.52×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.49×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;1,456,891&lt;/td&gt;
&lt;td&gt;857,559&lt;/td&gt;
&lt;td&gt;507,979&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.69×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.87×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;2,929,924&lt;/td&gt;
&lt;td&gt;1,693,678&lt;/td&gt;
&lt;td&gt;945,964&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.79×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three engines return identical &lt;code&gt;lotIds&lt;/code&gt; and &lt;code&gt;realizedLoss&lt;/code&gt; at every size. The 64-lot row is Maya's scenario, exactly −$3,140.000000 (&lt;code&gt;-3140000000&lt;/code&gt; in 6-dp USD, the same figure &lt;code&gt;forge test&lt;/code&gt; and &lt;code&gt;cargo test&lt;/code&gt; pin).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fair engine-to-engine figure is 1.69× at 64 lots, not 2.87×.&lt;/strong&gt; About 41% of the ratio I first published (2.87× → 1.69×) came from my Solidity baseline re-reading storage, not from WASM. The 2.87× is still accurate for the Solidity twin I shipped. It just isn't the gap between the engines.&lt;/p&gt;

&lt;p&gt;The repo's README, JUDGE.md and DEMO.md now give the fair figure next to every mention of 2.87×, and JUDGE.md has a dated correction entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Stylus still loses, and where it wins
&lt;/h2&gt;

&lt;p&gt;At &lt;strong&gt;8 lots, the memory-copy Solidity ledger beats Stylus (0.86×)&lt;/strong&gt;. The Stylus program is uncached on this testnet, so every call pays a fixed WASM initialisation cost, and at 8 lots there isn't enough loop to make up for it. Caching the program (&lt;code&gt;cargo stylus cache bid&lt;/code&gt;) would remove that cost, but Robinhood Chain testnet has no ArbOS cache manager yet: &lt;code&gt;ArbWasmCache.allCacheManagers()&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt; on chain 46630. So these are worst-case Stylus numbers.&lt;/p&gt;

&lt;p&gt;Past the fixed cost, the fair ratio goes 1.29× → 1.52× → 1.69× → 1.79× as the book grows. All three engines pay the same cold storage read per lot. Stylus only wins on the comparison loop, and that loop grows with the number of lots times the number of picks. A tax-lot ledger does grow: Maya's 64 lots turn into hundreds after a few years of recurring buys. In this use case the advantage grows with the portfolio. For small books, I'd write it in Solidity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Read-only, no wallet. This asks the deployed Stylus ledger for the next HIFO picks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cast call 0xEff7B46049fC677F58264e0ebb19dF1a39195a21 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'computeHarvest(address,address,uint256,uint256)(uint64[],int256)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  0x72cd3cB98A5d9B830b386EeBA7B2340132Ba557b 0x5884aD2f920c162CFBbACc88C9C51AA75eC09E02 638501157098660213 412300000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rpc-url&lt;/span&gt; https://rpc.testnet.chain.robinhood.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full three-engine bench needs a funded testnet key, because it seeds the three ledgers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--recurse-submodules&lt;/span&gt; https://github.com/edycutjong/harvestbot &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;harvestbot
&lt;span class="nb"&gt;cd &lt;/span&gt;contracts &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; forge &lt;span class="nb"&gt;test&lt;/span&gt;              &lt;span class="c"&gt;# 91 tests, incl. the memory-copy differential&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../stylus/ledger &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cargo &lt;span class="nb"&gt;test&lt;/span&gt;       &lt;span class="c"&gt;# 6 native tests on the WASM engine&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../.. &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;RPC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="nv"&gt;PRIVATE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... python3 scripts/bench.py
&lt;span class="c"&gt;# --components-only refreshes the gas split without re-seeding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bench ledger addresses are in &lt;code&gt;deployments/46630-bench.json&lt;/code&gt;, and the raw numbers, including p50/p95 latency, are in &lt;code&gt;bench/results.json&lt;/code&gt;. Latency is &lt;code&gt;eth_call&lt;/code&gt; wall-clock through the public RPC (1181.9–1215.2 ms p50 across every engine and size). It's bound by the network, not the engine, so I don't claim anything from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testnet only.&lt;/strong&gt; Robinhood Chain mainnet exists; I didn't deploy there (gas budget, unaudited code).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uncached Stylus.&lt;/strong&gt; Caching would remove the init floor. I haven't measured how much of the 8-lot gap that closes, so I don't claim it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mocks, labeled.&lt;/strong&gt; The oracle mark, the swap venue and the bond's USDC are MOCK contracts with MOCK in their names. The assets are real Robinhood testnet Stock Tokens from the faucet. The bench ledgers use synthetic asset keys and never touch a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One workload.&lt;/strong&gt; This measures HIFO selection. A different access pattern (few comparisons, many writes) would give different numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The benchmark control is mine.&lt;/strong&gt; Someone better at gas golfing could probably narrow the gap further. The memory-copy ledger is the best Solidity I wrote for it, not the best Solidity possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you benchmark Stylus against Solidity, make sure both sides use the same data path first. My first number compared WASM plus memory against EVM plus repeated storage reads, and it overstated the engine gap by about 41%. The corrected number is smaller, but it measures only the engine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://harvestbot.edycu.dev" rel="noopener noreferrer"&gt;Live site&lt;/a&gt; · &lt;a href="https://harvestbot.edycu.dev/verify/" rel="noopener noreferrer"&gt;/verify&lt;/a&gt; · &lt;a href="https://harvestbot.edycu.dev/judge/" rel="noopener noreferrer"&gt;/judge&lt;/a&gt; · &lt;a href="https://github.com/edycutjong/harvestbot" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; · &lt;a href="https://youtu.be/Q9FVTu7iuCM" rel="noopener noreferrer"&gt;Demo video&lt;/a&gt; · &lt;a href="https://github.com/edycutjong/harvestbot/blob/main/bench/RESULTS.md" rel="noopener noreferrer"&gt;bench/RESULTS.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've measured Stylus with a cached program, I'd like to see how your numbers compare.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>rust</category>
      <category>solidity</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Your passkey can be an encryption address. The domain is the key.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:45:33 +0000</pubDate>
      <link>https://dev.to/edycutjong/your-passkey-can-be-an-encryption-address-the-domain-is-the-key-1gfo</link>
      <guid>https://dev.to/edycutjong/your-passkey-can-be-an-encryption-address-the-domain-is-the-key-1gfo</guid>
      <description>&lt;p&gt;There is still no simple way to encrypt something to a person. You can encrypt to a server, or to someone you have already swapped keys with, but not to "Maya, whoever and wherever she is."&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/edycutjong/letterlock" rel="noopener noreferrer"&gt;Letterlock&lt;/a&gt; to try: the passkey you already have becomes an &lt;strong&gt;encryption address&lt;/strong&gt;. Your device derives an X25519 key from the passkey, publishes only the public half to a directory contract on Monad, and from then on any app or agent can look your key up with one read and seal data only your passkey opens. It is live on Monad mainnet (&lt;a href="https://youtu.be/2tx9jVs7tbY" rel="noopener noreferrer"&gt;2:32 demo with a real passkey&lt;/a&gt;, &lt;a href="https://app.letterlock.edycu.dev/judge" rel="noopener noreferrer"&gt;try it in two minutes, no wallet&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This post is about the one design decision that matters more than the cryptography: &lt;strong&gt;the domain your passkey is bound to is, in effect, the key.&lt;/strong&gt; I got it wrong in the first release and had to move it.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a passkey to a key pair
&lt;/h2&gt;

&lt;p&gt;WebAuthn's PRF extension lets a passkey act as a keyed hash: give it a 32-byte salt, it returns a 32-byte secret that only that credential can produce. Run that through HKDF and you have an X25519 private key. This is the derivation, straight from the SDK:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prfSaltFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;checkEpoch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;epoch&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;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`letterlock/hpke/v1/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;epoch&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="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyPairFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prfOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&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="nx"&gt;EncryptionKeyPair&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prfOutput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LetterlockError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PRF_UNSUPPORTED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`PRF output must be 32 bytes, got &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prfOutput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;secretKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prfOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;letterlock/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x25519&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The salt is Letterlock's own namespace, separate from the salt &lt;a href="https://mera.category.xyz" rel="noopener noreferrer"&gt;mera&lt;/a&gt; uses to derive the same passkey's EVM account, so one passkey yields an encryption key &lt;em&gt;and&lt;/em&gt; the account that publishes it, and neither reveals the other. Rotation is &lt;code&gt;epoch + 1&lt;/code&gt;: a new salt, an unrelated key, and every old epoch can still be re-derived to open old letters. Nothing secret is ever stored; the key is re-derived on every open.&lt;/p&gt;

&lt;p&gt;Sealing needs none of this. A sender resolves your public key and uses HPKE (RFC 9180):&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;letterlock&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;letterlock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;letterlock&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;monad&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;ll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent:10260&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// one keyOf / keyOfAgent read&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&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;ll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sealTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent:10260&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the dentist moved to Thursday 10:40&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolve plus seal against Monad mainnet measured &lt;strong&gt;p50 28.199 ms, p95 36.122 ms over N = 1,000&lt;/strong&gt; (&lt;code&gt;pnpm bench&lt;/code&gt;, results in the repo). Publishing a first key cost 70,863 gas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall: a PRF output belongs to its rpId
&lt;/h2&gt;

&lt;p&gt;Here is the part that is easy to miss. A passkey is created for a &lt;em&gt;relying party ID&lt;/em&gt;, the rpId, which is a domain. The browser only lets a page use its own domain (or a parent of it) as the rpId, and the PRF output is bound to that rpId. Three consequences follow, and each one shaped the design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same passkey, different domain, different key.&lt;/strong&gt; Derive under &lt;code&gt;localhost&lt;/code&gt; or a preview deploy and you get a key that production can never re-derive. Anything sealed to it is unopenable forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whoever serves the rpId can derive every key.&lt;/strong&gt; If I control the domain, I can serve a page that runs the same passkey ceremony and gets the same PRF output. The rpId's host is the most trusted party in the whole system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opening has to happen on that domain.&lt;/strong&gt; Other apps can seal to you from anywhere, but the open (the passkey ceremony) can only run where the rpId lives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because of (1), the SDK pins one production rpId and refuses to publish a key derived anywhere else:&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;pinned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rpId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;LETTERLOCK_RP_ID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsafeAllowAnyRpId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LetterlockError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INPUT_INVALID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: rpId "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rpId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is not the production rpId "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LETTERLOCK_RP_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;". A key derived under another rpId can never be re-derived in production, so every note sealed to it would be unopenable`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A key also has to say where it was derived: one rebuilt from its public fields is refused, because it could have come from any passkey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I got it wrong
&lt;/h2&gt;

&lt;p&gt;Version 0.1.0 pinned &lt;code&gt;letterlock-app.vercel.app&lt;/code&gt;. It worked, every test passed, and it was a mistake because of consequence (2): a &lt;code&gt;*.vercel.app&lt;/code&gt; name is a Vercel &lt;strong&gt;project name&lt;/strong&gt;, held only while that project exists. Delete the project, someone else claims the name, and they can serve a page there that derives every Letterlock key ever made.&lt;/p&gt;

&lt;p&gt;Version 0.1.1 moved the pin to &lt;code&gt;app.letterlock.edycu.dev&lt;/code&gt;, a subdomain of a domain I registered:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LETTERLOCK_RP_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app.letterlock.edycu.dev&lt;/span&gt;&lt;span class="dl"&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 old host now answers every request for the app with a 308 to the new one, so no new key is made under the old rpId. Moving was cheap only because it happened early: the only keys derived under the old rpId were the app's own live-check test keys, made with a virtual authenticator and deleted after each run. Had anyone real been using it, their old letters would have become unopenable at the new address. That is the general rule: &lt;strong&gt;pick the rpId as if it were a root key, before the first user, on a domain you will keep renewing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot do (from the README's limits)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opening happens on the Letterlock app.&lt;/strong&gt; It is consequence (3): other apps seal to you from anywhere, but the passkey ceremony that opens a letter runs on the pinned rpId.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whoever serves &lt;code&gt;app.letterlock.edycu.dev&lt;/code&gt; can derive every key.&lt;/strong&gt; It is on my own domain now, which has to stay registered and pointed at the app for as long as keys derived under it are in use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone can seal to anyone.&lt;/strong&gt; HPKE base mode is anonymous; no letter names a sender, and the app never shows one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No recovery.&lt;/strong&gt; If every synced copy of the passkey is lost, so is the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-device depends on the platform.&lt;/strong&gt; By the WebAuthn spec a synced passkey returns the same PRF output for the same salt. Apple developers have reported otherwise in one direction and across some OS versions (&lt;a href="https://developer.apple.com/forums/thread/822523" rel="noopener noreferrer"&gt;forum thread&lt;/a&gt;), so treat cross-device opening as something to verify on your devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm is one release behind.&lt;/strong&gt; npm still serves 0.1.0, the version with the old pin; until the next release lands, use the SDK from the repo.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://app.letterlock.edycu.dev/judge" rel="noopener noreferrer"&gt;Two-minute judge path&lt;/a&gt;: create an address with your passkey, ask the reference ERC-8004 agent to seal you a note, open it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://youtu.be/2tx9jVs7tbY" rel="noopener noreferrer"&gt;Demo video, 2:32&lt;/a&gt;, recorded with a real passkey on Monad mainnet.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/edycutjong/letterlock" rel="noopener noreferrer"&gt;Code, MIT&lt;/a&gt;: the contract, the SDK and CLI, the app, the agent, and the benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building an agent or an app that keeps something about people, I would like to hear whether "seal to a person with one read" fits what you are doing.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>passkeys</category>
      <category>security</category>
      <category>cryptography</category>
    </item>
    <item>
      <title>My voice app's one job was to take back a wrong sentence. It was taking back sentences it never said.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Tue, 29 Sep 2026 10:31:18 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-voice-apps-one-job-was-to-take-back-a-wrong-sentence-it-was-taking-back-sentences-it-never-52cn</link>
      <guid>https://dev.to/edycutjong/my-voice-apps-one-job-was-to-take-back-a-wrong-sentence-it-was-taking-back-sentences-it-never-52cn</guid>
      <description>&lt;p&gt;Ray is 68 and five days home after a hip replacement. He asks his assistant whether he can put weight on the new hip. Halfway through the answer, his physio changes the plan. &lt;a href="https://github.com/edycutjong/unsay" rel="noopener noreferrer"&gt;Unsay&lt;/a&gt; exists for that moment: the assistant stops mid-sentence and says &lt;em&gt;"Wait — don't do that. What I just told you is out of date."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Its one job is to take back a wrong sentence. When I had the code audited, it turned out it was also taking back sentences it had never said.&lt;/p&gt;

&lt;p&gt;This post covers the four ways the retraction was wrong, and what fixing each one taught me about MCP resource subscriptions. The repo is public, with every fix below in its own commit. Try it live at &lt;a href="https://api.unsay.edycu.dev/judge" rel="noopener noreferrer"&gt;api.unsay.edycu.dev/judge&lt;/a&gt;, and see the hash-chain proof at &lt;a href="https://api.unsay.edycu.dev/verify" rel="noopener noreferrer"&gt;/verify&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Unsay is an MCP server (spec 2025-11-25, Streamable HTTP). The care plan is not a PDF: each instruction is a versioned MCP resource such as &lt;code&gt;care://ray/weight_bearing&lt;/code&gt;. A voice host subscribes with &lt;code&gt;resources/subscribe&lt;/code&gt;. When a clinician publishes a signed revision, the server sends &lt;code&gt;notifications/resources/updated&lt;/code&gt;. The host stops, re-reads the resource, and finds a server-rendered sentence in &lt;code&gt;_meta["unsay/retraction"]&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// 1 — the stop, before anything else.&lt;/span&gt;
  &lt;span class="s2"&gt;`Wait — don’t do that. What I just told you is out of date.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// 2 — the sentence being withdrawn, so there is one instruction to drop.&lt;/span&gt;
  &lt;span class="s2"&gt;`I said &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;// …and the one that replaces it, with who changed it and when.&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; changed it &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is the safety property. A frightened person acts on the first clause, so the stop comes before any metadata.&lt;/p&gt;

&lt;p&gt;For hosts that can't subscribe, there's a fallback tool, &lt;code&gt;whats_changed&lt;/code&gt;. Everything below is about what &lt;code&gt;prev&lt;/code&gt; should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong #1: "the previous version" is not "what you said"
&lt;/h2&gt;

&lt;p&gt;Here is the original line:&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;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version &lt;em&gt;n − 1&lt;/em&gt; is what the &lt;strong&gt;chain&lt;/strong&gt; said before. It is not what &lt;strong&gt;this listener&lt;/strong&gt; heard. Those only coincide in a demo.&lt;/p&gt;

&lt;p&gt;The seeded record starts at v2, so the very first read of &lt;code&gt;care://ray/weight_bearing&lt;/code&gt; already carried:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wait — don't do that. What I just told you is out of date. I said "No weight through the operated leg. Transfers with the frame only." …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing had been said yet. And the proof file I had committed as evidence (&lt;code&gt;docs/proof/live_run.jsonl&lt;/code&gt;) recorded exactly that sentence on a first read. The receipt documented the bug.&lt;/p&gt;

&lt;p&gt;The resume case was worse. The host reads v2, the connection drops, v3 and v4 land, and the host re-reads. The retraction then quoted v3, a sentence the listener never heard.&lt;/p&gt;

&lt;p&gt;The fix is a map per session from resource to the version this session was last served:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uriFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audience&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;lastHeard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heard&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="nx"&gt;key&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;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;superseded&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lastHeard&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lastHeard&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastHeard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;superseded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;heard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no retraction on a first read, and none for an old version someone asked for by URI. That URI now says &lt;code&gt;[SUPERSEDED — v1, replaced by v2; …]&lt;/code&gt; in its text, because annotations don't survive a read in every host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong #2: "since when you last spoke" is the wrong cursor
&lt;/h2&gt;

&lt;p&gt;The fallback tool took a &lt;code&gt;since&lt;/code&gt; timestamp, and I told hosts to pass "when you last spoke about the plan". That sounds right, and it's wrong twice over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A correction that lands &lt;strong&gt;while the host is still talking&lt;/strong&gt; is written &lt;em&gt;before&lt;/em&gt; the host finishes. So &lt;code&gt;writtenAt &amp;gt; endOfAnswer&lt;/code&gt; filters out exactly the event the product exists for.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;since&lt;/code&gt; came from the host's clock and was compared against the server's &lt;code&gt;writtenAt&lt;/code&gt;. A host clock running a minute fast returned &lt;code&gt;changed: []&lt;/code&gt;, with no error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is the same one every sync API eventually makes: the server hands out the cursor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server clock at the call, i.e. before the host speaks. Handed back as the next&lt;/span&gt;
&lt;span class="c1"&gt;// `since`, it catches a correction written while the host was still talking.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;asOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;since&lt;/code&gt; ahead of the server clock is now an error, not a silent "nothing changed". The reviewer then found one more hole: a write stamped in the &lt;em&gt;same millisecond&lt;/em&gt; as &lt;code&gt;asOf&lt;/code&gt; was never returned, because the filter was &lt;code&gt;&amp;gt;&lt;/code&gt;. It's &lt;code&gt;&amp;gt;=&lt;/code&gt; now. A record returned twice carries no second retraction, which brings us to #3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong #3: a re-confirmation is not a correction
&lt;/h2&gt;

&lt;p&gt;The anticoagulant record ships stale on purpose. When the GP confirms it with a new review date, the words don't change, only &lt;code&gt;staleAfter&lt;/code&gt; does. That is a real revision, so a notification fires, and the host dutifully stopped mid-word to say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I said "Rivaroxaban 10mg once daily. Stop date: 25 September 2026." Dr Mensah, GP changed it just now: "Rivaroxaban 10mg once daily. Stop date: 25 September 2026."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fallback path already had a same-words guard. The subscribed path didn't. Both now share one rule:&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="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Agent Skill tells the host what a missing retraction means: &lt;em&gt;the words you were saying did not change: carry on.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong #4: the gloss turned "do NOT" into "you can"
&lt;/h2&gt;

&lt;p&gt;Clinical phrases get a plain-English gloss. "Full weight-bearing as tolerated" becomes "you can put as much weight through that leg as is comfortable". The glossary matched on text alone, so this instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do NOT progress to full weight-bearing as tolerated until the X-ray is reviewed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;was helpfully explained as permission.&lt;/p&gt;

&lt;p&gt;My first fix was a list of negation words. The review came back with four sentences the list couldn't see: "&lt;strong&gt;Stop&lt;/strong&gt; full weight-bearing…", "&lt;strong&gt;Wait for&lt;/strong&gt; the X-ray &lt;strong&gt;before&lt;/strong&gt;…", "…&lt;strong&gt;is cancelled&lt;/strong&gt;", "&lt;strong&gt;Hold off&lt;/strong&gt;…". No word list is ever complete, but position is checkable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A weight-bearing gloss is worded as permission, and no word list catches every&lt;/span&gt;
&lt;span class="c1"&gt;// negation ("Hold off…", "…is cancelled"). Position does: gloss only a value that&lt;/span&gt;
&lt;span class="c1"&gt;// OPENS with the term, with no action word over it. Definitions are unaffected.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;WEIGHT_BEARING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;opens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ACTION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gloss&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gloss&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When in doubt, the sentence is quoted verbatim with no gloss. A missing explanation is an inconvenience. A wrong one is a fall.&lt;/p&gt;

&lt;h2&gt;
  
  
  How these were found
&lt;/h2&gt;

&lt;p&gt;I gave another model read access to the repository and asked it for an architecture audit. The rule for every finding it raised: verify it against the code before touching anything, reject it with a &lt;code&gt;file:line&lt;/code&gt; if it's wrong, and commit the fix with a regression test that fails first if it's right.&lt;/p&gt;

&lt;p&gt;Three rounds produced 24 fixes. The four above are the ones that changed what the product &lt;em&gt;says&lt;/em&gt;. Most of the rest were in the &lt;code&gt;/write&lt;/code&gt; endpoint: it accepted &lt;code&gt;"Assistant"&lt;/code&gt; as an audience and spoke it, a captured signed request could be replayed to roll back a newer correction, and a subscribe to &lt;code&gt;…/weight_bearing/&lt;/code&gt; returned &lt;code&gt;{}&lt;/code&gt; and then never fired.&lt;/p&gt;

&lt;p&gt;The part I'd repeat is the verification step, not the model. Every one of these bugs passed a green suite. The suite tested what I thought the product did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, with their caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;344 tests&lt;/strong&gt;, &lt;code&gt;tsc --strict&lt;/code&gt; clean, plus 34 safety assertions run against a live server (&lt;code&gt;npm run verify&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local server path:&lt;/strong&gt; signed write → notification → re-read, p95 &lt;strong&gt;3.0 ms&lt;/strong&gt;, 200/200 inside a 3.4 s speech window. The window is an assumption (12 words at ~150 wpm), not a measurement of Alexa+ speech.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over the public internet&lt;/strong&gt; (client on another continent from the us-west2 server, both network legs paid twice): the committed receipt is 200/200 with p95 &lt;strong&gt;461 ms&lt;/strong&gt;. Two re-runs today gave p95 710 ms and 966 ms, and one run landed 199/200. The server accounts for single-digit milliseconds, so that spread is the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It has never talked to Alexa+ itself.&lt;/strong&gt; Whether Alexa+ declares resource subscriptions is untested. That's why the &lt;code&gt;whats_changed&lt;/code&gt; fallback exists and is exercised end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The patient is fictional, and this is not a medical device.&lt;/strong&gt; The server renders the clinician's own words. It generates no clinical advice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No one outside the project has used it yet.&lt;/strong&gt; If you work with post-operative patients, I'd genuinely like to know where this breaks.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Repo: &lt;a href="https://github.com/edycutjong/unsay" rel="noopener noreferrer"&gt;github.com/edycutjong/unsay&lt;/a&gt; (MIT) · Try it: &lt;a href="https://api.unsay.edycu.dev/judge" rel="noopener noreferrer"&gt;api.unsay.edycu.dev/judge&lt;/a&gt; · Demo video: &lt;a href="https://youtu.be/jJM5LWHONDU" rel="noopener noreferrer"&gt;youtu.be/jJM5LWHONDU&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Built for the Amazon Alexa+ hackathon track.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>mcp</category>
      <category>typescript</category>
      <category>ai</category>
    </item>
    <item>
      <title>50,000 property tests passed while my app crowned an impostor</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Fri, 25 Sep 2026 02:26:11 +0000</pubDate>
      <link>https://dev.to/edycutjong/50000-property-tests-passed-while-my-app-crowned-an-impostor-2bbo</link>
      <guid>https://dev.to/edycutjong/50000-property-tests-passed-while-my-app-crowned-an-impostor-2bbo</guid>
      <description>&lt;p&gt;You hear "buy PEPE", type PEPE into your wallet, and get fourteen tokens with the same name and the same frog. Which one do you buy?&lt;/p&gt;

&lt;p&gt;I built a tool to answer that. You type a ticker, and it asks &lt;a href="https://nansen.ai" rel="noopener noreferrer"&gt;Nansen&lt;/a&gt;'s API for every token with that name across chains. Then it checks who actually holds and trades each one: labelled Smart Money, whales, top-PnL wallets, exchange flow, tagged top holders. Exactly one card turns green, or the tool abstains.&lt;/p&gt;

&lt;p&gt;Last week it crowned a token that its own scorer had flagged as an impostor. My property test ran 50,000 generated cases on that exact decision, and they all passed. While writing this post I found out why, and the answer was worse than I expected.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://whichone.edycu.dev" rel="noopener noreferrer"&gt;https://whichone.edycu.dev&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/whichone" rel="noopener noreferrer"&gt;https://github.com/edycutjong/whichone&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two rules in two files
&lt;/h2&gt;

&lt;p&gt;The engine judges each candidate twice.&lt;/p&gt;

&lt;p&gt;The impostor rule lives in &lt;code&gt;score.ts&lt;/code&gt;. A candidate is an impostor when nothing labelled has touched it, it has no meaningful exchange flow, and it is either brand new or barely held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/score.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;impostor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labelledWallets&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;exchMag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalHolders&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalHolders&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;impostor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IMPOSTOR: nothing labelled has ever touched it&lt;/span&gt;&lt;span class="dl"&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 crown rule lives in &lt;code&gt;verdict.ts&lt;/code&gt;. It decides whether &lt;code&gt;ranked[0]&lt;/code&gt; gets the green card or the tool says "none of these looks real". This is how it read before the fix (abridged: I left out the guards for zero results, unscorable chains and failed lookups, which didn't change):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/verdict.ts, before commit 3b5f16f (abridged)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Scored&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;sameNameCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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="nl"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Scored&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;abstainReason&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;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... guards: 0 results, unscorable, every lookup failed, holders lookup failed ... */&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ABSTAIN_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labelledWallets&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recognisedHolders&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_RECOGNISED_TO_CROWN&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;abstainReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none of these looks real — nothing labelled has touched any of them&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;best.impostor&lt;/code&gt; isn't read anywhere in it. The score module had already answered the question, and the crown rule never asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fix for one bug opened the door
&lt;/h2&gt;

&lt;p&gt;Three days earlier, live testing turned up a different failure. &lt;code&gt;SHIB2&lt;/code&gt; was a dead token, about $36K market cap, three years old, with zero labelled wallets, and the tool crowned it REAL. The cause was the holders tiebreak. Nansen's &lt;code&gt;tgm/holders&lt;/code&gt; returns an &lt;code&gt;address_label&lt;/code&gt; for each top holder, and SHIB2's top holders included a &lt;code&gt;UniswapV2&lt;/code&gt; pool and a &lt;code&gt;SHIB2 Token Deployer&lt;/code&gt;. Every token has a pool and a deployer, so those labels prove nothing.&lt;/p&gt;

&lt;p&gt;That fix excluded structural tags (pools, deployers, ENS/SNS names, burn addresses, the contract itself). It also raised the bar for a candidate with zero labelled wallets: it can be crowned only if at least 3 of its top 20 holders carry a wealth or activity tag, like "Token Millionaire" or "High Activity". That is &lt;code&gt;MIN_RECOGNISED_TO_CROWN = 3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I added that clause on purpose as an escape hatch. Nansen's labels are uneven, and some real tokens have no labelled wallets in a 7-day window but plenty of tagged holders.&lt;/p&gt;

&lt;p&gt;On 2026-09-19, &lt;code&gt;PEPEGA&lt;/code&gt; got through that hatch. It had one candidate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 labelled wallets in 7 days&lt;/li&gt;
&lt;li&gt;no meaningful exchange flow&lt;/li&gt;
&lt;li&gt;283 holders&lt;/li&gt;
&lt;li&gt;7 of its top 20 holders wealth-tagged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;283 holders is under 500, so &lt;code&gt;score()&lt;/code&gt; set &lt;code&gt;impostor: true&lt;/code&gt;. The score cleared the abstain threshold and 7 tagged holders beat the bar of 3, so &lt;code&gt;crown()&lt;/code&gt; crowned it anyway. The fix commit put it this way: "a green card and an IMPOSTOR badge at once."&lt;/p&gt;

&lt;p&gt;The fix takes two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/verdict.ts, after commit 3b5f16f&lt;/span&gt;
  &lt;span class="c1"&gt;// a card cannot be "this is the one" and IMPOSTOR at once: when the best candidate trips the impostor rule, abstain&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impostor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;abstainReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none of these looks real — the best candidate trips the impostor rule&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the CLI output now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PEPEGA — 1 same-name token on Nansen

✖ ethereum   0x9634…c879    PEPEGA   4.68   0 labelled wallets in 7 days · no meaningful exchange flow · 283 holders IMPOSTOR

no winner — none of these looks real — the best candidate trips the impostor rule
7 credits · 4 calls (0 cached) · 2.1s · verdict 47b4ac4fc907
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why 50,000 property cases missed it, part one
&lt;/h2&gt;

&lt;p&gt;Two days before PEPEGA, I had added property-based tests with fast-check. The crown-rule property generates random lists of scored candidates, ranks them, calls &lt;code&gt;crown()&lt;/code&gt;, and checks every winner. It runs 10,000 times for each of five properties, which is where the 50,000 comes from. Everything was green.&lt;/p&gt;

&lt;p&gt;This is what the property asserted about a winner before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/test/property.test.ts, before commit 3b5f16f (the winner check only)&lt;/span&gt;
&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scorable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unchecked&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;ABSTAIN_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labelledWallets&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recognisedHolders&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_RECOGNISED_TO_CROWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;abstainReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line restates one of &lt;code&gt;crown()&lt;/code&gt;'s own &lt;code&gt;if&lt;/code&gt; statements, so the property only checked that the function did what the function said. The rule I had missed wasn't in &lt;code&gt;crown()&lt;/code&gt; at all. It was a rule about what the user sees: &lt;strong&gt;a card is never green and IMPOSTOR at once&lt;/strong&gt;. That rule crosses two modules, and my tests were organized by module.&lt;/p&gt;

&lt;p&gt;The fix commit added &lt;code&gt;!winner.impostor &amp;amp;&amp;amp;&lt;/code&gt; to that list. At the time I thought that closed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part two: the generator never made an impostor
&lt;/h2&gt;

&lt;p&gt;While writing this post I wanted to say how fast the new assertion would have caught the bug. So I copied the test's generator into a scratch script, paired it with the pre-fix &lt;code&gt;crown()&lt;/code&gt;, and counted.&lt;/p&gt;

&lt;p&gt;The generator draws each wallet class separately, and the labelled count is their sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/test/property.test.ts (abridged: 4 of the 17 fields)&lt;/span&gt;
&lt;span class="nx"&gt;smartTraderWallets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;whaleWallets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;topPnlWallets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;publicFigureWallets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;labelledWallets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;smartTraderWallets&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;whaleWallets&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topPnlWallets&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicFigureWallets&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a candidate to have zero labelled wallets, all four independent draws have to land on 0. Across five seeded runs of 10,000 lists, about 44,000 candidates per run, the generator produced &lt;strong&gt;zero&lt;/strong&gt; candidates with zero labelled wallets. So it produced zero impostors, and zero cases where the pre-fix &lt;code&gt;crown()&lt;/code&gt; crowned one. Even with the right assertion, the old code passed.&lt;/p&gt;

&lt;p&gt;It is not just the impostor line. Every branch that matters for a zero-labelled token (the SHIB2 bar, the PEPEGA veto, the failed-holders abstain) was sitting in a corner of the input space the generator never visited.&lt;/p&gt;

&lt;p&gt;I changed one thing in the scratch copy, not in the repo. Each wallet count became &lt;code&gt;fc.oneof(fc.constant(0), fc.nat(500))&lt;/code&gt;, so zero comes up about half the time for each class. Then I ran it against the pre-fix &lt;code&gt;crown()&lt;/code&gt; with the &lt;code&gt;!winner.impostor&lt;/code&gt; property. fast-check found a counterexample on all five seeds, after &lt;strong&gt;47, 53, 71, 109 and 244&lt;/strong&gt; cases.&lt;/p&gt;

&lt;p&gt;Two things went wrong, and fixing either one alone would not have caught the bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The invariant was the implementation restated.&lt;/strong&gt; Write properties about what the output must never do, not about which branches the code takes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The generator had no weight where the bugs were.&lt;/strong&gt; A uniform draw almost never produces the edge case your domain revolves around. Here that case was "zero". If a value drives a branch, put it in the generator on purpose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As of this writing, the repo's generator still has that gap. The PEPEGA regression test pins the actual bug, so the crown rule is covered. The property test just doesn't cover as much of it as "50,000 cases" suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the system
&lt;/h2&gt;

&lt;p&gt;The score is plain arithmetic over four Nansen endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search/general&lt;/code&gt;: 0 credits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tgm/flow-intelligence&lt;/code&gt; and &lt;code&gt;tgm/token-information&lt;/code&gt;: 1 credit each, for up to 8 candidates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tgm/holders&lt;/code&gt;: 5 credits, for the top two finalists only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Market cap, volume and search rank are left out of the score, because those are what an impostor can buy.&lt;/p&gt;

&lt;p&gt;The repo includes a benchmark: 12 queries × 2 cold runs against the live API, on 2026-09-16. Cold p50 was &lt;strong&gt;3.6 s&lt;/strong&gt;, p95 &lt;strong&gt;7.2 s&lt;/strong&gt;, warm p50 &lt;strong&gt;3 ms&lt;/strong&gt;. Verdicts cost &lt;strong&gt;18.6 credits&lt;/strong&gt; on average and 26 at most. &lt;code&gt;npm run verify&lt;/code&gt; replays twelve recorded verdicts offline with the same decision hash. It needs no API access and no network. The web page streams every Nansen call into a side rail as it fires, with the endpoint, credits, latency and a short hash of each response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search/general&lt;/code&gt; decides the candidate set. The tool can't warn about an impostor Nansen hasn't indexed.&lt;/li&gt;
&lt;li&gt;Label coverage is uneven across chains. A real token on a thinly labelled chain can lose to a bridged copy on a busier one, which is why there's a chain filter.&lt;/li&gt;
&lt;li&gt;Flow data covers a 7-day window, so a real but dormant token can look quiet.&lt;/li&gt;
&lt;li&gt;The zero-labelled escape hatch is still there. On 2026-09-16, &lt;code&gt;AI16Z&lt;/code&gt; and &lt;code&gt;PEPE UNCHAINED&lt;/code&gt; were crowned on wealth-tagged holders alone. The card says "0 labelled wallets" so you can see the weakness, but it's a weaker verdict than a green card backed by 90 labelled wallets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DOGE&lt;/code&gt; crowns a Solana meme DOGE, because native DOGE has no contract to compare against.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USDC&lt;/code&gt; is the slow outlier, about 15 s cold. Nansen times out on some of its lookups, and the call drawer shows each timeout.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://whichone.edycu.dev" rel="noopener noreferrer"&gt;https://whichone.edycu.dev&lt;/a&gt;&lt;/strong&gt;. Try &lt;code&gt;PEPE&lt;/code&gt;, then &lt;code&gt;PEPEGA&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/whichone" rel="noopener noreferrer"&gt;https://github.com/edycutjong/whichone&lt;/a&gt;&lt;/strong&gt;. &lt;code&gt;npm run whichone -- PEPE --explain&lt;/code&gt; prints every term of the score (you need your own Nansen key).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you write property tests, count how often your generator actually produces the values your branches depend on. I hadn't, until this post.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>typescript</category>
      <category>testing</category>
      <category>crypto</category>
    </item>
  </channel>
</rss>
