<?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: MORINAGA</title>
    <description>The latest articles on DEV Community by MORINAGA (@morinaga).</description>
    <link>https://dev.to/morinaga</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%2F3907455%2F8e6a4a13-bec8-4ec0-bc2d-ec192b7880f8.png</url>
      <title>DEV Community: MORINAGA</title>
      <link>https://dev.to/morinaga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/morinaga"/>
    <language>en</language>
    <item>
      <title>How three automated writers confused our content-publish gate with one shared label</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 14 Sep 2026 11:06:47 +0000</pubDate>
      <link>https://dev.to/morinaga/how-three-automated-writers-confused-our-content-publish-gate-with-one-shared-label-2lc5</link>
      <guid>https://dev.to/morinaga/how-three-automated-writers-confused-our-content-publish-gate-with-one-shared-label-2lc5</guid>
      <description>&lt;p&gt;&lt;code&gt;scripts/polish.py&lt;/code&gt; picks summaries from a deterministic pool of template sentences, seeded by the md5 hash of each model slug. It makes no LLM call. The label it stamps on every row it writes is now &lt;code&gt;"polish-py-template"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Until September 2026, it stamped &lt;code&gt;"claude-routine-polish"&lt;/code&gt;. So did &lt;code&gt;scripts/humanize-aiappdex.mjs&lt;/code&gt;. So did the Claude Code Routine that generates curated model entries via API.&lt;/p&gt;

&lt;p&gt;Three generators, one label. The curation gate in &lt;code&gt;apps/ai-tools/src/lib/curation.ts&lt;/code&gt; couldn't tell any of them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gate checks
&lt;/h2&gt;

&lt;p&gt;The publishable check is a single set membership 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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHABLE_MODEL_USED_LIST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-routine&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;human-edited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHABLE_MODEL_USED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlySet&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PUBLISHABLE_MODEL_USED_LIST&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model_used&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PUBLISHABLE_MODEL_USED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model_used&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unpublished:model_used&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;&lt;code&gt;"claude-routine-polish"&lt;/code&gt; is not in that set. When all three generators stamped it, the gate was blocking all of them — including the genuinely LLM-generated rows. Template content and Claude-drafted content were both unpublished, both indistinguishable, both labelled the same way.&lt;/p&gt;

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

&lt;p&gt;Each produces meaningfully different output but all arrived at the same label:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;scripts/polish.py&lt;/code&gt;&lt;/strong&gt;: Pure template. An md5 seed selects from a pool of summary sentences with &lt;code&gt;{n}&lt;/code&gt; (download count) and &lt;code&gt;{a}&lt;/code&gt; (author) placeholders filled in. Consistent structure, no variation beyond the placeholders. Makes no API call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;scripts/humanize-aiappdex.mjs&lt;/code&gt;&lt;/strong&gt;: Builds sentences from &lt;a href="https://huggingface.co/docs/hub/api#get-apimodelsmodelid" rel="noopener noreferrer"&gt;HuggingFace model metadata&lt;/a&gt;: pipeline tag, language list, license. Also deterministic, also no LLM call. The header of &lt;code&gt;scripts/relabel-aiappdex-provenance.py&lt;/code&gt; describes its shape as "one of its 6 openers AND one of its 8 caveats" — a fixed opener-caveat sentence structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Claude Code Routine&lt;/strong&gt;: Calls Claude Haiku 4.5 via the Anthropic API. Produces entries with genuine prose variation, specific technical context, and actual model-level detail that the template pools don't have access to.&lt;/p&gt;

&lt;p&gt;These three are not equivalent. A curation gate designed to ensure pages are built only from the third class was working from a label that didn't encode which class any row belonged to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the relabeling script found
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scripts/relabel-aiappdex-provenance.py&lt;/code&gt; ran a template-matching pass over &lt;code&gt;apps/ai-tools/src/data/models.json&lt;/code&gt; to assign the correct label per row after the fact. It produced four classes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;claude-routine&lt;/code&gt;&lt;/strong&gt; — summary didn't match any known template shape. Residual class: likely LLM-generated but not positively identified as such.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;polish-py-template&lt;/code&gt;&lt;/strong&gt; — verbatim match against the &lt;code&gt;scripts/polish.py&lt;/code&gt; sentence pools (with &lt;code&gt;{n}&lt;/code&gt;, &lt;code&gt;{a}&lt;/code&gt;, &lt;code&gt;{ln}&lt;/code&gt; wildcarded).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadata-derived&lt;/code&gt;&lt;/strong&gt; — matched the &lt;code&gt;humanize-aiappdex.mjs&lt;/code&gt; opener-plus-caveat structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fallback-template&lt;/code&gt;&lt;/strong&gt; — the ETL's own fallback sentence, written by the ETL when no generator ran.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script's own header is explicit about one caveat: &lt;code&gt;"claude-routine"&lt;/code&gt; is the residual class, not a positive identification. A row lands there because the script couldn't attribute it to any known template pattern. An older revision of one of the template generators whose shape wasn't encoded in the classifier would also land there. The agreement between the relabeling script's output and an earlier generation audit isn't independent corroboration — it's the same template-matching method applied to the same corpus, so reproducibility was guaranteed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looked like from the output side
&lt;/h2&gt;

&lt;p&gt;The publish gate gates page generation, not data ingestion. All rows were stored in &lt;code&gt;models.json&lt;/code&gt; regardless of &lt;code&gt;model_used&lt;/code&gt;. But only rows labeled &lt;code&gt;"claude-routine"&lt;/code&gt; or &lt;code&gt;"human-edited"&lt;/code&gt; became pages.&lt;/p&gt;

&lt;p&gt;During the period when all three generators stamped &lt;code&gt;"claude-routine-polish"&lt;/code&gt;, the rows that should have been published as pages weren't. Astro's build gate (&lt;code&gt;apps/ai-tools/src/lib/curation.ts&lt;/code&gt; is called at build time) filtered them out silently. There was no build error — the build succeeded, it just produced fewer pages than it should have.&lt;/p&gt;

&lt;p&gt;Silent misses of this kind are harder to notice than failures. A build error stops everything. A correctly behaving gate returning the wrong answer from a wrong label produces a running site with missing pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is obvious in hindsight
&lt;/h2&gt;

&lt;p&gt;Unique label per generator, set at write time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/polish.py&lt;/code&gt; now stamps &lt;code&gt;"polish-py-template"&lt;/code&gt;. &lt;code&gt;scripts/humanize-aiappdex.mjs&lt;/code&gt; stamps &lt;code&gt;"metadata-derived"&lt;/code&gt;. The Claude Routine stamps &lt;code&gt;"claude-routine"&lt;/code&gt;. The gate's publishable set matches what the generators actually write.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/what-i-learned-about-detecting-template-residue-in-ai-generated-directory-content-5b6j"&gt;template residue detection work&lt;/a&gt; I wrote about earlier ran the opposite direction: infer provenance from content text after the fact. That article found the same tradeoff — text-based classification at scale is expensive and fragile compared to asserting the class at write time. Labels are cheap. Classifiers built to recover missing labels are not.&lt;/p&gt;

&lt;p&gt;The broader lesson: any field that drives automated decisions downstream should carry the correct value at the moment of write. A label that needs a one-off relabeling script to become meaningful is a label that was wrong from the start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Three ways a GitHub ETL can silently delete valid alternatives — and how I fixed each</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 14 Sep 2026 11:06:44 +0000</pubDate>
      <link>https://dev.to/morinaga/three-ways-a-github-etl-can-silently-delete-valid-alternatives-and-how-i-fixed-each-2a5f</link>
      <guid>https://dev.to/morinaga/three-ways-a-github-etl-can-silently-delete-valid-alternatives-and-how-i-fixed-each-2a5f</guid>
      <description>&lt;p&gt;The &lt;a href="https://dev.to/morinaga/i-built-3-programmatic-seo-sites-for-25month-using-claude-haiku-heres-the-full-architecture-3pl8"&gt;OSS alternatives directory&lt;/a&gt; I'm building pairs each SaaS product with its open-source alternatives by fetching live data from GitHub's API. Every night a refresh job runs: it upserts the repos it can reach and deletes the ones that are no longer in the current seed list.&lt;/p&gt;

&lt;p&gt;Deleting stale data is the right default. A repo that disappears from the seed was removed for a reason — the SaaS page it belonged to is gone, or the alternative was curated out. But the delete logic I had originally couldn't distinguish between "no longer in the seed" and "temporarily unreachable." Those are different states that produce the same symptom: the repo is missing from the &lt;code&gt;keep&lt;/code&gt; array the DELETE uses as its NOT IN set.&lt;/p&gt;

&lt;p&gt;I found three distinct ways that deletion was silently wrong. None of them produced an error. All of them would eventually cause data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: An incomplete keep-list from one failed fetch
&lt;/h2&gt;

&lt;p&gt;The refresh loop works like this: for each SaaS entry, fetch each alternative from GitHub, push the successful &lt;code&gt;full_name&lt;/code&gt; values into a &lt;code&gt;keep&lt;/code&gt; array, then run &lt;code&gt;DELETE FROM alternatives WHERE saas_slug = ? AND lower(repo) NOT IN (keep)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If GitHub returns a 403 or 429 for &lt;em&gt;one&lt;/em&gt; of five alternatives, that repo doesn't make it into &lt;code&gt;keep&lt;/code&gt;. The DELETE then removes it from the database even though it's in the seed and the only reason it's missing is a transient API error.&lt;/p&gt;

&lt;p&gt;The next run — if it fetches successfully — re-inserts the entry. So the visible symptom is flickering: an alternative is present one day, absent the next, present again. Hard to notice unless you're watching row counts.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;failed&lt;/code&gt; counter per SaaS slug:&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;failed&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; fetch failure(s) — skipping stale-row prune for this slug`&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;keep&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;0&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;placeholders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keep&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lower(?)&lt;/span&gt;&lt;span class="dl"&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="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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`DELETE FROM alternatives WHERE saas_slug = ? AND lower(repo) NOT IN (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;keep&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;If any fetch in the slug's loop fails, the &lt;code&gt;DELETE NOT IN&lt;/code&gt; prune is skipped entirely for that slug. The entries stay in the database until a clean pass — one where every fetch succeeds — confirms the current ground truth.&lt;/p&gt;

&lt;p&gt;This failure pattern appears in other forms. The &lt;a href="https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo"&gt;94-day detection lag&lt;/a&gt; in one of my pipelines came from a Reddit source that returned 403 and whose catch handler returned an empty array instead of re-throwing. An empty array is not obviously wrong. A 403 that's caught and silenced isn't obviously wrong either. The fix in both cases is identical: distinguish "successfully queried and found nothing" from "query failed, and I don't know what's there."&lt;/p&gt;

&lt;p&gt;I run the same principle in a different domain: a shelf-scanning project running on a Raspberry Pi 3. Rather than alerting on any single scan that detects a gap, the system uses a temporal majority vote — a detection is only &lt;code&gt;confirmed&lt;/code&gt; if it appears in at least 2 of the last 3 scans (&lt;a href="https://dev.to/morinaga/the-three-layers-that-made-my-0844-map-shelf-detector-useful-on-a-pi-3-54oc"&gt;the three post-processing layers are described here&lt;/a&gt;). The GitHub ETL's &lt;code&gt;failed&lt;/code&gt; counter is the same principle: don't commit a DELETE based on a set of observations you know is incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: Seed casing vs GitHub canonical naming
&lt;/h2&gt;

&lt;p&gt;The seed file lists alternatives by GitHub repo path — &lt;code&gt;Requarks/wiki&lt;/code&gt;, &lt;code&gt;calcom/cal.com&lt;/code&gt;, and so on. GitHub's API returns a &lt;code&gt;full_name&lt;/code&gt; that reflects the current canonical spelling, including the current owner and exact casing. The problem is that repos get renamed, owners change, and the seed spelling drifts away from what GitHub considers the authoritative identifier.&lt;/p&gt;

&lt;p&gt;Original code used the seed path as the database primary key. Two bugs followed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate rows&lt;/strong&gt;: A repo appears as &lt;code&gt;calcom/cal.com&lt;/code&gt; in one seed version and differently later. The next run inserts under the new spelling without removing the old row. A 2026-09 audit of the directory pages found 4 pages with duplicate alternative listings from exactly this casing drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-referencing pages&lt;/strong&gt;: Two pages had an alternatives row pointing back to the same product as the page's SaaS entry — a repo ended up in its own product's list. These are in the audit notes as "自己参照 2 頁" (self-reference 2 pages).&lt;/p&gt;

&lt;p&gt;The fix uses &lt;code&gt;r.full_name&lt;/code&gt; from the API response as the database key:&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;canonical&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;full_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// resolved after GitHub rename/transfer&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`INSERT INTO alternatives (saas_slug, repo, ...) VALUES (?, ?, ...)
        ON CONFLICT(saas_slug, repo) DO UPDATE SET
          name = excluded.name, stars = excluded.stars, ...`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canonical&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;// Remove any row stored under a different casing or old name for this repo&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`DELETE FROM alternatives
        WHERE saas_slug = ? AND repo &amp;lt;&amp;gt; ?
          AND (lower(repo) = lower(?) OR lower(repo) = lower(?))`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repoFull&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 second DELETE fires on every successful fetch. It catches the case where the DB held the seed spelling and the API returned the canonical — removing the stale-casing row immediately after writing the canonical one. This is different from the NOT IN prune in failure 1: it's a per-repo cleanup on success, not a per-slug cleanup at the end of a loop.&lt;/p&gt;

&lt;p&gt;The relationship to ON CONFLICT patterns is worth naming. &lt;code&gt;DO UPDATE SET&lt;/code&gt; is correct here because the API's response (current stars, last push date) is fresher than what was in the DB. Using &lt;code&gt;DO NOTHING&lt;/code&gt; instead would mean repos that existed under the old casing would never get their data refreshed. The right strategy depends on which version of the data is authoritative, and the API is authoritative over the seed for live metrics.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/four-github-api-license-values-that-an-oss-directory-cannot-display-verbatim-1e1p"&gt;GitHub API license fields I described earlier&lt;/a&gt; have a similar property: what the API says about a license is authoritative over what I infer from other signals, and the DB should reflect the API's answer, not my seed's guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: Unguarded bulk delete when the seed file shrinks
&lt;/h2&gt;

&lt;p&gt;A separate function, &lt;code&gt;pruneStaleSaas&lt;/code&gt;, handles SaaS-level cleanup: if a product is removed from the seed, its rows (SaaS record, content, all alternatives) should be removed from the database.&lt;/p&gt;

&lt;p&gt;The logic is straightforward: read all SaaS slugs from the DB, filter out those present in the current seed, delete the rest. This works when the seed is intact. When the seed file is accidentally truncated — a merge conflict that ate half the JSON, an editing mistake — the "stale" list becomes most of the database.&lt;/p&gt;

&lt;p&gt;The fix is a ratio guard:&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;MAX_STALE_SAAS_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&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;MAX_STALE_SAAS_FLOOR&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;MAX_STALE_SAAS_FLOOR&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="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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;MAX_STALE_SAAS_RATIO&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;stale&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;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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;stale&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; saas rows not in seed exceeds prune limit &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — skipping prune (check seed-saas.json)`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If more than 10% of DB rows appear stale — or more than 3 rows when the database is small — the prune is skipped and an error is logged. Between runs, the seed almost never loses more than 10% of entries legitimately. A stale count that high is almost certainly a data anomaly.&lt;/p&gt;

&lt;p&gt;The floor of 3 handles early-stage databases: at 5 rows, 10% would be 0, which would fire the guard on a single legitimate removal. The floor ensures small databases can still prune.&lt;/p&gt;

&lt;p&gt;This is a circuit-breaker pattern, not a repair. It stops the damage and logs something visible. The &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;three approaches to silent failure detection&lt;/a&gt; I've written about all share this structure: detect the anomaly, stop, make noise. The code doesn't try to infer whether the seed file is valid — it just checks whether the prune looks plausible given current DB state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared principle
&lt;/h2&gt;

&lt;p&gt;Looking at the three fixes together: &lt;strong&gt;before any DELETE, verify that the input driving the DELETE is trustworthy&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Failure 1: the &lt;code&gt;keep&lt;/code&gt; list must be complete (no failed fetches). If it's not, skip the delete.&lt;/li&gt;
&lt;li&gt;Failure 2: the repo identity must be resolved through the API's canonical name, not the seed's spelling. Use the API answer as the key.&lt;/li&gt;
&lt;li&gt;Failure 3: the "absent from seed" count must be plausible given current DB size. If it's not, skip the bulk prune.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these changed the delete logic itself. They're preconditions: checks on whether the delete instruction is based on trustworthy inputs before it runs.&lt;/p&gt;

&lt;p&gt;A practical rule from this: any DELETE that uses an externally derived set (API results, a seed file, a user-supplied list) should have an explicit check on whether that set is complete before running. If completeness can't be verified, defer to the next run. A stale row persisting an extra night is much cheaper than a valid row vanishing without an error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why not just log errors instead of skipping deletes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The errors are logged — &lt;code&gt;console.warn&lt;/code&gt; names the slug and the failed count. But logging doesn't protect the rows. The guard and the logging are both needed: the guard prevents data loss, the log makes the gap visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the failed counter fires on a genuinely deleted repo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If GitHub returns 404 for a repo that's actually gone (not temporarily unavailable), the &lt;code&gt;failed&lt;/code&gt; counter fires and the prune is skipped, leaving the deleted repo in the database for one more night. The tradeoff is deliberate: a falsely persistent row is recoverable; a falsely deleted row isn't (without log archaeology). The next time the seed is updated to remove that repo, the per-slug cleanup will handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does the ratio guard handle a legitimate large cleanup?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a batch seed cleanup removes more than 10% of entries at once, the guard blocks the prune. The fix is to either raise the ratio temporarily or run the prune manually. The guard is a circuit breaker on unexpected deletions, not a permanent cap on how many entries can be legitimately removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does canonical &lt;code&gt;full_name&lt;/code&gt; resolution require an extra API call?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The &lt;code&gt;full_name&lt;/code&gt; is in the same response as stars, description, and license — there's no additional call per repo. The &lt;a href="https://dev.to/morinaga/how-i-built-the-oss-alternatives-directory-github-etl-turso-and-the-upsert-trap-i-hit-11ie"&gt;100ms sleep between calls&lt;/a&gt; was already present for rate-limit reasons, not added for the canonical lookup. For reference, GitHub's &lt;a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api" rel="noopener noreferrer"&gt;authenticated REST API rate limit&lt;/a&gt; is 5,000 requests per hour, so 100ms (36,000 per hour at max throughput) is well inside the safe range.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Four production models I'm watching: speaker diarization, reranking, OCR, prompt safety</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sun, 13 Sep 2026 10:45:25 +0000</pubDate>
      <link>https://dev.to/morinaga/four-production-models-im-watching-speaker-diarization-reranking-ocr-prompt-safety-1pm2</link>
      <guid>https://dev.to/morinaga/four-production-models-im-watching-speaker-diarization-reranking-ocr-prompt-safety-1pm2</guid>
      <description>&lt;p&gt;I pull model stats weekly to keep &lt;a href="https://aiappdex.com" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt; current. Most entries drift in with download counts that tell a straightforward story: high downloads, modest likes, task-category role obvious. Four recent models have a different profile — either their likes-to-download ratio is unusually high, or their pipeline tag puts them in infrastructure territory that rarely gets covered in round-ups.&lt;/p&gt;

&lt;p&gt;None of these are chat models. All four appear in production systems at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  pyannote/speaker-diarization-3.1 — 9.26 million downloads, 3,350 likes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/pyannote-speaker-diarization-3-1/" rel="noopener noreferrer"&gt;speaker-diarization-3.1 on aiappdex&lt;/a&gt; answers a question that Whisper does not: &lt;em&gt;who spoke when&lt;/em&gt;. Transcription tells you what was said; diarization adds speaker segmentation and identity labels across the recording.&lt;/p&gt;

&lt;p&gt;The likes-to-download ratio here is exceptional — one like for every 2,763 downloads. For context, most embedding models in the same download range hover around one like per 20,000 or more. That high ratio usually means specialists are using the model and are grateful enough to explicitly endorse it. The use cases are narrow but high-value: meeting transcription with attribution, podcast chapter editing, call centre analytics where GDPR and speaker count matter. Those are contexts where an open-weight option with Apache 2.0 licensing has obvious appeal over a cloud API.&lt;/p&gt;

&lt;p&gt;The pipeline is a two-stage system — segmentation, then speaker embedding and clustering. Pyannote's own documentation covers the necessary speaker count parameter; most production deployments I've seen in open-source tooling treat that parameter as a config value rather than hardcoding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  BAAI/bge-reranker-v2-m3 — 17.58 million downloads, 1,161 likes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/baai-bge-reranker-v2-m3/" rel="noopener noreferrer"&gt;bge-reranker-v2-m3 on aiappdex&lt;/a&gt; is not the same model as BGE-M3, though the names overlap. BGE-M3 is a bi-encoder embedder; this is a cross-encoder reranker. The difference matters at runtime: a bi-encoder encodes documents once and caches the results; a cross-encoder re-encodes every query-document pair at query time, which is slower but more accurate.&lt;/p&gt;

&lt;p&gt;The standard RAG pattern that reaches for this model: a fast bi-encoder retrieves the top 100 candidates, then bge-reranker-v2-m3 re-scores the top 20 for the final ranked list. What distinguishes it from the simpler ms-marco cross-encoders is multilingual support — it is built on XLM-RoBERTa and handles queries across languages from a single checkpoint. Apache 2.0, so no commercial-use complications.&lt;/p&gt;

&lt;p&gt;17.58 million downloads places it firmly in production territory. The model is large enough that you want GPU acceleration for interactive latency, but at batch re-ranking it runs fine on a modest CPU setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  ZhipuAI/GLM-OCR — 2 million downloads, 2,011 likes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/zai-org-glm-ocr/" rel="noopener noreferrer"&gt;GLM-OCR on aiappdex&lt;/a&gt; has a likes-to-download ratio close to 1:1. In practice that almost never happens — download counts accumulate from CI pipelines, automated tests, and package managers; likes require a human to click. When the ratio approaches parity, you are looking at a model that people download specifically because they went looking for it, not one that arrived as a dependency.&lt;/p&gt;

&lt;p&gt;GLM-OCR is MIT licensed, treats OCR as a sequence generation task, and supports Chinese, English, French, Spanish, Russian, German, Japanese, and Korean from a single model. That language coverage at MIT licensing is the specific combination that makes it interesting — most multilingual OCR alternatives either require cloud APIs or carry non-commercial restrictions. I do not run an OCR pipeline right now, but if I needed structured text extraction from screenshots or document images at the directory scale, this would be the first checkpoint I'd evaluate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meta/Prompt-Guard-86M — 4.37 million downloads, 397 likes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/meta-llama-prompt-guard-86m/" rel="noopener noreferrer"&gt;Prompt-Guard-86M on aiappdex&lt;/a&gt; is a safety classifier for detecting prompt injection in user-supplied text. With 86 million parameters it runs on CPU with low latency, which is the point — it is meant to be a gate, not the main workload.&lt;/p&gt;

&lt;p&gt;This one is relevant to my own pipeline. The article routine reads Dev.to reader comments and treats them as data for the VoC engine. There is a hard rule in the pipeline: comments are untrusted input and must never be followed as instructions (a fact documented in the project's recent-work-facts.md: reader comments feed an autonomous routine that commits and publishes, making injection the specific threat). Right now the defence is a combination of prompt framing and delimiting — the comments are labelled explicitly as untrusted data before being passed to any LLM. A dedicated classification step that runs before the LLM sees the text is the obvious next layer, and Prompt-Guard is the open-weight option for that.&lt;/p&gt;

&lt;p&gt;One caveat that matters: Prompt-Guard-86M is subject to Llama 3.1 Community license terms, not Apache 2.0. Check that licence before commercial production use.&lt;/p&gt;




&lt;p&gt;All four models are in the aiappdex directory at &lt;a href="https://aiappdex.com" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt;. The download counts here are from this week's ETL snapshot; likes counts shift more slowly and tend to be more stable as a quality signal.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Three YouTube Data API v3 behaviors that broke my analytics classifier</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sat, 12 Sep 2026 09:44:12 +0000</pubDate>
      <link>https://dev.to/morinaga/three-youtube-data-api-v3-behaviors-that-broke-my-analytics-classifier-4kp6</link>
      <guid>https://dev.to/morinaga/three-youtube-data-api-v3-behaviors-that-broke-my-analytics-classifier-4kp6</guid>
      <description>&lt;p&gt;I run a daily Python script that fetches the last 30 videos from a YouTube channel, classifies them as high or low performers by view count relative to the channel median (as of July 2026; the script has since switched to a views-per-day rate with an age-eligibility window), and writes a strategy directive telling the next day's video-generation routine which archetype to produce. I wrote about &lt;a href="https://dev.to/morinaga/how-i-built-a-youtube-performance-classifier-that-adjusts-tomorrows-video-script-bias-19ba"&gt;the classification logic here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Three behaviors in the YouTube Data API v3 broke that script. Only one of them announced itself with an error, and even that error didn't say why. Each is documented if you read the reference carefully, but they're easy to miss when you're building quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Channel handle lookup fails silently when the handle doesn't match
&lt;/h2&gt;

&lt;p&gt;To look up a channel by its handle (the &lt;code&gt;@name&lt;/code&gt; you see in YouTube URLs), you use the &lt;code&gt;forHandle&lt;/code&gt; parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /youtube/v3/channels?part=snippet,statistics&amp;amp;forHandle=myhandle&amp;amp;key=&amp;lt;key&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: when the handle doesn't match — a missing underscore, a hyphen where the real handle has none, a handle that was renamed since you wrote it down — the API returns a 200 OK with an empty &lt;code&gt;items&lt;/code&gt; array. No error, no &lt;code&gt;404&lt;/code&gt;, no explanation. Your code receives valid JSON that looks like a successful response, but there are no channels in it.&lt;/p&gt;

&lt;p&gt;My first API-key version tried exactly one handle — &lt;code&gt;YT_CHANNEL_HANDLE&lt;/code&gt;, defaulting to &lt;code&gt;claude_automate&lt;/code&gt; — and raised &lt;code&gt;RuntimeError&lt;/code&gt; with the entire response body when &lt;code&gt;items&lt;/code&gt; came back empty. That &lt;code&gt;raise&lt;/code&gt; is the only reason this didn't turn into silent bad data. What it couldn't do was explain anything: the run died with &lt;code&gt;channel not found for handle @...&lt;/code&gt; followed by a body that was a perfectly healthy 200 OK carrying an empty array. The error proved the request had succeeded and told me nothing about which string form the channel actually answers to, and I never did pin down the exact mismatch — I stopped trying to identify it and made the lookup stop depending on getting it right.&lt;/p&gt;

&lt;p&gt;The fix is a cascade: try multiple handle variants (&lt;code&gt;claudeautomate&lt;/code&gt;, &lt;code&gt;claude_automate&lt;/code&gt;, &lt;code&gt;claude-automate&lt;/code&gt; alongside the configured one), then fall back to a &lt;code&gt;search.list&lt;/code&gt; query if all handle lookups return empty:&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;handle&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;env_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channelname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel-name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;api_get&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;channels?part=snippet&amp;amp;forHandle=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Final fallback: search by channel name
&lt;/span&gt;&lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;api_get&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;search?part=snippet&amp;amp;type=channel&amp;amp;q=channel+name&amp;amp;key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
    &lt;span class="n"&gt;channel_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channelId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;api_get&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;channels?part=contentDetails,statistics&amp;amp;id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search fallback is slower (one extra API call) and counts against quota, but it's a last resort for a once-daily job. The real lesson is that any YouTube handle lookup needs a fallback path — there's no single canonical string form that reliably works across all channels.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;forUsername&lt;/code&gt; parameter is also worth knowing: it's the legacy form for older channels that predate the &lt;code&gt;@handle&lt;/code&gt; system. If your channel predates 2022 or so and &lt;code&gt;forHandle&lt;/code&gt; keeps failing, try &lt;code&gt;forUsername&lt;/code&gt; with the channel's original username.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. View counts require a second API call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;playlistItems.list&lt;/code&gt; gives you the list of videos in a channel's uploads playlist. It includes video IDs, publish timestamps, titles, and descriptions. It does not include view counts, like counts, or any performance statistics.&lt;/p&gt;

&lt;p&gt;To get &lt;code&gt;viewCount&lt;/code&gt;, &lt;code&gt;likeCount&lt;/code&gt;, and &lt;code&gt;commentCount&lt;/code&gt;, you need a separate &lt;code&gt;videos.list&lt;/code&gt; call with &lt;code&gt;part=statistics&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_uploads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uploads_playlist_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Returns: videoId, publishedAt, title, description
&lt;/span&gt;    &lt;span class="c1"&gt;# Does NOT return: viewCount, likeCount, commentCount
&lt;/span&gt;    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playlistItems?part=snippet,contentDetails&amp;amp;playlistId=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uploads_playlist_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;maxResults=30&amp;amp;key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;api_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contentDetails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;videoId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&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;fetch_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;video_ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ids&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="n"&gt;video_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# batch up to 50 IDs per request
&lt;/span&gt;    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;videos?part=statistics,snippet&amp;amp;id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;api_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;videos.list&lt;/code&gt; endpoint lets you batch up to 50 video IDs per request as a comma-separated string. For a 30-video sample, one call gets everything.&lt;/p&gt;

&lt;p&gt;One thing to watch: the order of items in the &lt;code&gt;videos.list&lt;/code&gt; response is not guaranteed to match the order of IDs in the request. Don't assume &lt;code&gt;stats[i]&lt;/code&gt; corresponds to &lt;code&gt;video_ids[i]&lt;/code&gt;. Match by &lt;code&gt;item["id"]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stats_by_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;v&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="nf"&gt;fetch_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;video_ids&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="n"&gt;ordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;stats_by_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vid&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;vid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;video_ids&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stats_by_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-call minimum also means you need the &lt;code&gt;contentDetails.relatedPlaylists.uploads&lt;/code&gt; value from the channel response (the uploads playlist ID), which is its own separate fact. Getting from "channel handle" to "view counts on the last 30 videos" requires: channels.list → playlistItems.list → videos.list. Three calls minimum. The &lt;a href="https://developers.google.com/youtube/v3/docs/videos/list" rel="noopener noreferrer"&gt;YouTube Data API v3 videos.list reference&lt;/a&gt; documents which &lt;code&gt;part&lt;/code&gt; values return which fields — &lt;code&gt;statistics&lt;/code&gt; is the one that has view counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Including "unknown" videos corrupts the archetype ranking
&lt;/h2&gt;

&lt;p&gt;After getting view counts, I join each video with its archetype — &lt;code&gt;product_findindiegame&lt;/code&gt;, &lt;code&gt;build_in_public&lt;/code&gt;, &lt;code&gt;meta&lt;/code&gt;, and so on — by matching the YouTube title against titles stored in local uploaded-video JSON files. The match uses word overlap: if at least 4 content words are shared between the API title and the local title, the video gets that archetype. If no match clears the threshold, the video gets &lt;code&gt;_archetype = "unknown"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The initial version kept "unknown" in the pool and picked the next day's preferred archetype by &lt;em&gt;frequency&lt;/em&gt; — the most common archetype among the high performers. Here's the archetype table from the 2026-06-20 report, the one that made the problem obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;archetype        |  n | median_views
-----------------|----|-------------
unknown          | 22 | 19
contrarian       |  2 | 15
curated          |  1 | 10
build_in_public  |  1 | 55
meta             |  1 | 18
ai_tools         |  1 | 7
technical        |  1 | 67
recap            |  1 | 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unknown&lt;/code&gt; wasn't the best-performing bucket — &lt;code&gt;technical&lt;/code&gt; (67) and &lt;code&gt;build_in_public&lt;/code&gt; (55) both beat its median of 19. It was simply the &lt;em&gt;biggest&lt;/em&gt; bucket: 22 of the 30 sampled videos, and 9 of the 11 high performers. So the frequency-based tuner emitted "Prefer: unknown." It also emitted "Avoid: unknown," because &lt;code&gt;unknown&lt;/code&gt; dominated the low bucket too. Contradictory and meaningless guidance — "unknown" isn't a content format you can produce, and while the tuner was chasing it, genuinely weak formats (a &lt;code&gt;recap&lt;/code&gt; video sitting at 1 view) kept getting generated.&lt;/p&gt;

&lt;p&gt;The reason "unknown" swallowed the sample: it collected the older videos that predated the automated pipeline (no uploaded JSON file exists for them) &lt;em&gt;and&lt;/em&gt; recent videos whose archetype file never got written, because the publish workflow had been failing on artifact quota. Neither group says anything about which format to produce tomorrow.&lt;/p&gt;

&lt;p&gt;The fix was two changes in one commit: rank archetypes by median views instead of by how often they show up in the high bucket, and drop "unknown" from the ranking entirely:&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;video&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;archetype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_archetype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&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;archetype&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# skip — no actionable signal
&lt;/span&gt;    &lt;span class="n"&gt;arch_views&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;archetype&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statistics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;viewCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arch_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After exclusion, the rankings only reflect videos where I know the production context. "Prefer &lt;code&gt;product_findindiegame&lt;/code&gt;" is actionable. "Prefer &lt;code&gt;unknown&lt;/code&gt;" is noise.&lt;/p&gt;




&lt;p&gt;The same pattern comes up in any classifier built on fuzzy join data: always define your null category explicitly, and decide upfront whether it should participate in ranking or be excluded. Null-inclusive rankings silently inflate or deflate whichever real category happens to share data with the null bucket.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Five things I noticed this week: deletion bias, hidden assumptions, production AI</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sat, 12 Sep 2026 09:44:08 +0000</pubDate>
      <link>https://dev.to/morinaga/five-things-i-noticed-this-week-deletion-bias-hidden-assumptions-production-ai-4kkp</link>
      <guid>https://dev.to/morinaga/five-things-i-noticed-this-week-deletion-bias-hidden-assumptions-production-ai-4kkp</guid>
      <description>&lt;p&gt;Five observations from this week. Loose format — things that surprised me, contradicted something I assumed, or changed how I think about a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. I deleted my way to a fake improvement in YouTube analytics
&lt;/h2&gt;

&lt;p&gt;I delete YouTube shorts that fall below 50 views in their first week. The reasoning: remove clutter, sharpen the signal. What I hadn't accounted for: deleting the worst performers removes them from the denominator.&lt;/p&gt;

&lt;p&gt;On 2026-08-11, the headline median jumped from 2.9 to 3.3 views/day. I started writing a note about the improvement. Then I recomputed with deleted videos carried at their last observed value (LOCF correction). The corrected figure was &lt;strong&gt;2.91&lt;/strong&gt; — flat. The apparent gain was entirely the deletion.&lt;/p&gt;

&lt;p&gt;It got worse with A/B testing. One arm had 36% attrition, the other 44%. The effect size swung between 0.99× and 1.89× depending only on how I handled the missing data. The test is now SUSPENDED rather than reporting a winner. &lt;a href="https://dev.to/morinaga/how-i-found-deletion-bias-in-my-own-youtube-shorts-analytics-28dk"&gt;Full writeup here.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern has a name: outcome-dependent censoring. The measurement outcome is exactly the selection criterion for removal. Once you see the shape of the error, you see it everywhere — any system that deletes, archives, or filters its worst observations before aggregating them will drift upward for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cadence assumptions are the sneakiest hidden state in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;I published a piece on &lt;a href="https://dev.to/morinaga/how-systematic-review-catches-the-github-actions-assumptions-authors-cant-see-3if"&gt;systematic review of GitHub Actions workflows&lt;/a&gt; — running two independent review passes to surface assumptions an author wouldn't notice. Two passes across a batch of workflows found 8 defect categories.&lt;/p&gt;

&lt;p&gt;The sneakiest: assumptions tied to cadence. A cleanup window set to 7 days implicitly assumes the generating job also runs daily. A retry backoff of "a few minutes" implicitly assumes the trigger fires less than once per hour. Neither shows up as an explicit constraint in the code. They're encoded in the numbers.&lt;/p&gt;

&lt;p&gt;A reader on that article: "every assumption about external systems eventually decays when the load pattern changes." That matches what I've seen. The code is unchanged; the inputs changed. The defect was already there — it just needed the right timing to become visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The headless Pi scanner hit the same class of implicit constraint
&lt;/h2&gt;

&lt;p&gt;On the edge AI shelf scanner I run on a Raspberry Pi 3, one issue I had to handle explicitly after an audit pass was scan collision — a new cron run starting before the previous scan finished. At median 8.5 seconds per inference on an hourly cycle, it almost never matters. But SD card writes, post-processing, and occasional outliers (range went to 11.8 s across 19 real scans) can push the total runtime higher.&lt;/p&gt;

&lt;p&gt;The collision case wasn't in any spec. It was implicit in the assumption that "hourly scans are fine" — which is true until they aren't, and the failure mode isn't a crash, it's corrupted scan history. Recognizing it required thinking through what overlapping runs would actually do, not just whether the timing worked on average.&lt;/p&gt;

&lt;p&gt;Same shape as the GitHub Actions cadence problem: the constraint was real but invisible until someone asked "what breaks at the boundary?"&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Production HuggingFace downloads are dominated by models nobody blogs about
&lt;/h2&gt;

&lt;p&gt;Two pieces this week on what actually leads production downloads in the &lt;a href="https://aiappdex.com/" rel="noopener noreferrer"&gt;aiappdex model catalog&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/sentence-transformers-all-minilm-l6-v2/" rel="noopener noreferrer"&gt;all-MiniLM-L6-v2&lt;/a&gt; has 246 million downloads. &lt;a href="https://aiappdex.com/models/google-bert-bert-base-uncased/" rel="noopener noreferrer"&gt;BERT-base-uncased&lt;/a&gt; from 2018 has 58.6 million. Both sit above any text-generation model in the catalog. The &lt;a href="https://dev.to/morinaga/chronos-2-clip-and-mobilenetv3-three-production-ai-models-for-non-text-workloads-1nij"&gt;non-text production models&lt;/a&gt; — CLIP, MobileNetV3, Chronos-T5-Large for time series — have their own substantial download bases and cover use cases that LLMs don't touch.&lt;/p&gt;

&lt;p&gt;The models that dominate AI news — frontier chat models, large instruction-tuned LLMs — don't appear near the top of production download counts. This isn't a criticism of coverage. It's a gap between what gets discussed and what gets used: embedding models and smaller classification baselines still power most production inference. The discourse is shaped by what's interesting to write about.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Detection lag tells you more than uptime ever did
&lt;/h2&gt;

&lt;p&gt;This week's &lt;a href="https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo"&gt;piece on detection lag vs. uptime&lt;/a&gt; covers something I keep coming back to: six failures in this repo with detection lags ranging from 36 to 113 days, all while the jobs stayed green.&lt;/p&gt;

&lt;p&gt;The common pattern: the job produced a well-formed output file. An empty array is not an error. A file with wrong numbers isn't a parse failure. A published article that nobody reads doesn't throw an exception. The job finished, the badge went green, nothing in the monitoring flagged it.&lt;/p&gt;

&lt;p&gt;Measuring how long it takes to &lt;em&gt;notice&lt;/em&gt; a failure — not whether the job &lt;em&gt;ran&lt;/em&gt; — gives a more honest picture. It also tells you where to invest monitoring effort: not at the process boundary (did the job exit 0?) but at the output boundary (does the content of this file look plausible?). The pipeline health question isn't "is it running?" It's "when it breaks, how long until I know?"&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>showdev</category>
      <category>machinelearning</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three Signals from 284 Published Articles That Changed How I Plan New Content</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 11 Sep 2026 10:08:15 +0000</pubDate>
      <link>https://dev.to/morinaga/three-signals-from-284-published-articles-that-changed-how-i-plan-new-content-5cdh</link>
      <guid>https://dev.to/morinaga/three-signals-from-284-published-articles-that-changed-how-i-plan-new-content-5cdh</guid>
      <description>&lt;p&gt;When I started cross-publishing articles in April 2026, the dataset was too small to tell me anything useful. I had 10 articles and single-digit reactions — not a sample, just noise. After 284 articles — machine-counted by &lt;code&gt;scripts/article-analytics.mjs&lt;/code&gt; querying the Dev.to public API daily — there are patterns worth naming. Three of them changed what I actually do when planning new posts.&lt;/p&gt;

&lt;p&gt;One caveat upfront: 284 articles with 173 total reactions is a heavily right-skewed distribution. Most articles have zero engagement. The patterns here describe the exceptions, and the exception count is still small. These are directional signals, not controlled results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: Recent articles outperform older ones, despite less time to accumulate
&lt;/h2&gt;

&lt;p&gt;Articles published in the last 30 days average 1.26 reactions each. Articles older than 30 days average 0.42 reactions. The older posts have had months longer to accumulate, so this runs opposite to what you'd expect from a compounding model where traffic builds over time.&lt;/p&gt;

&lt;p&gt;The most plausible explanation: Dev.to's feed surfaces recent content, and reaction behavior follows that surfacing. An article from three months ago sitting in search results doesn't receive the same passive impressions that a new post gets from the "latest" and "following" feeds. The engagement window is probably the first several days after publication, not the full lifetime of the post.&lt;/p&gt;

&lt;p&gt;What this changed: I stopped treating the backlog as a long-term asset that compounds quietly. Articles are more like timestamped snapshots — the publication moment is when they reach most of the audience they'll ever reach. Publishing consistently matters more than perfecting any individual article before shipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: Weekly-digest format has the highest median reactions
&lt;/h2&gt;

&lt;p&gt;Across all format archetypes in the 284-article set, weekly-digest posts (the "five things I noticed this week" format I run on Saturdays) have the highest median reaction count: 1.5. Technical howto posts and "why I'm betting on X" meta articles occupy the top 10 individually, but the digest posts do it more consistently across the format as a whole.&lt;/p&gt;

&lt;p&gt;My interpretation: digest posts have lower reading commitment than a 2,000-word technical deep dive. Someone who wouldn't read a full article on canonical URL implementation might still spend 90 seconds on a digest that mentions it alongside four other observations. The format matches available attention better than a single long argument does.&lt;/p&gt;

&lt;p&gt;What this changed: I stopped treating digest posts as lower-value output. They're the most consistently engagement-optimized format I have. The trade-off is that they have less long-tail keyword potential than a specific technical article — a digest titled "Five things I noticed this week" won't rank for anything in six months. Digest posts serve current readers; technical articles serve future search. Both have a role, and I was underweighting the digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: Concentration is extreme — two articles account for a disproportionate share
&lt;/h2&gt;

&lt;p&gt;The top article has 5 reactions and 5 comments. The second has 4 reactions and 4 comments. Together that's 13 of the 173 total reactions — 7.5% of engagement concentrated in two of 284 articles.&lt;/p&gt;

&lt;p&gt;More interesting than the concentration is what the top two articles are about. "Four signals I built into an OSS decision score instead of fabricating reviews" and "How I coordinate Claude and Codex sessions with a pull-based JSON handoff ledger." Neither is about business strategy or AI tools in general. Both are about specific engineering decisions with concrete alternatives implied.&lt;/p&gt;

&lt;p&gt;The OSS scoring article has a number in the title — four signals — and describes something that can't easily be found elsewhere (an actual scoring approach, with the decision to avoid fabricating reviews stated explicitly). The coordination article names a mechanism — pull-based JSON — that immediately suggests its alternative (push-based, or a database). Both titles contain something falsifiable and specific.&lt;/p&gt;

&lt;p&gt;What this changed: I now try to make titles contain at least one concrete number or mechanism. Not as a formula — the dataset is too small for formulas — but as a heuristic: the articles that got the most engagement in my own data all have a specific, verifiable claim in the title. That's directional evidence, held loosely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not changing
&lt;/h2&gt;

&lt;p&gt;Despite the recency signal, I'm not shifting to more-frequent shorter posts at the expense of technical depth. The &lt;a href="https://dev.to/morinaga/what-i-learned-closing-the-engagement-feedback-loop-in-a-45-article-per-month-pipeline-2dnk"&gt;engagement feedback loop&lt;/a&gt; I built is meant to surface which formats are connecting with readers, not to optimize for Dev.to feed placement at the cost of the kind of writing I'd still want to have published in month 12.&lt;/p&gt;

&lt;p&gt;The engagement data is useful for calibration, not direction. A recency effect in the feed is real but shallow — it doesn't tell me that shallow content outperforms deep content, it tells me that content visible in the feed outperforms content not visible in the feed. The goal is to be consistently visible, which means publishing regularly, which I was already doing.&lt;/p&gt;

&lt;p&gt;The one concrete change: running this engagement report daily and checking it weekly. The data comes from the &lt;a href="https://developers.forem.com/api" rel="noopener noreferrer"&gt;Dev.to (Forem) public API&lt;/a&gt; — specifically the articles endpoint which returns reaction and comment counts without authentication for published posts. Before I built the automated collection, I had no systematic view of what was connecting. Now I do, and the three signals above came directly from having the data in front of me in a format I could actually read.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>What I Learned About Declaring Source-of-Truth Per Claim in an Automated Pipeline</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 11 Sep 2026 10:08:12 +0000</pubDate>
      <link>https://dev.to/morinaga/what-i-learned-about-declaring-source-of-truth-per-claim-in-an-automated-pipeline-3ii</link>
      <guid>https://dev.to/morinaga/what-i-learned-about-declaring-source-of-truth-per-claim-in-an-automated-pipeline-3ii</guid>
      <description>&lt;p&gt;After publishing &lt;a href="https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6"&gt;Two times an external API overruled my own repository&lt;/a&gt;, a reader left a question I couldn't stop thinking about. &lt;a class="mentioned-user" href="https://dev.to/raknaos"&gt;@raknaos&lt;/a&gt; asked whether I had encoded the authoritative-source mapping anywhere, or if it was still implicit in each checker — and noted that declaring it per claim starts to look like a small trust database with its own maintenance cost.&lt;/p&gt;

&lt;p&gt;Short answer: it's still implicit. Longer answer: the failure modes that motivated the original article aren't isolated. I found two more cases in the same pipeline with the same shape. The per-claim approach is what I should have built; this article is about why, and what it would look like.&lt;/p&gt;

&lt;p&gt;The thesis in one sentence: when multiple data sources can each describe the same claim, and an automated checker must pick one, the choosing must be declared in configuration — not inherited as a convention from which source happens to be easiest to query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The class of failure: confident wrong corrections
&lt;/h2&gt;

&lt;p&gt;Both failures from &lt;a href="https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6"&gt;the previous article&lt;/a&gt; had this structure: an automated checker read a claim, found evidence from its primary source, and updated the claim to match. The update looked correct from the checker's perspective. From the article's perspective, it introduced an error.&lt;/p&gt;

&lt;p&gt;This isn't a quality-gate problem in the usual sense. The &lt;a href="https://dev.to/morinaga/how-i-implemented-qualitycontract-v2-four-fields-that-audit-ai-articles-at-the-source-45jc"&gt;quality contract v2 frontmatter&lt;/a&gt; I added to every article includes &lt;code&gt;verified_at&lt;/code&gt; — a date for when claims were checked. That field tells me a check ran; it doesn't tell me which source was authoritative. Two auto-correction passes using different sources can each attach a valid &lt;code&gt;verified_at&lt;/code&gt; to contradicting facts, and both timestamps look legitimate.&lt;/p&gt;

&lt;p&gt;The problem is not "did the checker run?" It's "which source did the checker consult, and was that the right one for this particular claim?" A green &lt;code&gt;verified_at&lt;/code&gt; doesn't answer that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two cases from my pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Case 1: Rejection count.&lt;/strong&gt; A published article described the project's rejection history. The repo had four events committed. The author's own notes showed seven — three rejections arrived as verbal notifications and were never committed. A checker reading git saw four as the ground truth and updated any article showing seven. A different pass reading another source saw seven and corrected it back. Each pass was confident. Each correction was wrong half the time.&lt;/p&gt;

&lt;p&gt;The claim "how many rejections did this project receive?" has one correct answer. The problem: two sources (git commits; author notes) gave different answers, and each looked authoritative to whichever checker happened to query it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2: Newsletter status.&lt;/strong&gt; An article described an active manual newsletter workflow. Git commit history showed publishing setup work. The Beehiiv API, when queried, showed zero posts, zero drafts — the publication had been configured but never used. The claim was settled by the source the automatic checker hadn't been reading.&lt;/p&gt;

&lt;p&gt;The claim "is the newsletter active?" has one correct answer. The problem: git commits documented intent (the setup work), while the provider API documented outcome (zero sends). These are subtly different propositions, but the article stated the outcome, so only the outcome source was authoritative.&lt;/p&gt;

&lt;p&gt;In both cases the failure signal was invisible. The job produced a well-formed file. The updated number looked plausible. The &lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;output inspection principle&lt;/a&gt; says to verify the output rather than the job status — but even inspecting the output file doesn't catch "this number looks right but came from the wrong source."&lt;/p&gt;

&lt;h2&gt;
  
  
  What per-claim source declaration looks like
&lt;/h2&gt;

&lt;p&gt;This is the design I haven't built yet.&lt;/p&gt;

&lt;p&gt;The minimum viable form: a configuration that lists facts with multiple plausible sources and names the authoritative one for each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rejection_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;authoritative&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;author_notes&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git records committed events only; verbal notifications stay in notes&lt;/span&gt;

&lt;span class="na"&gt;newsletter_active&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;authoritative&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;provider_api&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git records intent; the API records outcome&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checker reads this config before querying any source. It queries only the declared authoritative source. If that source is unavailable, it returns &lt;code&gt;unknown&lt;/code&gt; — not a silent fallback to whatever is readable. The failure-closed behavior is load-bearing: a silent fallback to git is exactly what created the four-vs-seven pattern in Case 1.&lt;/p&gt;

&lt;p&gt;The config lives in the repo. Changes to it are in git history, reviewable, attributable — the same auditability that &lt;a href="https://dev.to/morinaga/how-systematic-review-catches-the-github-actions-assumptions-authors-cant-see-3if"&gt;systematic review of implicit assumptions provides&lt;/a&gt; for GitHub Actions configuration.&lt;/p&gt;

&lt;p&gt;A parallel from a different project makes the value clear. The shelf scanner I run on a Raspberry Pi 3 uses &lt;a href="https://github.com/Tencent/ncnn" rel="noopener noreferrer"&gt;NCNN&lt;/a&gt; for inference — a lightweight framework designed for embedded hardware. The YOLO model detects empty space — single class, no product identification. Which SKU belongs in that gap is resolved by a planogram (a shelf-layout master file), not the model. Two distinct sources, two explicit roles: the model is authoritative for "is there empty space here?"; the planogram is authoritative for "what should be stocked there?" Conflating them would require the model to do SKU recognition — a much harder problem that needs per-store retraining. The explicit boundary makes the system maintainable in a way a conflated design wouldn't. I want the same clarity in the article pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance overhead question
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/raknaos"&gt;@raknaos&lt;/a&gt; framed this accurately: a per-claim trust config is a small database with its own maintenance cost. Add a new data source and you need to update the config. Change which source is authoritative for a claim and you need to reason through every place the old source was cited. An unmaintained config that points to a deprecated field is worse than no config — it routes confidently to the wrong place.&lt;/p&gt;

&lt;p&gt;Against that: what's the cost of the current approach?&lt;/p&gt;

&lt;p&gt;The two-corrections case produced no visible error signal. Each pass produced a well-formed file with a plausible-looking number. I found the discrepancy manually, while fact-checking something else. The &lt;a href="https://dev.to/morinaga/three-devto-api-behaviors-i-hit-when-syncing-135-article-corrections-4j9m"&gt;Dev.to API sync behaviors&lt;/a&gt; I've documented are similar: some fields have exactly one plausible source and are safe to treat as authoritative implicitly; others — like reaction counts on a published article — have a correct value that only the live API knows, not a cached projection. The difference between those two categories is currently in my head, not in the code.&lt;/p&gt;

&lt;p&gt;My rough estimate: the maintenance overhead of keeping a trust config current is lower than the debugging overhead of a confident-wrong correction in a pipeline that runs unattended. But the answer depends on how often authority shifts. In a stable pipeline reading from a fixed set of sources, the config is set once and rarely touched. In a pipeline with active ETL from multiple third-party APIs — where a provider can deprecate a field, add a more-granular endpoint, or change what a field means — the maintenance cost rises, and the config earns its overhead most visibly precisely when authority shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where implicit mapping stays acceptable
&lt;/h2&gt;

&lt;p&gt;Not every claim needs a declaration. Some have exactly one plausible source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Article word count: the file is the source, and no external authority can override it.&lt;/li&gt;
&lt;li&gt;Publication timestamp: the platform API records it; the repo stores it after the fact. One source.&lt;/li&gt;
&lt;li&gt;Inference time measured on your own device: your measurements, no alternative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These don't need declarations because "which source?" has a single obvious answer. Implicit is fine when there's nothing to declare.&lt;/p&gt;

&lt;p&gt;The pattern that warrants explicit declaration: whenever a developer could construct two plausible stories about the same fact from two different sources, and both stories would sound defensible, and an automated system must pick one without asking. That is the decision point the config exists to make explicit and auditable.&lt;/p&gt;

&lt;p&gt;Looking back at what the &lt;a href="https://dev.to/morinaga/how-i-implemented-qualitycontract-v2-four-fields-that-audit-ai-articles-at-the-source-45jc"&gt;quality contract v2 implementation&lt;/a&gt; actually introduced: &lt;code&gt;verified_at&lt;/code&gt; records when verification ran, and &lt;code&gt;original_evidence&lt;/code&gt; records what was checked. Neither records which source was authoritative when multiple sources exist. Adding a per-claim source declaration field to the contract is the next logical step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;verified_at&lt;/code&gt; field was the right idea applied at the wrong granularity. It stamps the article with "verified on this date" but doesn't record what source was queried for each claim. Two articles verified the same day from different sources can contradict each other; both have valid timestamps.&lt;/p&gt;

&lt;p&gt;What I'm going to build: a &lt;code&gt;source_declarations&lt;/code&gt; section in the frontmatter contract for claims that have multiple plausible sources. Each entry names the claim, the authoritative source, and a short reason. The checker script enforces it: if a claim type has an entry in &lt;code&gt;source_declarations&lt;/code&gt;, the checker queries only that source and fails closed if it's unavailable.&lt;/p&gt;

&lt;p&gt;This won't prevent every wrong correction. It will prevent the specific class of error where an automated checker picks the accessible source over the authoritative one, and does so silently, and the output looks correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if the declared authoritative source is unavailable when the checker runs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Return &lt;code&gt;unknown&lt;/code&gt; and skip the update. A checker that silently falls back to a secondary source reintroduces the failure mode you're designing around. An article with one field missing is recoverable; an article confidently carrying a number from the wrong source can sit there for weeks before anyone notices — or it can trigger a correction pass that confidently restores the wrong number again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you identify which claims need declarations before you've had a failure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You mostly don't, which is why the retroactive approach is more practical than prospective enumeration. Every confident-wrong correction reveals a claim that needed a declaration. Keep a running list. Each failure is free documentation of a gap in the config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this the same as data lineage tracking?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Related but narrower. Lineage tracks how a value was transformed: "this field was computed from X using function Y at time Z." Per-claim source declaration answers a simpler, earlier question: when I need to verify this specific claim, which source do I query? The two can coexist and reference each other; neither replaces the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What did you actually implement as of this writing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing beyond &lt;code&gt;verified_at&lt;/code&gt; and &lt;code&gt;original_evidence&lt;/code&gt; in the frontmatter. The per-claim config is the next step. Writing this post is how I'm committing to the shape of it before building it. The two-corrections failure is documented in &lt;a href="https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6"&gt;the prior article&lt;/a&gt;; this is the proposed design for making that failure structurally impossible next time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Related:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6"&gt;Two times an external API overruled my own repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;Why I'm betting output inspection beats process monitoring for solo-developer pipelines&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Chronos-2, CLIP, and MobileNetV3: three production AI models for non-text workloads</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 10 Sep 2026 10:11:04 +0000</pubDate>
      <link>https://dev.to/morinaga/chronos-2-clip-and-mobilenetv3-three-production-ai-models-for-non-text-workloads-1nij</link>
      <guid>https://dev.to/morinaga/chronos-2-clip-and-mobilenetv3-three-production-ai-models-for-non-text-workloads-1nij</guid>
      <description>&lt;p&gt;When I sort the model catalog at &lt;a href="https://aiappdex.com/" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt; by total downloads, the 15th most downloaded model processes time-series data. The 14th handles zero-shot image classification. The 16th runs on hardware with under 3M parameters. None of them generate text.&lt;/p&gt;

&lt;p&gt;Three models from that tier deserve attention — not because they're new, but because the download numbers say someone is shipping them in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Downloads&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chronos-2&lt;/td&gt;
&lt;td&gt;25,699,844&lt;/td&gt;
&lt;td&gt;time-series-forecasting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLIP-vit-base-patch32&lt;/td&gt;
&lt;td&gt;19,936,700&lt;/td&gt;
&lt;td&gt;zero-shot-image-classification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MobileNetV3-small&lt;/td&gt;
&lt;td&gt;17,428,712&lt;/td&gt;
&lt;td&gt;image-classification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Numbers from &lt;code&gt;apps/ai-tools/src/data/models.json&lt;/code&gt; as of this writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chronos-2 — zero-shot time-series at 25.7M downloads
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/amazon-chronos-2/" rel="noopener noreferrer"&gt;Chronos-2&lt;/a&gt; is Amazon's second-generation pretrained model for zero-shot time-series forecasting. The architecture frames the problem as language modeling: quantize the time-series values into tokens, run a T5 encoder-decoder, decode the output tokens back into predicted values. Apache 2.0.&lt;/p&gt;

&lt;p&gt;The zero-shot claim is the practically important part. Most forecasting approaches require labeled in-domain training data — you need historical examples for the domain you're forecasting. Chronos-2 was trained on a large corpus of diverse time-series and can generalize to new domains without fine-tuning. That's the same premise CLIP made for vision: collect enough diverse data at pretraining time and zero-shot transfer becomes viable.&lt;/p&gt;

&lt;p&gt;25.7M downloads. In practice this means anomaly detection and demand forecasting in production data pipelines where someone had a time series and wanted a reasonable forecast without standing up a labeling operation. That use case — "I have the data, I don't have labeled examples" — is common enough to drive those numbers.&lt;/p&gt;

&lt;p&gt;What it doesn't do: outperform a well-tuned in-domain model on a domain where you have plenty of training data. Zero-shot is the alternative when you don't have that data, not a replacement when you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLIP-vit-base-patch32 — 19.9M downloads for cross-modal search
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/openai-clip-vit-base-patch32/" rel="noopener noreferrer"&gt;CLIP-vit-base-patch32&lt;/a&gt; is OpenAI's CLIP model with a ViT-B/32 image encoder, trained contrastively on 400 million image-text pairs. The training procedure aligns image and text representations in a shared embedding space — which means you can ask "which of these images is most similar to the phrase 'empty shelf'" without training a classifier for that specific concept.&lt;/p&gt;

&lt;p&gt;19.9M downloads. 1,137 likes.&lt;/p&gt;

&lt;p&gt;The B/32 variant (as opposed to ViT-L/14) trades accuracy for speed. In production that often makes sense: faster inference across more items is more useful than marginally better accuracy on fewer. The applications behind these numbers are image search, content moderation pre-filtering, and embedding images alongside text in retrieval-augmented generation pipelines.&lt;/p&gt;

&lt;p&gt;The cross-modal property is worth highlighting separately: CLIP's image encoder produces embeddings that are directly comparable to text embeddings from the same model. That's what makes it show up in hybrid retrieval systems — you can retrieve by text query and surface matching images without a translation step.&lt;/p&gt;

&lt;h2&gt;
  
  
  MobileNetV3-small — 17.4M downloads for edge classification
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/timm-mobilenetv3-small-100-lamb-in1k/" rel="noopener noreferrer"&gt;MobileNetV3-small-100.lamb-in1k&lt;/a&gt; is a sub-3-million-parameter image classifier trained on ImageNet-1k via the timm library. At this size it runs on mobile hardware and embedded systems. 17.4M downloads.&lt;/p&gt;

&lt;p&gt;MobileNetV3 is in this list for a different reason than Chronos-2 or CLIP. It isn't zero-shot and it doesn't bridge modalities. It's a very small, very fast image classifier for hardware where nothing else fits. The downloads come from the overlap of two things: timm's consistent preprocessing API (so it's easy to drop in as a swap for any other timm classifier) and the large number of production systems that need a classification signal where a 10M-parameter model is already too large.&lt;/p&gt;

&lt;p&gt;The trade-off is explicit: you give up accuracy in exchange for a model that runs on edge hardware. For a use case where inference happens on device and latency matters more than peak accuracy, that's the right trade-off. The 103 likes (low relative to downloads) reflects that this is a utility model — people use it because it solves a specific constraint, not because they're enthusiastic about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern across all three
&lt;/h2&gt;

&lt;p&gt;None of these are frontier models. None are mentioned in the AI news I read. All three are in the top 20 downloads on a catalog that includes hundreds of text-generation and instruction-tuned models.&lt;/p&gt;

&lt;p&gt;They're production workhorses in categories with genuinely different constraints than language generation. Chronos-2 targets the no-labeled-data case in time-series. CLIP targets cross-modal retrieval where text and images need to live in the same embedding space. MobileNetV3 targets inference on hardware where memory and latency are the primary constraints.&lt;/p&gt;

&lt;p&gt;If you're building something in one of those three spaces, 17–26 million monthly downloads is a better production-readiness signal than a benchmark table. It means the preprocessing API is stable, the common failure modes are documented, and someone has already debugged the deployment on your target hardware.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>How systematic review catches the GitHub Actions assumptions authors can't see</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 10 Sep 2026 10:11:00 +0000</pubDate>
      <link>https://dev.to/morinaga/how-systematic-review-catches-the-github-actions-assumptions-authors-cant-see-3if</link>
      <guid>https://dev.to/morinaga/how-systematic-review-catches-the-github-actions-assumptions-authors-cant-see-3if</guid>
      <description>&lt;p&gt;The conclusion I reached this week: when I changed my YouTube video pipeline from a daily cadence to three times per week, a Codex review found four defects. A second review, run the next day after I fixed those four, found four more. All eight fell into the same category. I had built assumptions about external system behavior directly into the code, and I couldn't see them because I built the system knowing how it was supposed to work. A reviewer who doesn't share that knowledge can ask questions the author doesn't know to ask.&lt;/p&gt;

&lt;p&gt;This article is the four categories, one example each, and what I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline and why the cadence changed
&lt;/h2&gt;

&lt;p&gt;The pipeline picks one video spec from a queue, runs a quality gate on it, and if it passes, uploads the video and commits the result back to the repository. It was running daily. After observing that the pipeline was spending publish slots on rejected specs, I cut the cadence to three times per week — Sunday, Tuesday, Thursday — to give the queue more time to fill with passing specs between runs. That's a structural change: the scheduling logic, monitoring thresholds, and gate behavior were all designed for a daily run.&lt;/p&gt;

&lt;p&gt;I ran a Codex review on the pull request. It found four issues. I fixed those and ran a second review. It found four more. Eight total. Every single one was an assumption about how some external system behaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 1 — Monitoring thresholds tuned to the wrong cadence
&lt;/h2&gt;

&lt;p&gt;The pipeline health monitor had two thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Alert if no publish in 36 hours&lt;/li&gt;
&lt;li&gt;Open a branch-drift issue if no spec file in 2 days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both made sense for a daily schedule. With a three-times-per-week cadence, the longest healthy gap is Friday to Monday: 72 hours. Both thresholds would have fired every Sunday on a healthy pipeline.&lt;/p&gt;

&lt;p&gt;I hadn't noticed because I set those thresholds when the pipeline was daily. They were correct then. They felt like constants, not assumptions.&lt;/p&gt;

&lt;p&gt;Codex caught it because it could compare the monitoring thresholds to the publish schedule without knowing "these thresholds used to be correct." It asked, in effect: what's the longest healthy gap in the new schedule? The answer — 72 hours — was longer than both alert windows. Fix: 84 hours and 4 days, giving one schedule-queueing slot of slack on each (from commit d97bc91).&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 2 — A rejected spec consuming a publish slot
&lt;/h2&gt;

&lt;p&gt;The quality gate ran on one file selected from the queue. If that file failed the gate, the job quarantined the file and exited. The slot was consumed. The next spec would try in two or three days.&lt;/p&gt;

&lt;p&gt;At the time of the cadence cut, the queue held five specs. Two failed the gate. The pre-gate-then-pick sequence would have spent the September 11 and September 14 slots on quarantine commits and pushed the next actual upload to September 16 — a seven-day gap on a three-times-per-week schedule. Those numbers are from the commit message; I can count the files in the queue.&lt;/p&gt;

&lt;p&gt;Codex caught this by reasoning about the queue state, not just the per-file gate logic. The per-file logic was correct in isolation. The problem was the interaction between the gate's exit behavior and the queue's current contents — a state that I knew about but that the code had no way to account for when I wrote the gate.&lt;/p&gt;

&lt;p&gt;Fix: a pre-gate sweep step now runs first and quarantines every reject in a single commit before the pick step runs. The per-file gate stays as the fail-closed backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 3 — Git state swallowed by &lt;code&gt;|| true&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;One step in the pipeline rebased the working branch before committing. The rebase step was followed by &lt;code&gt;|| true&lt;/code&gt; — a common pattern for "this step might fail and that's acceptable."&lt;/p&gt;

&lt;p&gt;Except it wasn't acceptable here. When the rebase fails — for example, due to a conflicted index — the step still exits 0. The pipeline continues. The upload happens. The commit lands on a detached HEAD rather than on main. The video looks uploaded. The queue doesn't see it as done. The same video would publish again on the next run.&lt;/p&gt;

&lt;p&gt;I wouldn't have caught this from reading the code because my mental model said "the rebase step is there so the branch is current before we commit — it usually succeeds." The &lt;code&gt;|| true&lt;/code&gt; was there to handle the case where there was nothing to rebase. Codex read it without that context: if the rebase fails for any other reason, the process continues and produces output that looks correct but is committed to unreachable state (from commit ba21585).&lt;/p&gt;

&lt;p&gt;Fix: the pre-gate step now aborts the rebase and fails before any upload if the rebase doesn't complete cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 4 — Platform behavior inference vs. explicit checking
&lt;/h2&gt;

&lt;p&gt;The targeted workflow_dispatch guard — which controls which video file a manual trigger can publish — was written as a job-level &lt;code&gt;if:&lt;/code&gt; condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.inputs.file != ''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a schedule event, &lt;code&gt;github.event.inputs&lt;/code&gt; is undefined. What GitHub returns for &lt;code&gt;github.event.inputs.file&lt;/code&gt; when inputs is undefined depends on how the platform casts the access — the &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context" rel="noopener noreferrer"&gt;GitHub Actions context documentation&lt;/a&gt; describes the &lt;code&gt;inputs&lt;/code&gt; context as available only for &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;workflow_call&lt;/code&gt; triggers, so accessing it on a schedule trigger is accessing an undefined context. My mental model of what it returns ("empty") wasn't the same as what the platform does. The step below the guard used a bash test: &lt;code&gt;[ -n "${INPUT_FILE:-}" ]&lt;/code&gt;. That pattern was already verified in production. Two places checking the same condition, one inferring platform behavior and one testing it explicitly.&lt;/p&gt;

&lt;p&gt;A second instance in the same review: &lt;code&gt;yt_last_publish_at&lt;/code&gt; parsed &lt;code&gt;uploaded_at&lt;/code&gt; from the queue JSON and compared it to &lt;code&gt;datetime.utcnow()&lt;/code&gt;. All 89 current files end in &lt;code&gt;Z&lt;/code&gt;. A future writer adding a naive timestamp would raise a &lt;code&gt;TypeError&lt;/code&gt; on the aware/naive comparison and take the health check down entirely.&lt;/p&gt;

&lt;p&gt;Fix: the guard moved from &lt;code&gt;if:&lt;/code&gt; to bash, matching the existing pattern. The timestamp now coerces naive values to UTC before comparison (from commit 9f79c39).&lt;/p&gt;

&lt;h2&gt;
  
  
  The common thread
&lt;/h2&gt;

&lt;p&gt;All eight defects came from the same source: the author's knowledge of how the system is supposed to work prevented seeing cases where it doesn't.&lt;/p&gt;

&lt;p&gt;"The rebase step usually succeeds." True. "The monitoring thresholds were correct when I wrote them." Also true. "All timestamps in production end in Z." Still true. None of those thoughts are wrong — they describe how the system works when it works. They make it invisible to the author that anything could go wrong.&lt;/p&gt;

&lt;p&gt;A systematic reviewer doesn't have those thoughts. It reads the code and asks: what does this actually do when the rebase exits non-zero? What does GitHub return for &lt;code&gt;github.event.inputs&lt;/code&gt; on a schedule trigger? What happens when a naive timestamp arrives? Those are first-principles questions that the author stopped asking once the system was built.&lt;/p&gt;

&lt;p&gt;This is the same dynamic I see in &lt;a href="https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo"&gt;pipeline health monitoring with detection lag&lt;/a&gt;: the job kept producing a well-formed file, so nobody looked inside it. The &lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;output inspection pattern&lt;/a&gt; addresses the same failure mode but downstream — catching that outputs are wrong. Systematic code review catches it earlier, before the wrong output is ever produced.&lt;/p&gt;

&lt;p&gt;The shelf scanner project shows the same pattern at the hardware level. The most consequential operational problems weren't model accuracy issues — they were assumption violations: &lt;a href="https://dev.to/morinaga/three-headless-pi-3-failure-modes-i-had-to-handle-before-my-shelf-scanner-stayed-running-2p2f"&gt;scan collisions where cron runs overlapped, SD card exhaustion, and Wi-Fi assumptions in the firstrun hook&lt;/a&gt;. Those were found through operational experience on a PoC that had produced 19 scans, not through code review — because there was no code reviewer for a headless Pi in a room with nobody watching. &lt;a href="https://dev.to/morinaga/four-github-actions-cron-timing-bugs-that-silently-broke-my-daily-pipelines-4935"&gt;GitHub Actions cron timing bugs&lt;/a&gt; follow the same pattern in CI: assumptions about when the job will fire, not whether it runs correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Run systematic review whenever the scheduling or cadence of a pipeline changes. Monitoring thresholds, gate exit behavior, and git operation sequences are tuned to a specific deployment rhythm. Changing that rhythm turns previously correct assumptions into silent mistakes — exactly the category that &lt;a href="https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo"&gt;causes failures to go unnoticed for 36 to 113 days&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The two categories cheapest to fix in review rather than in production: external-system behavior inference (what does platform X return when Y?) and state-assumption carryover (what do thresholds set under one operational model mean under a different one?).&lt;/p&gt;

&lt;p&gt;The two categories a systematic reviewer won't catch: domain correctness ("is this the right threshold for the right business reason?") and requirements drift ("the pipeline is doing what I coded, but not what I actually need"). Those still require the author's judgment.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/four-github-actions-skip-tag-patterns-that-prevent-recursive-publishing-loops-2b2n"&gt;skip-tag patterns that prevent recursive publishes&lt;/a&gt; and the &lt;a href="https://dev.to/morinaga/pausing-a-github-actions-cron-the-yaml-trap-that-breaks-all-workflow-triggers-2nbp"&gt;pausing-workflow trigger traps&lt;/a&gt; are both examples of external-system behavior assumptions that are now explicit guards rather than inferred behavior in the &lt;a href="https://dev.to/morinaga/why-i-reused-a-single-ci-pipeline-for-two-youtube-channels-and-three-seo-sites-50ae"&gt;single CI pipeline running across three sites and two YouTube channels&lt;/a&gt;. Adding a new guard now feels like a checklist item, not a novel decision. That's the direction worth maintaining.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why didn't unit tests catch these?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unit tests verify that code does what the author expects when the assumptions hold. They don't test what happens when the git rebase exits non-zero, because mocking git state at that granularity is harder than fixing the &lt;code&gt;|| true&lt;/code&gt;. The assumption violation — that rebase will succeed — is the thing that needs catching, and unit tests don't challenge assumptions; they execute them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would a human reviewer have caught these?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A human reviewer who knows the codebase and its history shares the author's context. "The monitoring thresholds were set when this was a daily pipeline" is information that makes the thresholds look reasonable. A reviewer without that history, or a systematic reviewer that reasons from the code structure rather than from accumulated context, is more likely to ask "what's the longest healthy gap?" as a first-principles question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should you run systematic review on a pipeline?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On structural changes: cadence, schedule, external dependency, or trigger condition changes. These are precisely the events that turn old assumptions into new mistakes. Per-feature changes are lower risk because they add behavior without changing the operational model the rest of the system assumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this mean the code was badly written?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Eight defects over nine months of production use, all caught before causing observed failures, is a reasonable track record. The point isn't that the code was wrong from the start. The point is that structural changes introduce a class of defect that the author is reliably blind to, and systematic review is a low-cost way to surface it before production does.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>programming</category>
      <category>showdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>BERT-base-uncased, Electra, and why fill-mask models lead production NLP downloads</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:16:00 +0000</pubDate>
      <link>https://dev.to/morinaga/bert-base-uncased-electra-and-why-fill-mask-models-lead-production-nlp-downloads-43c2</link>
      <guid>https://dev.to/morinaga/bert-base-uncased-electra-and-why-fill-mask-models-lead-production-nlp-downloads-43c2</guid>
      <description>&lt;p&gt;When I sort the model catalog in &lt;a href="https://aiappdex.com/" rel="noopener noreferrer"&gt;aiappdex&lt;/a&gt; by total downloads, the top of the list looks nothing like the AI discourse I read daily:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Downloads&lt;/th&gt;
&lt;th&gt;Pipeline tag&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;all-MiniLM-L6-v2&lt;/td&gt;
&lt;td&gt;246,135,287&lt;/td&gt;
&lt;td&gt;sentence-similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MS-MARCO cross-encoder&lt;/td&gt;
&lt;td&gt;83,934,980&lt;/td&gt;
&lt;td&gt;text-ranking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGE-small-en-v1.5&lt;/td&gt;
&lt;td&gt;65,039,030&lt;/td&gt;
&lt;td&gt;feature-extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BERT-base-uncased&lt;/td&gt;
&lt;td&gt;58,556,227&lt;/td&gt;
&lt;td&gt;fill-mask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Electra-base-discriminator&lt;/td&gt;
&lt;td&gt;56,993,793&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase-multilingual-MiniLM-L12&lt;/td&gt;
&lt;td&gt;45,263,676&lt;/td&gt;
&lt;td&gt;sentence-similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGE-M3&lt;/td&gt;
&lt;td&gt;36,725,443&lt;/td&gt;
&lt;td&gt;sentence-similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-0.6B&lt;/td&gt;
&lt;td&gt;21,444,854&lt;/td&gt;
&lt;td&gt;text-generation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Numbers from &lt;code&gt;apps/ai-tools/src/data/models.json&lt;/code&gt; as of this writing.&lt;/p&gt;

&lt;p&gt;None of the top six are large language models. None of them generate text in a conversational sense. Number four is a model from 2018.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fill-mask models are actually used for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fill-mask&lt;/code&gt; is the pipeline tag for masked language models like BERT, which are pretrained to predict a hidden token in context. In 2018 this was a breakthrough. In 2026 nobody blogs about it, but &lt;a href="https://aiappdex.com/models/google-bert-bert-base-uncased/" rel="noopener noreferrer"&gt;BERT-base-uncased&lt;/a&gt; still pulls 58.6 million downloads a month.&lt;/p&gt;

&lt;p&gt;The actual usage is almost always fine-tuning. You take the pretrained checkpoint, attach a classification head, and train on your labeled dataset — customer intent classification, named entity recognition, content moderation labels. At 110M parameters, BERT-base runs on CPU without GPU infrastructure. Fine-tuning takes hours on a laptop. That's why it's in thousands of production ETL pipelines that nobody writes about.&lt;/p&gt;

&lt;p&gt;The 2,910 likes suggest it has genuine fans, not just passive users who followed a tutorial once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Electra: the discriminator that outperforms BERT-base on classification
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/google-electra-base-discriminator/" rel="noopener noreferrer"&gt;Electra-base-discriminator&lt;/a&gt; sits at 56.9 million downloads — within rounding distance of BERT-base. Electra uses a different pretraining objective: instead of predicting masked tokens, it trains a discriminator to identify tokens that were replaced by a generator model. This produces representations that are more sample-efficient to train and, at the same parameter count, often score higher on classification benchmarks.&lt;/p&gt;

&lt;p&gt;In practice the difference is subtle unless you're on a small dataset where the better pretraining generalizes further. Teams that have tried both tend to pick whichever initialized faster for their task and stick with it. The near-identical download counts suggest the user bases are largely overlapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where text-generation fits in these numbers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/qwen-qwen3-0-6b/" rel="noopener noreferrer"&gt;Qwen3-0.6B&lt;/a&gt; sits at 21.4 million downloads — the highest text-generation entry I see in the top tier. It's a capable small language model, and 21M downloads is not a small number. But it sits below BERT-base by a factor of 2.7x.&lt;/p&gt;

&lt;p&gt;Part of that gap is age: BERT has years of integrations, downstream checkpoints, and tutorials that compound the download count. But part is real economics. If the task is classifying 100,000 rows of support tickets into 12 intent categories, fine-tuned BERT on a CPU node is still cheaper and faster to serve than a generative model. The 21M downloads for Qwen3-0.6B reflect genuine adoption of small text-generation; the 58M for BERT reflect a different use case that generative models don't displace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The embedding models above everything else
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/sentence-transformers-all-minilm-l6-v2/" rel="noopener noreferrer"&gt;allMiniLM-L6-v2&lt;/a&gt; at 246M downloads has no close competition. It's a sentence-similarity model from the sentence-transformers library that hit a minimum-viable size-quality tradeoff before anything else did — small enough to run on CPU, good enough to beat BM25 on most semantic search benchmarks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aiappdex.com/models/baai-bge-small-en-v1-5/" rel="noopener noreferrer"&gt;BGE-small-en-v1.5&lt;/a&gt; (65M downloads) and &lt;a href="https://aiappdex.com/models/baai-bge-m3/" rel="noopener noreferrer"&gt;BGE-M3&lt;/a&gt; (36.7M downloads) are the main challengers on quality, with M3 adding multilingual support and longer context. Switching costs and existing integrations keep allMiniLM-L6-v2 dominant even as newer models exceed it on MTEB benchmarks.&lt;/p&gt;

&lt;p&gt;These embedding models are different from fill-mask models in one key way: you use them directly, not as a fine-tuning base. Give them a sentence, get a vector. That makes the integration simpler and the download curve steeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this tells you about production NLP
&lt;/h2&gt;

&lt;p&gt;The top download chart is a lagging indicator of what's deployed at scale, not what's experimentally interesting. Fill-mask and sentence-similarity models show up in this chart because teams found them worth integrating at production volume — not because they're generating benchmark coverage on leaderboards.&lt;/p&gt;

&lt;p&gt;If you're building an NLP system and haven't compared fine-tuned BERT against API-based inference for your classification task, the 58 million downloads are a prompt to check. That count didn't accumulate by accident.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I'm betting on detection lag over uptime for automated pipeline health</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:15:56 +0000</pubDate>
      <link>https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo</link>
      <guid>https://dev.to/morinaga/why-im-betting-on-detection-lag-over-uptime-for-automated-pipeline-health-apo</guid>
      <description>&lt;h2&gt;
  
  
  The problem with "zero errors"
&lt;/h2&gt;

&lt;p&gt;I run several automated pipelines: a daily article publisher, a YouTube video generator, an ETL that pulls from HuggingFace and Steam — all of them on &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule" rel="noopener noreferrer"&gt;GitHub Actions schedule triggers&lt;/a&gt;. For a long time I treated green CI as the signal that things were working. A job that completes with exit code 0 is a healthy job, right?&lt;/p&gt;

&lt;p&gt;Not quite. Over the past several months I've been building a pdca-baseline — a document tracking every known failure in this project, with the commit that introduced it and the commit that found it. When I totaled up the numbers, five separate failures had run undetected for weeks or months:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Days undetected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Steam price field returned the discounted price; every caller quoted it as list price&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Reddit source 403'd; &lt;code&gt;.catch(() =&amp;gt; null)&lt;/code&gt; turned it into an empty array&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;97 consecutive uploads failed the same distribution gate&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Articles published for ~3 months with zero measured human readers&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A hardcoded "~20x" multiplier, measured once after a data anomaly, never recomputed&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The common thread: &lt;strong&gt;the job kept producing a well-formed file&lt;/strong&gt;. An empty array is valid JSON. A 403 that returns null is swallowed by the catch block. A green workflow icon does not mean content shipped.&lt;/p&gt;

&lt;p&gt;I was tracking uptime (100%) and error rate (0%) the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What detection lag actually measures
&lt;/h2&gt;

&lt;p&gt;Detection lag is the number of days between the commit that introduced a fault and the commit that found it. You can calculate it from &lt;code&gt;git log&lt;/code&gt; without any monitoring infrastructure — just find the two commits and subtract.&lt;/p&gt;

&lt;p&gt;The interesting property of this metric is that it rewards a different kind of engineering caution. Uptime pushes you to keep the job running, even if what it's producing is wrong. Detection lag pushes you to check the output, not just the process.&lt;/p&gt;

&lt;p&gt;The fault that went 113 days undetected wasn't a crash. The Steam price scraper kept returning valid JSON with a fresh timestamp and the right field names. The &lt;em&gt;value&lt;/em&gt; was wrong — it was silently reading the discounted price instead of the list price — but nothing in the pipeline validated values, only structure. Green exit code, wrong data, for nearly four months.&lt;/p&gt;

&lt;p&gt;The 91-day case was similar in shape. Articles were being published, cross-posting was working, the pipeline was "healthy." Zero actual humans were reading the output for three months. That failure doesn't show up in any process metric because the process was working. The process was delivering content to a broken distribution path.&lt;/p&gt;

&lt;p&gt;This is the failure mode that &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;three approaches to silent failure detection&lt;/a&gt; tries to address — but naming the metric gives you something concrete to track over time, not just fix in one-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counter-example that sharpened the argument
&lt;/h2&gt;

&lt;p&gt;One failure was caught in approximately two days. A fabricated number made it into an article draft — a claim I had no commit-backed evidence for. The independent review step, reading the staged file, flagged it as an unverified metric. Found and removed in two days.&lt;/p&gt;

&lt;p&gt;That's the contrast that matters. A wrong sentence in a markdown draft got caught in two days. A wrong number in a database field ran for 113 days unchallenged.&lt;/p&gt;

&lt;p&gt;The difference wasn't monitoring sophistication. The code-review step was reading the &lt;em&gt;output&lt;/em&gt; and asking "does this claim have evidence?" — not watching a &lt;em&gt;process&lt;/em&gt; and asking "did the job complete?" I've written about this distinction in &lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;Why I'm betting output inspection beats process monitoring&lt;/a&gt;, but detection lag is the measurable version of that observation. It's what you get when you run the experiment on your own history.&lt;/p&gt;

&lt;p&gt;The pattern also connects to what happened with survivorship bias in my analytics: videos I manually deleted stayed out of the sample, making the channel look like it was improving. The pipeline was running. The output was wrong in a subtle way. The full account is in &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;How I fixed survivorship bias in my YouTube analytics&lt;/a&gt; — that one took a systematic audit, not an alert, to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The falsifiable bet
&lt;/h2&gt;

&lt;p&gt;Here's the version I can be held to: &lt;strong&gt;for solo-developer automation pipelines that produce files rather than expose APIs, detection lag measured from commit history will average more than 30 days across all faults found in a six-month window.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The mechanism behind that prediction: pipelines that write files have no live user traffic to surface wrong values. Nobody sends a 400 error when a JSON file contains the wrong price. Nobody complains when an article is published to an empty audience. The feedback loop is severed at the output boundary.&lt;/p&gt;

&lt;p&gt;If that's true, the monitoring question changes. It isn't "did the job fail?" — that question has a real-time answer you can alert on. It's "how long before someone noticed?" — and that question requires reading what the pipeline actually wrote.&lt;/p&gt;

&lt;p&gt;One cheap heuristic that doesn't require code: if a file that's supposed to change daily hasn't changed in five days, that's worth a look. That single check would have caught at least two of the five failures above. I've been applying a version of this with &lt;a href="https://dev.to/morinaga/what-i-learned-using-git-rev-list-to-diff-a-data-file-across-a-seven-day-window-5030"&gt;git-based diffing of weekly data files&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  An edge AI parallel: designing for a known detection lag
&lt;/h2&gt;

&lt;p&gt;The shelf-scanning system I'm building on a Raspberry Pi 3 runs at a median of 8.5 seconds per inference scan (n=19 real scans; range 8.4–11.8 s). On a Pi 3, that's honest — you don't get fast inference without hardware that costs more.&lt;/p&gt;

&lt;p&gt;The pipeline design acknowledges it. The system runs hourly, and it doesn't confirm a detection until the same gap appears in &lt;strong&gt;at least 2 of the last 3 scans&lt;/strong&gt; — a temporal majority vote. That means the maximum detection lag for a real gap is three scan cycles, or three hours. False positive rate drops because a camera shake or bad frame doesn't trip the alert.&lt;/p&gt;

&lt;p&gt;The tradeoff is explicit: three hours to confirm an empty shelf is the chosen bound. The 8.5 s inference time is acceptable because the cadence is hourly, not real-time. I wrote more about the post-processing design in &lt;a href="https://dev.to/morinaga/the-three-layers-that-made-my-0844-map-shelf-detector-useful-on-a-pi-3-54oc"&gt;Three layers that made the shelf detector useful on a Pi 3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That's a better posture than my publishing pipeline had. The shelf detector has a named detection lag bound. My ETL pipeline had an implicit detection lag of whatever I happened to notice, which turned out to be 113 days.&lt;/p&gt;

&lt;p&gt;The detection accuracy on the held-out test set is mAP50 0.844. That sounds solid, but it's the model accuracy — the detection &lt;em&gt;lag&lt;/em&gt; is a property of the scheduling and voting logic, not the model. You can have a 0.99 mAP model that still takes three months to report a fault if nobody checks the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would change my mind
&lt;/h2&gt;

&lt;p&gt;I'd update this bet if I found a practical process-monitoring setup that reliably catches silent output failures without reading the output. I haven't found one yet — to know that a JSON file contains wrong values, you have to read and validate the values, which means output checking. But if Datadog or Grafana or a similar platform has a mode that samples output content and checks semantic validity, not just "did the file update?", I'd look at it seriously.&lt;/p&gt;

&lt;p&gt;I'd also revise if the cost of output sampling grew high enough to avoid. Right now the checks are cheap: was the file updated? Is the array non-empty? Do the values pass basic plausibility tests (price &amp;gt; 0, title length &amp;gt; 0)? Those are cheap enough that skipping them is hard to defend. If the validation needed to catch failures became expensive, that would shift the math.&lt;/p&gt;

&lt;p&gt;The third scenario: if pipeline architecture improved to make silent failures structurally impossible — say, by making every write a validated, schema-checked transaction — detection lag becomes less interesting because the failure mode is designed away. I'd take that as a win. The metric matters because the failure mode currently exists, not as an end in itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it going forward
&lt;/h2&gt;

&lt;p&gt;I'm treating detection lag as the primary lagging indicator of pipeline health quality across this project. Each time a new failure is documented in the pdca-baseline, I record fault-introduction commit, fault-detection commit, days elapsed. If that number trends down over time, the output-checking strategy is working. The &lt;a href="https://dev.to/morinaga/three-pdca-patterns-that-closed-a-92-day-detection-lag-in-my-content-pipeline-56m1"&gt;pdca-prediction patterns analysis&lt;/a&gt; has the earlier framing of this — detection lag is the number I'm watching now.&lt;/p&gt;

&lt;p&gt;The number I'd most like to report in 30 days: whether the five cases above are outliers or representative for this class of pipeline. I don't know yet. The data will tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you calculate detection lag if git history doesn't perfectly align with deploy timestamps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use commit timestamps, not deploy times. Fault-introduction is the commit that introduced the bug; detection is the commit that documents finding it or the first fix commit. For this project those are usually the same event. It's accurate to within a day or two — close enough to be useful as a trend metric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about failures caught and fixed immediately?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those count, and they're the healthy signal. An average detection lag genuinely under 7 days would mean the output-checking strategy is working. I track all detected failures, not just the slow ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My pipelines don't have month-long failures. Is the 113-day case an anomaly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Maybe. My hypothesis is that file-producing pipelines are more susceptible than API-serving ones, because files have no live error feedback from downstream consumers. If your pipeline serves live traffic and users report issues, you probably catch failures in days not months. Mine don't serve live user traffic in that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does this relate to survivorship bias in analytics?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Directly. The deleted-video survivorship bias ran because the analytics pipeline was producing reports that looked plausible. The flaw was only visible when comparing the pre-deletion and post-deletion samples. That's output validation — comparing what the pipeline said to a ground truth the process logs couldn't see. Same mechanism as the detection-lag problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you prevent this with better schema validation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Partially. Schema validation catches structural errors — missing fields, wrong types, empty arrays. It doesn't catch semantic errors. The Steam price was the right type (number), within a plausible range, just sourced from the wrong field. Catching that requires domain-specific output validation. Schema is a floor, not a ceiling.&lt;/p&gt;




&lt;p&gt;Related reading: &lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;Why I'm betting output inspection beats process monitoring&lt;/a&gt; · &lt;a href="https://dev.to/morinaga/three-pdca-patterns-that-closed-a-92-day-detection-lag-in-my-content-pipeline-56m1"&gt;Three PDCA patterns that closed a 92-day detection lag&lt;/a&gt; · &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;Three approaches to silent failure detection in GitHub Actions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>showdev</category>
      <category>githubactions</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Two times an external API overruled my own repository</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Tue, 08 Sep 2026 10:13:20 +0000</pubDate>
      <link>https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6</link>
      <guid>https://dev.to/morinaga/two-times-an-external-api-overruled-my-own-repository-5dg6</guid>
      <description>&lt;p&gt;Most automated verification tools assume the repository is the source of truth. If the code says X, X is true. If the commit history documents Y, Y happened. It's a reasonable default — until it isn't.&lt;/p&gt;

&lt;p&gt;Two failures I hit this year had the same structure: an automated pass reasoned confidently from the repo, produced a well-formed answer, and was wrong because the actual state lived somewhere the tool couldn't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: the newsletter that didn't exist
&lt;/h2&gt;

&lt;p&gt;While correcting a published article, an automated pass flagged a line claiming there was an active manual newsletter workflow — the article described sending posts by hand to a specific email platform.&lt;/p&gt;

&lt;p&gt;The checker read the article, searched the repo for workflow evidence, found nothing committed, and marked the claim unverifiable. That was the right call for the wrong reason. The repo had nothing because the newsletter platform had nothing.&lt;/p&gt;

&lt;p&gt;One API call settled it: get the publication, list its posts. The response came back with &lt;strong&gt;zero posts and zero drafts&lt;/strong&gt;. Not "posts I couldn't find in the repo" — zero posts on the platform itself. An active manual workflow is impossible when there is nothing to send. The article was wrong about the mechanism; the API told me the mechanism had never been used at all.&lt;/p&gt;

&lt;p&gt;The repo couldn't have told me that. The repo can document what was committed; it cannot document the absence of activity on an external platform. Those are different things, and treating them the same is how the automated pass stayed confidently wrong for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: the rejection count that kept changing
&lt;/h2&gt;

&lt;p&gt;A different article mentioned four AdSense rejections. The number came from the repo's documented history: four entries, each timestamped.&lt;/p&gt;

&lt;p&gt;An automated fact-check pass later changed the article to seven rejections, sourced from the author's own records kept outside the repo. A second automated pass changed it back to four, re-reading the repository.&lt;/p&gt;

&lt;p&gt;The actual count is seven. Rejections five, six, and seven arrived as verbal notifications — emails read but never committed. The repo's four was accurate for what was committed; it understated the real count by three.&lt;/p&gt;

&lt;p&gt;Two automated passes disagreed with each other not because of a bug but because they each read different sources, both internally consistent, with no signal indicating which was authoritative. The repo said four and was internally consistent. The external records said seven and were internally consistent. The only way to resolve the conflict was to check which source had the more complete view — and the answer was "neither alone."&lt;/p&gt;

&lt;p&gt;The fix was mechanical: commit the three missing entries. The harder question is the design one underneath it: &lt;strong&gt;when is a repository entry complete, and when is it a documented subset of a larger set?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For this project, the honest answer is that commit history is the subset. Things happen that don't generate commits — API calls that fail silently and get noticed weeks later, verbal information that comes in during a meeting, external systems that change state without notifying the repo. A tool that only reads the repo will confidently "correct" true statements into false ones whenever the truth lives somewhere the repo cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern on a Raspberry Pi
&lt;/h2&gt;

&lt;p&gt;The shelf scanner I run on a Raspberry Pi runs a scan every hour and writes the result to a JSON log on the Pi's local filesystem. That log then syncs to cloud storage.&lt;/p&gt;

&lt;p&gt;Whether the scan actually ran is not in the GitHub repository. CI has a record of each firmware deploy — that's the repo's version of events. But a green deploy commit is evidence the software was updated, not that the scan executed. The freshness of the scan history lives in the Pi's own output, not in git.&lt;/p&gt;

&lt;p&gt;When I built a check for stale scan history — "has the scanner produced a result in the last two hours?" — I had to read the Pi's actual output file rather than querying git log. The repo couldn't answer that question. The scan log could.&lt;/p&gt;

&lt;p&gt;This is the same structure as the newsletter and rejection cases: the authoritative source for a fact depends on what the fact &lt;em&gt;is&lt;/em&gt;, not on where the code that produces it lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in practice
&lt;/h2&gt;

&lt;p&gt;For the newsletter: the article was corrected to remove the manual-workflow claim. One API call, one correction, done.&lt;/p&gt;

&lt;p&gt;For the rejections: three entries were committed to the repo. The article now reflects seven. The automated checker no longer contradicts itself across passes.&lt;/p&gt;

&lt;p&gt;The broader fix was a distinction in the pipeline between two different failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Claim not found in repo"&lt;/strong&gt; — this means: look externally before marking it false. The repo doesn't hold everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Claim contradicted by repo"&lt;/strong&gt; — this is a real flag. If the repo's own evidence contradicts a claim, that needs investigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating these identically was the actual bug. &lt;a href="https://dev.to/morinaga/why-im-betting-output-inspection-beats-process-monitoring-for-solo-developer-pipelines-2kn9"&gt;Output inspection — reading what the pipeline actually produced — is different from process inspection&lt;/a&gt;, and this is a case where the output lives outside the repo entirely. The repo is a very good source of truth for code and deliberate commits. It's a poor source of truth for anything that was never committed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>showdev</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
