<?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: Ken Imoto</title>
    <description>The latest articles on DEV Community by Ken Imoto (@kenimo49).</description>
    <link>https://dev.to/kenimo49</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%2F3800250%2F275022f6-cba9-47e3-b69e-e8faf7675a0c.jpg</url>
      <title>DEV Community: Ken Imoto</title>
      <link>https://dev.to/kenimo49</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenimo49"/>
    <language>en</language>
    <item>
      <title>5 PR Metrics on 303 Merged PRs from pytest and pydantic: 2 Point at Review Bottlenecks</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/5-pr-metrics-on-303-merged-prs-from-pytest-and-pydantic-2-point-at-review-bottlenecks-2o17</link>
      <guid>https://dev.to/kenimo49/5-pr-metrics-on-303-merged-prs-from-pytest-and-pydantic-2-point-at-review-bottlenecks-2o17</guid>
      <description>&lt;p&gt;Engineering-productivity dashboards tend to show the same five pull-request metrics: Time to First Review, Time to Merge, Comments per PR, Review Cycles, and AI Resolution Rate. Each gets a card and a trend line.&lt;/p&gt;

&lt;p&gt;I wanted to know which of them can answer one narrow question: "is review the slow part of shipping a change?" My own repositories are no help here. I work alone, and my busiest repo merged four PRs in the last 90 days. So I pointed a script at two public projects with real review traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pytest-dev/pytest&lt;/strong&gt;: 107 merged PRs from humans (166 merged, 59 from bots), 46 authors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pydantic/pydantic&lt;/strong&gt;: 196 merged PRs from humans (221 merged, 25 from bots), 40 authors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The window is PRs merged between 2026-07-21 and 2026-10-19 (UTC). All numbers below come from the GitHub REST API, and you can rerun the script to check them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# review_metrics.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Github&lt;/span&gt;

&lt;span class="n"&gt;REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pytest-dev/pytest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Github&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_repo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desc&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;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# sorted by updated: nothing older can have merged in the window
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[bot]&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="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;
    &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_reviews&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;approvals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPROVED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hours&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;

    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merged_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_review_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&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;first&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_approval_to_merge_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&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;approvals&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review_comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;review_comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cycles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHANGES_REQUESTED&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;APPROVED&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;changed_lines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;additions&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deletions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&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;n=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  median=&lt;/span&gt;&lt;span class="si"&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;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  mean=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  merged &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;..&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  human PRs: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;print&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;PRs with no review from anyone but the author: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;first_review_h&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&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;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_review_h&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;merge_h&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;last_approval_to_merge_h&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;review_comments&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;cycles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three definitions matter. A "review" is any submitted GitHub review (approve, request changes, or comment) from someone other than the PR author. &lt;code&gt;review_comments&lt;/code&gt; counts inline code comments only, not the conversation tab. A "cycle" is one approve or request-changes event.&lt;/p&gt;

&lt;p&gt;Raw output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pytest-dev/pytest  merged 2026-07-21..2026-10-19  human PRs: 107
PRs with no review from anyone but the author: 0
first_review_h             n=107  median=    9.2  mean=  284.6
merge_h                    n=107  median=  143.4  mean=  806.0
last_approval_to_merge_h   n=107  median=    1.8  mean=  128.3
review_comments            n=107  median=    0.0  mean=    3.3
cycles                     n=107  median=    1.0  mean=    1.5

pydantic/pydantic  merged 2026-07-21..2026-10-19  human PRs: 196
PRs with no review from anyone but the author: 100
first_review_h             n= 96  median=    0.2  mean=   38.2
merge_h                    n=196  median=    1.6  mean=   63.4
last_approval_to_merge_h   n= 41  median=    0.3  mean=   28.7
review_comments            n=196  median=    0.0  mean=    0.8
cycles                     n=196  median=    0.0  mean=    0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in that output are worth more than any single metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the average describes five PRs
&lt;/h2&gt;

&lt;p&gt;pytest's mean Time to First Review is 284.6 hours. The median is 9.2 hours. A dashboard that shows the mean says "nearly 12 days"; nearly two thirds of the PRs got one within 24 hours.&lt;/p&gt;

&lt;p&gt;The gap comes from a handful of old PRs. Five of the 107 were opened between January and early July 2026 and only merged inside the window. Their first-review waits (5,568h, 5,415h, 4,261h, 3,305h, 1,650h) add up to 66% of all first-review hours in the sample. Drop those five and the mean falls to 100.5 hours.&lt;/p&gt;

&lt;p&gt;The spread is wide even without them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pytest, Time to First Review&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25th percentile&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;9.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75th percentile&lt;/td&gt;
&lt;td&gt;95.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90th percentile&lt;/td&gt;
&lt;td&gt;507.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;43 of 107 PRs (40%) got a first review within 4 hours, 69 (64%) within 24 hours, and 20 waited more than a week. "Share of PRs reviewed within N hours" plus the 90th percentile says far more than an average.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the metric quietly skips half the PRs
&lt;/h2&gt;

&lt;p&gt;In pydantic, 100 of 196 human PRs were merged without any GitHub review from someone other than the author. 75 of those 100 came from a single maintainer. Unreviewed PRs merged in a median of 0.3 hours, reviewed ones in 7.5 hours.&lt;/p&gt;

&lt;p&gt;Time to First Review cannot be computed for a PR that never got a review, so the 0.2-hour median above describes only 96 of the 196 PRs. Whether merging without a review is fine depends on the project's rules, and I am not judging pydantic's. The dashboard problem is that the number looks complete when it covers 49% of merges. Put the coverage next to the metric: "median 0.2h, 96 of 196 PRs reviewed".&lt;/p&gt;

&lt;h2&gt;
  
  
  The two metrics that point at review
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time to First Review&lt;/strong&gt;, reported as a median and percentiles with coverage, measures the stretch where a PR waits and no reviewer has acted. That wait is a review-process delay by definition.&lt;/p&gt;

&lt;p&gt;Published research gives a reference point. Google's 2018 case study of its own code review, based on logs of about nine million reviewed changes, reports a median wait for initial feedback of under an hour for small changes and about 5 hours for very large ones, with an overall median under 4 hours for the whole review process (&lt;a href="https://sback.it/publications/icse2018seip.pdf" rel="noopener noreferrer"&gt;Sadowski et al., ICSE-SEIP 2018&lt;/a&gt;). That is a large company with dedicated tooling, so treat it as a reference point rather than a target for a volunteer project.&lt;/p&gt;

&lt;p&gt;The lever with the strongest evidence is explicit assignment. Meta suspected that diffs assigned only to a team were slowed by the bystander effect ("someone else will probably review this") and tested it. In an A/B test on 12.5k authors in spring 2023, it randomly assigned one of the top three recommended reviewers on team-only diffs. TimeInReview fell 11.6%, with no significant change in the time reviewers spent on each diff (&lt;a href="https://arxiv.org/abs/2312.17169" rel="noopener noreferrer"&gt;Improving Code Reviewer Recommendation, ACM TOSEM 2026, arXiv:2312.17169&lt;/a&gt;). You do not need Meta's recommender to try the idea. GitHub can &lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" rel="noopener noreferrer"&gt;request code owners automatically&lt;/a&gt;, and team review requests can be &lt;a href="https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team" rel="noopener noreferrer"&gt;routed to individual members by round robin or load balance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review Cycles&lt;/strong&gt; measures whether review converges. In pytest, 68 of 107 PRs merged after one approve-or-request-changes event, 26 after two, and 13 after three to five. Cycles barely tracked PR size (Spearman 0.19 against changed lines), so a high count is less about big diffs and more about how a review gets run, for example comments arriving in several rounds. That makes it a review-process signal you can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three that answer other questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time to Merge&lt;/strong&gt; mixes three intervals: waiting for a first review, the back-and-forth until the last approval, and the wait from last approval to merge. I split every pytest PR into those three segments and looked at which one was longest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Longest segment of Time to Merge (pytest)&lt;/th&gt;
&lt;th&gt;PRs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Waiting for first review&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval to merge&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First review to last approval&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Summed over all PRs, the split was 35% waiting, 49% back-and-forth, 16% after approval. One number covers three different problems, and the approval-to-merge part (CI, merge queues, release timing) is outside review entirely. Time to Merge is still a fine throughput metric. It just cannot tell you review is the bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments per PR&lt;/strong&gt; mostly tracks size. In pytest, inline comment count correlated with changed lines at Spearman 0.53, and 67 of 107 PRs (63%) had zero inline comments. In pydantic, 135 of 196 had none. A month-over-month rise in the average usually reflects a shift in PR mix. What it can flag is a sustained zero on large PRs, which is worth a look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI Resolution Rate&lt;/strong&gt; cannot be computed from the GitHub API alone, because GitHub does not record whether a bot comment was "resolved" in the sense the metric needs. You need data from the AI review tool itself, so I did not measure it here. One structural caution applies anyway: the rate depends on what the bot chooses to flag, so changing the tool or its rules moves the number without any change in how your team responds. Compare it only within one tool and one configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would put on the dashboard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Time to First Review: median, 90th percentile, share reviewed within 4h and 24h, and how many merged PRs had a review at all&lt;/li&gt;
&lt;li&gt;Review Cycles: distribution (1 / 2 / 3+) alongside the mean&lt;/li&gt;
&lt;li&gt;Time to Merge, Comments per PR, AI Resolution Rate: on a separate throughput or tooling page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rerun the script on your own repo before you trust any of these. The two projects here behaved so differently (pytest reviews every PR, pydantic merged half without one) that a single benchmark would have misled both.&lt;/p&gt;

&lt;p&gt;The longer version, with the three-layer review model (hooks, AI review, human review), the CodeRabbit configuration, and a GitHub Actions workflow that writes a weekly metrics report, is in my book &lt;a href="https://kenimoto.dev/books/harness-code-review/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=5-pr-metrics-review-signal" rel="noopener noreferrer"&gt;Systematizing AI Code Review&lt;/a&gt;. The English edition is on Kindle.&lt;/p&gt;




&lt;p&gt;Sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/pytest-dev/pytest" rel="noopener noreferrer"&gt;pytest-dev/pytest&lt;/a&gt; and &lt;a href="https://github.com/pydantic/pydantic" rel="noopener noreferrer"&gt;pydantic/pydantic&lt;/a&gt;, GitHub REST API, PRs merged 2026-07-21 to 2026-10-19&lt;/li&gt;
&lt;li&gt;Sadowski, Söderberg, Church, Sipko, Bacchelli. &lt;a href="https://sback.it/publications/icse2018seip.pdf" rel="noopener noreferrer"&gt;Modern Code Review: A Case Study at Google&lt;/a&gt;. ICSE-SEIP 2018&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2312.17169" rel="noopener noreferrer"&gt;Improving Code Reviewer Recommendation: Accuracy, Latency, Workload, and Bystanders&lt;/a&gt;. ACM TOSEM 35(1), 2026&lt;/li&gt;
&lt;li&gt;GitHub Docs: &lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" rel="noopener noreferrer"&gt;About code owners&lt;/a&gt;, &lt;a href="https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team" rel="noopener noreferrer"&gt;Managing code review settings for your team&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>devops</category>
      <category>github</category>
      <category>opensource</category>
    </item>
    <item>
      <title>RTX 4070 + logprobs: 96 of 100 Claude Code Requests Needed the Cloud. 97 of 200 Steps Could Go Local</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/rtx-4070-logprobs-96-of-100-claude-code-requests-needed-the-cloud-97-of-200-steps-could-go-local-1nn1</link>
      <guid>https://dev.to/kenimo49/rtx-4070-logprobs-96-of-100-claude-code-requests-needed-the-cloud-97-of-200-steps-could-go-local-1nn1</guid>
      <description>&lt;p&gt;Routing whole requests to a local LLM did not work for my Claude Code workload. I took 100 jobs from my work log (March to September 2026), rewrote each one as the instruction I would have typed, and labelled where it could run. 96 needed a frontier model. The other 4 had to stay local because they contained personal or financial details, not because a local model could do them. Zero were simply "a local model is good enough."&lt;/p&gt;

&lt;p&gt;Then the unit went down to single steps inside agent sessions. Out of 200 tool-call steps pulled from my Claude Code logs, 97 were labelled "a 4B to 9B local model is enough if you hand it the material." That is 48.5%.&lt;/p&gt;

&lt;p&gt;The step-level router that came out of this is on GitHub under MIT: &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;&lt;code&gt;kenimo49/local-step-router&lt;/code&gt;&lt;/a&gt;. This post covers the numbers behind it, what the router code actually looks like, and where a logprobs judge on an RTX 4070 helped and where it did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why request-level routing failed
&lt;/h2&gt;

&lt;p&gt;A request like "fix the flaky test" is a label, not a payload. Nothing in the text tells a local model what to do until something reads the repo. In the 100 reconstructed requests, the task mix was mostly agent work (74 of 100), and agent work is exactly what you hand Claude Code in the first place.&lt;/p&gt;

&lt;p&gt;Raw instructions told the same story. Of 150 instructions sampled from interactive sessions, 138 only made sense with the earlier conversation ("send it", "do the daily log"), and only 5 were labelled local-capable. Of 100 fixed prompts that my cron pipeline sends to &lt;code&gt;claude -p&lt;/code&gt;, also 5.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Local is enough&lt;/th&gt;
&lt;th&gt;Must stay local (sensitive)&lt;/th&gt;
&lt;th&gt;Frontier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests rebuilt from the work log&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw instructions&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed pipeline prompts&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single steps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which steps go local
&lt;/h2&gt;

&lt;p&gt;The 200 steps came from three tool types in the logs. The split is lopsided:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step type&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Local is enough&lt;/th&gt;
&lt;th&gt;Must stay local&lt;/th&gt;
&lt;th&gt;Frontier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WebFetch&lt;/code&gt; (extract something from a page)&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git commit&lt;/code&gt; message&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-agent delegation&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Almost all of the local share is &lt;code&gt;WebFetch&lt;/code&gt;. A fetch prompt usually says what to pull out ("the price", "the release date"), and once the page text is in hand, that is extraction. Commit messages go local only when they are one-line summaries of a diff. Sub-agent delegation is research and multi-step tool use, and it stays frontier.&lt;/p&gt;

&lt;p&gt;Two caveats matter here. The labels are judgments, not runs: "local is enough" means "given the page body or the diff." And they were assigned by my AI assistant, with my review planned only for disagreements.&lt;/p&gt;

&lt;p&gt;So I ran a sample. 20 of the 86 local-labelled &lt;code&gt;WebFetch&lt;/code&gt; steps went to qwen3.5:9b on Ollama (&lt;code&gt;think=false&lt;/code&gt;, temperature 0), with the page re-fetched and extracted by trafilatura. 11 could be compared with the original output: 8 matched, 2 were partly wrong (a merged-cell spec table read into the wrong column, a title reported as missing), and 1 was wrong (it quoted "about 1 to 3 months" as evidence and still answered yes to "does it say 2 to 3 months?"). The other 9 never reached a fair comparison: JS-rendered pages with no extractable text, pages that had disappeared, or an original fetch that had already failed. Local processing took 6 to 66 seconds per item, median about 18. Most of the loss happened before the model, in fetching and extracting the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The router: rules first
&lt;/h2&gt;

&lt;p&gt;The repo's &lt;code&gt;Router&lt;/code&gt; checks rules top to bottom, first match wins. A probability judge only runs for steps no rule matched, and it is off by default. This is the shipped &lt;code&gt;examples/routing.yaml&lt;/code&gt;, rules part:&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# step rules, top to bottom, first match wins&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;WebFetch&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Agent&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;commit_message&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;         &lt;span class="c1"&gt;# frontier until a probability judge is added&lt;/span&gt;

&lt;span class="na"&gt;probability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;           &lt;span class="c1"&gt;# off until measured with the same judge model (chapter 14)&lt;/span&gt;
&lt;span class="na"&gt;default_route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And from Python:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;steprouter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;steprouter.router&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;examples/routing.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WebFetch&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;prompt&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;Extract the price table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;# Decision(route='local', by='rule', p_local=None)
&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;# Decision(route='frontier', by='default', p_local=None)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Decision.by&lt;/code&gt; tells you whether a rule, the probability judge, or the default made the call.&lt;/p&gt;

&lt;p&gt;Why rules first: on the 444 non-sensitive items across all three sets (107 labelled local-capable), I compared a single rule against asking a local model for P(local is enough):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Router&lt;/th&gt;
&lt;th&gt;Local-capable items caught (of 107)&lt;/th&gt;
&lt;th&gt;Frontier items sent local by mistake (of 337)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.5:9b probability, threshold set for at most 5% wrong-locals (AUROC 0.809)&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4:e4b probability, same calibration (AUROC 0.618)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One rule: &lt;code&gt;WebFetch&lt;/code&gt; goes local&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 9b judge's AUROC of 0.809 was mostly the judge recognising the step type. Within one type it could barely separate items: 0.095 on the pipeline prompts, 0.411 on raw instructions. If the step type decides, write a rule. Keep the probability for what the rules leave open.&lt;/p&gt;

&lt;p&gt;I also tried qwen3.5:4b as the routing judge. It timed out under GPU contention at 414 of 450 items, so it has no routing result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading logprobs from Ollama
&lt;/h2&gt;

&lt;p&gt;Where the local model does earn its place is the entrance gate: "does this request contain actual sensitive data?" The judge asks for a one-letter answer (Y or N), generates exactly one token, and reads the candidate letters from &lt;code&gt;top_logprobs&lt;/code&gt;. In the repo this is &lt;code&gt;steprouter/judge.py&lt;/code&gt;, using the native &lt;code&gt;/api/chat&lt;/code&gt; with &lt;code&gt;think: false&lt;/code&gt;, &lt;code&gt;num_predict: 1&lt;/code&gt; and &lt;code&gt;top_logprobs: 20&lt;/code&gt;. It returns the probabilities renormalised over the candidates, plus &lt;code&gt;mass&lt;/code&gt;: how much probability landed on the candidates at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mass&lt;/code&gt; is the field to watch, because two things make it collapse without any error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first token is not the answer.&lt;/strong&gt; A model that reasons before answering spends token 0 on its reasoning. With &lt;code&gt;think: false&lt;/code&gt;, qwen3.5 answers in the first token. gpt-oss:20b did not put a label there and gave mass 0 on my setup, so I could not use it this way. The other models I tried stayed between 0.89 (qwen3.5:0.8b) and 1.00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A candidate outside the top 20 reads as 0.&lt;/strong&gt; Ollama rejects &lt;code&gt;top_logprobs&lt;/code&gt; above 20 (&lt;a href="https://github.com/ollama/ollama/blob/main/server/routes.go" rel="noopener noreferrer"&gt;the check in &lt;code&gt;server/routes.go&lt;/code&gt;&lt;/a&gt;; &lt;a href="https://github.com/ollama/ollama/pull/18591" rel="noopener noreferrer"&gt;a PR to raise it to 100&lt;/a&gt; was closed without merging). If a candidate is not in that list, its probability is simply missing, and the others share 100%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo's judge marks a result unusable when &lt;code&gt;mass&lt;/code&gt; is below 0.5. A first test on any new judge model: send one known question and check that mass is close to 1.&lt;/p&gt;

&lt;p&gt;One correction to something you may have read, including in an earlier version of my own notes: the OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint does return logprobs. The &lt;a href="https://github.com/ollama/ollama/releases/tag/v0.12.11" rel="noopener noreferrer"&gt;v0.12.11 release notes&lt;/a&gt; say both APIs support them, and in &lt;a href="https://github.com/ollama/ollama/issues/16117" rel="noopener noreferrer"&gt;issue #16117&lt;/a&gt; a maintainer showed it working before the reporter closed it as their own client bug. The compatibility page in the docs still marks logprobs unsupported. The repo uses the native endpoint, so it does not depend on which of those is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threshold is not 0.5
&lt;/h2&gt;

&lt;p&gt;qwen3.5:4b ranked the 60 English test requests almost perfectly for sensitive content (AUROC 0.999), but its P(Y) stayed low. With a fixed 0.5 line it missed all 20 sensitive requests. Drawing the line from labelled examples with 5-fold cross-validation brought that to 2 misses and 1 false alarm. On the Japanese version of the same set, the fixed line missed 9 of 20 and the calibrated one missed 1.&lt;/p&gt;

&lt;p&gt;The repo's CLI does this from your own judged examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/judge_set.py qwen3.5:4b en &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; judged.jsonl
steprouter-threshold judged.jsonl &lt;span class="nt"&gt;--margin&lt;/span&gt; 0.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints JSON with &lt;code&gt;auroc&lt;/code&gt;, the &lt;code&gt;line&lt;/code&gt; (lowest P among your positive examples times the margin), and 5-fold &lt;code&gt;cv&lt;/code&gt; counts of misses and false alarms, and it warns when AUROC is under 0.95. The data set is 60 made-up requests, so treat these as small-sample numbers, and redraw the line on your own labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the RTX 4070 fits
&lt;/h2&gt;

&lt;p&gt;All of the judging above ran through Ollama on one RTX 4070 12 GB (version 0.20.2 for the 60-request tests). Small judges were not faster on that box: qwen3.5:0.8b through 9b all took about 0.5 to 0.7 seconds for two questions, mostly network round trip, while qwen3.5:35b-a3b took about 3.3 seconds because it does not fit in 12 GB. If you want a 35B MoE model on the same card for heavier local work, the &lt;code&gt;--cpu-moe&lt;/code&gt; flag in llama.cpp took it from 12.2 to 34.6 tok/s in &lt;a href="https://kenimoto.dev/blog/rtx-4070-cpu-moe-flag-2-8x-tokens/" rel="noopener noreferrer"&gt;a separate benchmark&lt;/a&gt;. That benchmark is about speed; the routing numbers above did not use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count your own steps before tuning anything.&lt;/strong&gt; My 48.5% is almost entirely &lt;code&gt;WebFetch&lt;/code&gt;. A workload with fewer fetches will get a smaller share.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide by step type first.&lt;/strong&gt; One &lt;code&gt;WebFetch&lt;/code&gt; rule beat both probability judges in my data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;mass&lt;/code&gt;, then draw the threshold from your own labels.&lt;/strong&gt; A fixed 0.5 missed every sensitive English request with qwen3.5:4b.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code: &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;&lt;code&gt;kenimo49/local-step-router&lt;/code&gt;&lt;/a&gt;. MIT, Python 3.10+, PyYAML as the only dependency, Ollama 0.12.11 or later for the judge. The tests run without Ollama.&lt;/p&gt;

&lt;p&gt;The full write-up, with the step labelling criteria, the sensitive-data gate, and the typed return values, is the book this repo accompanies: &lt;a href="https://kenimoto.dev/books/local-llm-step-routing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rtx-4070-logprobs-router" rel="noopener noreferrer"&gt;Local LLM or Claude Code?&lt;/a&gt; Kindle, included in Kindle Unlimited.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GitHub Stars Lied: 3 Tech-Selection Traps Senior Devs Still Walk Into</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/github-stars-lied-3-tech-selection-traps-senior-devs-still-walk-into-4f82</link>
      <guid>https://dev.to/kenimo49/github-stars-lied-3-tech-selection-traps-senior-devs-still-walk-into-4f82</guid>
      <description>&lt;p&gt;In the Stack Overflow Developer Survey 2025, 17.6% of respondents said they had done extensive work in Vue.js over the past year. On W3Techs (checked 2026-10-19), Vue.js runs on 0.6% of all websites. That is a 29x spread.&lt;/p&gt;

&lt;p&gt;Angular: 18.2% in the survey, 0.2% of websites. About 90x. React, the safe pick: 44.7% against 6.0%, about 7x.&lt;/p&gt;

&lt;p&gt;Neither number is wrong. The survey counts developers who answered a question about frameworks. W3Techs counts every website it can detect, and most of those are not hand-built single-page apps. The spread mostly says that the two denominators have nothing to do with each other.&lt;/p&gt;

&lt;p&gt;That is the useful part. Every popularity number answers a question about some population, and in a tech-selection meeting nobody asks which population. GitHub stars are the most quoted popularity number of all, and they have a population problem of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the bandwagon effect, dressed as "community size"
&lt;/h2&gt;

&lt;p&gt;The bandwagon effect is the tendency to judge something as right because many people have adopted it. Part of that is rational. A widely used tool has more documentation, a more mature ecosystem, and a bigger hiring pool. The trouble starts when that advantage outweighs every other axis.&lt;/p&gt;

&lt;p&gt;GitHub stars, npm download counts, Stack Overflow answers. All three signal community size. None of them signal fit for your project. As of 2026-10-19 (GitHub API), &lt;code&gt;apache/echarts&lt;/code&gt; has 67.5k stars, &lt;code&gt;chartjs/Chart.js&lt;/code&gt; has 67.7k, and &lt;code&gt;d3/d3&lt;/code&gt; has 113.8k. ECharts and Chart.js look like twins on a comparison table. D3 looks like the winner. D3 is a low-level toolkit for building visualizations; the other two hand you finished chart types. The star column cannot show that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some of the stars are not people
&lt;/h3&gt;

&lt;p&gt;A study by He et al., published at ICSE 2026, built a detector called StarScout and ran it over all GitHub event data from July 2019 to December 2024. It flagged 6.0 million suspected fake stars and 18,617 repositories with fake-star campaigns. Fake stars stayed at or below 1% of all stars in any month, but campaigns grew from 2022 and surged in 2024. Most of them promoted short-lived phishing malware repositories. The rest went mostly to AI/LLM, blockchain, tool, and tutorial repos.&lt;/p&gt;

&lt;p&gt;The paper also measured whether buying stars works. The effect on real attention lasts under two months, is about 5x smaller than the effect of real stars, and turns negative over the long term.&lt;/p&gt;

&lt;p&gt;So for a big, old project, fake stars are a rounding error. For a young repo in a hyped category, the star count can be partly bought. Either way, stars are a weak input, and the bandwagon effect makes them the loudest one.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the effect gets stronger
&lt;/h3&gt;

&lt;p&gt;The bandwagon effect strengthens under three conditions, and all three describe a normal tech-selection meeting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your own expertise in the domain is shallow (so you lean on "what everyone picks")&lt;/li&gt;
&lt;li&gt;The decision carries visible blame if it fails (so "we picked the popular one" is the safe story)&lt;/li&gt;
&lt;li&gt;You are under time pressure (so detailed comparison gets compressed into "which has more stars")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fifty thousand stars on one framework and five hundred on a competitor is the setup that triggers all three, even when the smaller one fits your use case better.&lt;/p&gt;

&lt;h3&gt;
  
  
  What catches it: a requirements matrix
&lt;/h3&gt;

&lt;p&gt;Telling the room to "be careful about stars" does nothing. Changing the table does.&lt;/p&gt;

&lt;p&gt;List the project's requirements before you look at candidates: performance targets, the team's existing skills, licensing, long-term support, learning cost. Then add community size as one more row, weighted like the others. Score every candidate on every row. Stars now contribute one row out of six, and the five rows that actually describe your project get filled in before anyone opens a GitHub tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the IKEA effect on "the thing we built ourselves"
&lt;/h2&gt;

&lt;p&gt;The IKEA effect is the tendency to overvalue things you put labor into. Norton, Mochon, and Ariely described it after experiments in which people assembled IKEA boxes, folded origami, and built Lego sets, then valued their own results above equivalent ready-made ones. Code is labor you can see, so the effect carries over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An in-house framework that two engineers wrote in a hackathon five years ago, defended with "it fits our needs exactly"&lt;/li&gt;
&lt;li&gt;A custom build config that only one person can modify, kept because "replacing it is risky"&lt;/li&gt;
&lt;li&gt;A fork of a library that has drifted three major versions, maintained because "we invested a lot of time understanding it"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not Invented Here syndrome is the acute version. The chronic version is quieter: a steady preference for the thing that has your team's hours in it.&lt;/p&gt;

&lt;p&gt;Market numbers will not prove the effect for you. npm counted 76.6 million Vitest downloads and 26.0 million Jest downloads in the week of 2026-10-10 to 10-16. Jest is still a very large installed base. Some of those projects stay because Jest fits them; some stay because of years of local config. The download count cannot tell you which, and neither can you, until you ask the question below about your own stack.&lt;/p&gt;

&lt;p&gt;The point to hold on to: "we built it" is a fact about past labor. It says nothing about quality. The question is what the next twelve months of maintenance will cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  The question that defuses it
&lt;/h3&gt;

&lt;p&gt;One line, on a sticky note by the monitor:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this system did not exist and we were choosing today, would we pick this?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A no does not mean "migrate now". Migration costs are real. A no means the current choice may be held in place by the IKEA effect plus the next trap, and deserves an actual comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: sunk cost, also known as the Concorde fallacy
&lt;/h2&gt;

&lt;p&gt;Everyone can define the sunk cost fallacy, and almost nobody applies it. "We have already invested three months in this refactor" is the shape. The three months are gone. They do not come back if you continue, and they do not vanish if you stop. The only live comparison is the cost of continuing from here against the cost of stopping and switching from here.&lt;/p&gt;

&lt;p&gt;The nickname comes from Concorde. Britain and France signed the development treaty in 1962, and costs overran the original estimate many times over. The British government privately regarded the project as a commercial disaster that should never have been started, yet both governments kept paying. Political and legal commitments made withdrawal hard, which is a lesson in itself: sunk cost rarely acts alone. Twenty aircraft were built, fourteen entered airline service, and the fleet retired in 2003.&lt;/p&gt;

&lt;p&gt;In engineering, sunk cost shows up as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deferred refactors ("throwing this code away wastes three months of work")&lt;/li&gt;
&lt;li&gt;A struggling project that keeps going ("we are 60% of the way there")&lt;/li&gt;
&lt;li&gt;Unused features kept alive ("we went to the trouble of building it")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those sentences is a sunk-cost claim in disguise.&lt;/p&gt;

&lt;h3&gt;
  
  
  The forward-only decision frame
&lt;/h3&gt;

&lt;p&gt;The frame that works gives "already spent" zero weight. Three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set all past investment to zero. Looking only at future cost and return, which option wins?&lt;/li&gt;
&lt;li&gt;What does staying on this choice cost over the next year (maintenance hours, learning cost for new hires, effect on hiring)?&lt;/li&gt;
&lt;li&gt;What is the one-time cost of switching, and how much does it save per year afterward? Does the saving pay back the switch within a horizon that matters?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Already spent" is the loudest voice in a technical meeting. It is also the one voice that cannot change the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hype cycle chains all three
&lt;/h2&gt;

&lt;p&gt;Gartner's hype cycle describes technology maturity in five phases, and a different bias dominates each part of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Innovation Trigger to Peak of Inflated Expectations&lt;/strong&gt;: the bandwagon effect at maximum. "Do not get left behind" overrides every other signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trough of Disillusionment&lt;/strong&gt;: negativity bias takes over, and the technology gets written off below its real value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slope of Enlightenment to Plateau of Productivity&lt;/strong&gt;: calm evaluation finally becomes possible, but whatever was adopted at the peak is now defended by sunk cost ("we cannot throw it away now") and the IKEA effect ("we worked hard to make it run").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you adopted during the current AI tooling peak will meet this chain on schedule. When the plateau arrives, the choice will be defended by two biases at once, and the forward-only frame is the tool for that review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sentences for the wall
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;GitHub stars are one row out of six in the requirements matrix. Not a tiebreaker.&lt;/li&gt;
&lt;li&gt;"If this did not exist, would we pick it today?" Ask it for every stack you maintain, once a quarter.&lt;/li&gt;
&lt;li&gt;Money already spent is zero on the future-cost line. The only live comparison is forward.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tech selection is a judgment you keep revisiting, and the three biases keep running the whole time. The sentences do not switch them off. They make the room notice when a bias is making the decision, and that is usually enough to get the actual comparison onto the whiteboard.&lt;/p&gt;

&lt;p&gt;These three traps and the hype-cycle chain are Chapter 5 of &lt;a href="https://kenimoto.dev/books/engineer-psychology-tricks?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=github-stars-lied-3-traps" rel="noopener noreferrer"&gt;&lt;em&gt;Cognitive Bias for Software Engineers&lt;/em&gt;&lt;/a&gt;. Chapter 6 applies the same lens to debugging, starting with confirmation bias and the habit of holding several hypotheses at once. It is on Kindle in English, Japanese, and Portuguese.&lt;/p&gt;




&lt;p&gt;Sources (all checked 2026-10-19):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow Developer Survey 2025: Technology&lt;/a&gt;: web frameworks, all respondents (React 44.7%, Angular 18.2%, Vue.js 17.6%)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://w3techs.com/technologies/comparison/js-angularjs,js-react,js-vuejs" rel="noopener noreferrer"&gt;W3Techs: React vs. Vue.js vs. Angular&lt;/a&gt;: share of all websites (React 6.0%, Vue.js 0.6%, Angular 0.2%), W3Techs data of 19 October 2026&lt;/li&gt;
&lt;li&gt;GitHub REST API, &lt;code&gt;gh api repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;&lt;/code&gt;, 2026-10-19: &lt;a href="https://github.com/apache/echarts" rel="noopener noreferrer"&gt;apache/echarts&lt;/a&gt; 67,543 stars, &lt;a href="https://github.com/chartjs/Chart.js" rel="noopener noreferrer"&gt;chartjs/Chart.js&lt;/a&gt; 67,746, &lt;a href="https://github.com/d3/d3" rel="noopener noreferrer"&gt;d3/d3&lt;/a&gt; 113,767&lt;/li&gt;
&lt;li&gt;He, Yang, Burckhardt, Kapravelos, Vasilescu, Kästner, &lt;a href="https://arxiv.org/abs/2412.13459" rel="noopener noreferrer"&gt;&lt;em&gt;Six Million (Suspected) Fake Stars in GitHub: A Growing Spiral of Popularity Contests, Spams, and Malware&lt;/em&gt;&lt;/a&gt;, ICSE 2026 (doi:10.1145/3744916.3764531)&lt;/li&gt;
&lt;li&gt;npm downloads API, last week (2026-10-10 to 2026-10-16): &lt;a href="https://api.npmjs.org/downloads/point/last-week/vitest" rel="noopener noreferrer"&gt;vitest&lt;/a&gt; 76,554,353, &lt;a href="https://api.npmjs.org/downloads/point/last-week/jest" rel="noopener noreferrer"&gt;jest&lt;/a&gt; 26,009,195&lt;/li&gt;
&lt;li&gt;Norton, Mochon, Ariely, "The IKEA effect: When labor leads to love", &lt;em&gt;Journal of Consumer Psychology&lt;/em&gt; (2012)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Concorde" rel="noopener noreferrer"&gt;Wikipedia: Concorde&lt;/a&gt; and &lt;a href="https://en.wikipedia.org/wiki/Sunk_cost" rel="noopener noreferrer"&gt;Sunk cost&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Claude, Codex, Kimi K3, Qwen: I graded their web search 120 times</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</link>
      <guid>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The Kimi API usage in this post was paid for with credits Moonshot AI provided for evaluation. Moonshot AI had no part in designing, running, or concluding the test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four months ago I wrote that &lt;a href="https://kenimoto.dev/blog/brave-invisible-to-ai-agents/" rel="noopener noreferrer"&gt;my own AI agents search Brave, not Google&lt;/a&gt;. Like most people, I thought about AI search in terms of which search engine sits underneath.&lt;/p&gt;

&lt;p&gt;This time I reread the official docs to compare web search in Claude, Codex, Kimi K3 and Qwen. That framing no longer fits. None of the four vendors names its search engine. OpenAI makes its own maintained index the default in Codex. Kimi now sells search itself as a standalone API. The question has moved from "which engine?" to "where does search run, and who calls it?" I did not expect that much change in four months.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funlgqv5ct4502sw5xhvw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funlgqv5ct4502sw5xhvw.jpg" alt="Illustration of four different-looking robots, each looking things up in its own way. From left: one pulls index cards from a wooden drawer, one looks through a telescope from a balcony at a cloud of floating web pages, one hands an envelope to a delivery drone, and one sits on the floor while a human hand reaches through a hatch in the wall to plug a cable into it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the mechanics changed, the results should too. Before measuring, I assumed live search would beat a cache whenever the question needs fresh information.&lt;/p&gt;

&lt;p&gt;Codex CLI's web search is not live by default. It runs in "cached" mode against an index OpenAI maintains. The docs give the reason: it lowers prompt injection risk (&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;). I read that as trading some freshness for safety.&lt;/p&gt;

&lt;p&gt;The measurements said the opposite. I asked the same 8 questions 3 times under 5 setups: Claude, Codex live, Codex cached, Kimi K3 and a local Qwen. &lt;strong&gt;Only cached search got every answer right.&lt;/strong&gt; It even found the title of an article that had been online for about three hours.&lt;/p&gt;

&lt;p&gt;The other surprise: &lt;strong&gt;the same search API produced 21 correct answers with one model and 9 with another.&lt;/strong&gt; I gave Kimi K3 and a local Qwen identical access to Kimi's search API. The results were the same. How each model read them was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where web search runs differs by vendor
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, I checked the mechanics in public docs. The biggest difference is where the search actually runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;th&gt;Codex / OpenAI&lt;/th&gt;
&lt;th&gt;Kimi (Moonshot AI)&lt;/th&gt;
&lt;th&gt;Qwen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where search runs&lt;/td&gt;
&lt;td&gt;Anthropic's side (server tool)&lt;/td&gt;
&lt;td&gt;OpenAI's side (hosted tool)&lt;/td&gt;
&lt;td&gt;New API: &lt;strong&gt;your code calls it and passes results to the model&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Alibaba's side (DashScope). &lt;strong&gt;A local Qwen has no search&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness control&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Codex defaults to cached; &lt;code&gt;--search&lt;/code&gt; for live&lt;/td&gt;
&lt;td&gt;Time window on Pro search&lt;/td&gt;
&lt;td&gt;7-365 day window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price per search&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.002-0.003&lt;/td&gt;
&lt;td&gt;Varies by region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search engine&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed ("OpenAI-maintained index")&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3ie7v4fh1xo4ppg5j8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3ie7v4fh1xo4ppg5j8c.png" alt="Table comparing where web search runs in three designs. Columns: receives the question, runs the search, reads results and answers. For Claude and OpenAI, the vendor's server and model handle all three. For Kimi's new API, your code receives the question and calls Kimi's search API, and Kimi K3 reads the results. A local Qwen has no built-in search, so your code wires in an external search API (Kimi's here) and the local Qwen reads the results. Bottom line: same Kimi search API, different readers, K3 scored 21/24 and local Qwen 9/24" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of the four names its search engine officially. For Claude, a March 2025 report said Brave Search had been added to Anthropic's list of subprocessors (&lt;a href="https://simonwillison.net/2025/Mar/21/anthropic-used-brave/" rel="noopener noreferrer"&gt;Simon Willison&lt;/a&gt;). Today's docs only say "Anthropic's web search backend."&lt;/p&gt;

&lt;p&gt;Claude and OpenAI run the search on their own servers and hand the results to the model. With Claude, results come back encrypted, and you must send them back unchanged to continue the conversation (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Kimi is mid-transition. The built-in &lt;code&gt;$web_search&lt;/code&gt; tool retires on 2026-10-20. Its replacements are standalone REST APIs for search and page fetch, such as &lt;code&gt;/v1/tools/search&lt;/code&gt; (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). With the new APIs, you decide when and how often to search. They cost a third to a fifth of the others, billed only when a call succeeds and returns results (&lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;Kimi: pricing&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Qwen the model has no search of its own. Alibaba Cloud's API offers search, but a Qwen running locally can only look things up if you wire in an external search tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two details in the docs you will want to know
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code's WebSearch returns only titles and URLs.&lt;/strong&gt; It does not read the pages. Reading happens in WebFetch, which summarizes the page with a separate model call first. The docs call this "lossy by design" (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen's search on Alibaba Cloud silently skips searching above 15 requests per second.&lt;/strong&gt; There is no error; the search just does not happen. The FAQ also says English questions often fail to trigger search, and recommends asking in Chinese or forcing search. Both notes appear only in the Chinese docs (&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云: 联网搜索&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hit the "lossy" part myself while researching this post. I had Claude Code's WebFetch read the Qwen docs, and it reported finding neither the 15 RPS note nor the English FAQ. Running grep over the raw HTML (about 460 KB) found both. Once a summary sits in the middle, "it doesn't say that" may only mean "the summary dropped it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;p&gt;Five setups, the same 8 questions, 3 runs each: 120 graded answers in total, collected on the evening of 2026-10-14.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;Claude Code (&lt;code&gt;claude -p&lt;/code&gt;) with WebSearch / WebFetch, Opus 5.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;Responses API &lt;code&gt;web_search&lt;/code&gt;, gpt-5.6-terra (my Codex CLI's default model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;Same, plus &lt;code&gt;external_web_access: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;kimi-k3 with my own search function backed by Kimi's &lt;code&gt;/v1/tools/search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;qwen3.5:35b-a3b on Ollama with the same search function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For Codex, I called the API the CLI uses under the hood. In Codex's source, cached mode maps to &lt;code&gt;external_web_access=false&lt;/code&gt; and live to &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;codex hosted_spec.rs&lt;/a&gt;). So the API calls reproduce the CLI's two modes.&lt;/p&gt;

&lt;p&gt;These were the 8 questions. I only used questions whose answers I could verify from primary sources or my own site.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;q1&lt;/td&gt;
&lt;td&gt;Kimi K3 API release date&lt;/td&gt;
&lt;td&gt;2026-07-16&lt;/td&gt;
&lt;td&gt;News after the training cutoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q2&lt;/td&gt;
&lt;td&gt;Kimi K3 open-weights release date (asked in English with "release date")&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;The "date" trap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q3&lt;/td&gt;
&lt;td&gt;Release date of a Japanese book, &lt;em&gt;MCP実践セキュリティ&lt;/em&gt; (Impress)&lt;/td&gt;
&lt;td&gt;2026-10-16&lt;/td&gt;
&lt;td&gt;Japanese-language sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q4&lt;/td&gt;
&lt;td&gt;Japanese title of a Kimi K3 article posted on kenimoto.dev that day&lt;/td&gt;
&lt;td&gt;Kimi K3の動画レビュー、Claudeと互角&lt;/td&gt;
&lt;td&gt;Freshness: a page a few hours old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q5&lt;/td&gt;
&lt;td&gt;Date the MCP 2026-07-28 spec release candidate came out&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;td&gt;A dated fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q6&lt;/td&gt;
&lt;td&gt;Retirement date of Kimi's &lt;code&gt;$web_search&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;td&gt;A detail buried in docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q7&lt;/td&gt;
&lt;td&gt;Kimi K3 total and activated parameters&lt;/td&gt;
&lt;td&gt;2.8T / 104B&lt;/td&gt;
&lt;td&gt;Numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q8&lt;/td&gt;
&lt;td&gt;Kimi K4 release date&lt;/td&gt;
&lt;td&gt;Not announced&lt;/td&gt;
&lt;td&gt;Does it invent something that doesn't exist?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;q3 is my own book and q4 is my own article, chosen because I know the answers for certain. That choice carries my own bias, and I'd rather say so. Both are in Japanese, so they also test how well each setup reaches Japanese-language sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Correct (of 24)&lt;/th&gt;
&lt;th&gt;Partial&lt;/th&gt;
&lt;th&gt;Wrong or no answer&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Searches (avg)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.0 s&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5.7 s&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI cached&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6.2 s&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;25.9 s&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;31.1 s&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Correct answers per question, out of 3 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;q1&lt;/th&gt;
&lt;th&gt;q2&lt;/th&gt;
&lt;th&gt;q3&lt;/th&gt;
&lt;th&gt;q4&lt;/th&gt;
&lt;th&gt;q5&lt;/th&gt;
&lt;th&gt;q6&lt;/th&gt;
&lt;th&gt;q7&lt;/th&gt;
&lt;th&gt;q8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the nonexistent Kimi K4 (q8), every setup said it hasn't been announced. Qwen once added, without a source, that K4 had been "mentioned as part of plans for 2027 or later."&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the cache wasn't stale
&lt;/h2&gt;

&lt;p&gt;Cached search answered all 24 correctly. The q4 article went live a little after 5 p.m. that day, and I measured about three hours later. Cached search returned its title in all three runs. Honestly, that surprised me. At least for my site, "an index OpenAI maintains" was refreshed within hours.&lt;/p&gt;

&lt;p&gt;Source quality also favored the cache at times. On q6 (when &lt;code&gt;$web_search&lt;/code&gt; retires), cached search cited Kimi's official docs. Live search got the same answer but, in one run, cited an unofficial roundup site. A wider reach also means picking up more unofficial pages.&lt;/p&gt;

&lt;p&gt;Live search missed q3, the Japanese book, once, saying it couldn't confirm a book by that title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: same results, different answers
&lt;/h2&gt;

&lt;p&gt;Kimi K3 and the local Qwen got exactly the same search API in exactly the same way. They still scored 21 and 9. That's more than double.&lt;/p&gt;

&lt;p&gt;Qwen's mistakes followed a few patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Its belief that it's 2024 beat the search results.&lt;/strong&gt; On q4 it said: "As of now (May 2024), an article dated October 14, 2026 does not exist. That is a future date." On q1 it found the July 16 date, then concluded it was "not released as of 2025"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its sources and its answers disagreed.&lt;/strong&gt; On q5, all three runs cited the release-candidate announcement dated May 21, then answered with the final release date, July 28&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It invented a source.&lt;/strong&gt; On q3 it cited an Impress press release URL that doesn't exist (404) and gave a wrong release date. Each run gave a different wrong date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It barely searched.&lt;/strong&gt; 1.7 searches on average, less than half of K3's 4.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model size differs, so this isn't a verdict on Qwen as a whole. What it does show is that &lt;strong&gt;web search accuracy is not decided by the search API alone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: Kimi's search API has two gaps
&lt;/h2&gt;

&lt;p&gt;All three of K3's misses were q4. It rephrased the query 12 times and never found the same-day article. The page simply wasn't in Kimi's search index yet. The cause wasn't the model. It was index freshness.&lt;/p&gt;

&lt;p&gt;The second gap: &lt;strong&gt;queries containing the &lt;code&gt;site:&lt;/code&gt; operator return HTTP 502.&lt;/strong&gt; Both K3 and Qwen tried &lt;code&gt;site:kenimoto.dev&lt;/code&gt; when hunting for a specific page. Each time, the search API returned a non-JSON error page, and on the first run it crashed my script. My tool now returns the failure to the model so it can rephrase and try again.&lt;/p&gt;

&lt;p&gt;Hitting the search API directly also showed how much wording matters. Searching "Kimi K3 release date Moonshot AI" let "date" take over: I got a time-zone site and JavaScript Date docs. Searching just "Kimi K3" put the official site, the paper and GitHub on top. None of the models fell into this trap during the actual runs, though. Both K3 and Qwen rephrased their own queries around it.&lt;/p&gt;

&lt;p&gt;By the way, the soon-to-retire &lt;code&gt;$web_search&lt;/code&gt; tool didn't work with K3. The second call, the one that returns search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. Passing search through regular function calling, as in this test, works fine with K3 (more in &lt;a href="https://kenimoto.dev/blog/kimi-k3-video-review-vs-claude/" rel="noopener noreferrer"&gt;Kimi K3 vs Claude: Video Review, Same Findings&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude struggled with a Japanese book
&lt;/h2&gt;

&lt;p&gt;On q3, Claude said in 2 of 3 runs that it couldn't find the book and that it may not exist. The book is listed, with its release date, on the publisher's NextPublishing page and on Rakuten Books.&lt;/p&gt;

&lt;p&gt;It was persistent elsewhere, though. In one q4 run, WebSearch didn't surface the article, so Claude found it through the sitemap and opened the page to confirm the title and date. Because WebSearch returns only titles and URLs, fetching pages when needed is part of the design, and here it paid off. Claude also often noted that it had only checked secondary sources, and avoided firm claims.&lt;/p&gt;

&lt;p&gt;The 22-second median includes Claude Code's agent loop. Calling the search tool directly through the API should be faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll use them
&lt;/h2&gt;

&lt;p&gt;Here's what I'm taking from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave cached search as the default for research.&lt;/strong&gt; If you use Codex, there's no need to pass &lt;code&gt;--search&lt;/code&gt; every time. The safer setting didn't lose on accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust "not found" for non-English sources.&lt;/strong&gt; Not found is not the same as not existing. When Claude says so, I check another way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you wire in search yourself, the model matters more.&lt;/strong&gt; With the same search API, K3 and Qwen differed by more than 2x. Pick a model that can read search results before picking the cheapest search API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your own search tool return failures.&lt;/strong&gt; Kimi's search API returns 502 on &lt;code&gt;site:&lt;/code&gt;. Pass the failure back instead of crashing, and the model will rephrase and search again&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;This is a small test: 8 questions, 3 runs. Topics lean toward Kimi K3 and MCP, and q3 and q4 are my own book and article&lt;/li&gt;
&lt;li&gt;Claude ran through Claude Code; Codex results come from calling the API the CLI uses&lt;/li&gt;
&lt;li&gt;The Qwen setup is a local 35B model wired to Kimi's search, not Qwen's official search&lt;/li&gt;
&lt;li&gt;Each setup works differently, so times aren't directly comparable. I didn't total the costs in this test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next, I want to call Claude's search tool directly through the API and try Qwen's official search on Alibaba Cloud, to close the gaps between setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;: pricing, citations, encrypted results, versions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;: how WebSearch and WebFetch behave&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;: cached / live modes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/tools-web-search" rel="noopener noreferrer"&gt;OpenAI: Web search tool&lt;/a&gt;: &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;openai/codex hosted_spec.rs&lt;/a&gt;: how the modes map to &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/guide/best-practices-for-web-search" rel="noopener noreferrer"&gt;best practices&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云 百炼: 联网搜索&lt;/a&gt; (Chinese; the 15 RPS note and the English-question FAQ appear only here)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>openai</category>
    </item>
    <item>
      <title>Kimi K3 reads video natively. Claude still caught the same issues</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</link>
      <guid>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Moonshot AI provided the Kimi API credits used here ($50) for evaluation. They had no part in designing, running, or concluding the test. Everything K3 couldn't do, and the features that didn't work, are in this post too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Moonshot AI's Kimi K3 takes &lt;strong&gt;video files straight through the API&lt;/strong&gt;. Claude has no video input, so when I want Claude to look at a video, I cut it into frames first. Codex CLI doesn't have a video channel either, even when you point it at K3 (&lt;a href="https://platform.kimi.ai/docs/guide/codex-kimi" rel="noopener noreferrer"&gt;Kimi API: Use K3 in Codex&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I wanted to know how much that difference matters in real work. My test material was a pair of 40-50 second hands-on videos I made for the &lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH series on kenimoto.dev /learn/&lt;/a&gt; (Japanese).&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 and Claude agreed on what was wrong.&lt;/strong&gt; Where viewers would drop off, the six fixes, where to insert diagrams, even which video to post first: all the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The differences were timestamp precision and effort.&lt;/strong&gt; K3 pinpointed frozen-screen intervals to fractions of a second, and all I did was hand it the file. Claude estimated from frames sampled every 2 seconds, so its timestamps drift by about ±1 second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K3 doesn't understand audio.&lt;/strong&gt; It can't hear the narration or catch audio sync problems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude was faster.&lt;/strong&gt; The same request took K3 363 seconds and Claude 185&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test material: auto-generated hands-on videos
&lt;/h2&gt;

&lt;p&gt;The videos come from handson-cast, a CLI I'm building. You give it a JSON file of steps. It runs each command for real inside Docker containers while &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;vhs&lt;/a&gt; records the terminal, then adds narration from OpenAI's text-to-speech and burns in subtitles. It's Python and ffmpeg underneath. The narration and subtitles are in Japanese.&lt;/p&gt;

&lt;p&gt;I made two videos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key authentication&lt;/strong&gt; (55 s): create a key, install the public key, confirm key-only login works, then disable password auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bastion host&lt;/strong&gt; (47 s): the same key gets rejected by the bastion with &lt;code&gt;ssh -J&lt;/code&gt;, but works through &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every line of terminal output was produced by OpenSSH 10.0p2 running in a container. Nothing is staged. When the rejection message changes from &lt;code&gt;(publickey,password)&lt;/code&gt; to &lt;code&gt;(publickey)&lt;/code&gt;, that really happened on camera.&lt;/p&gt;

&lt;p&gt;The first cut (v1) was not watchable, though. The first second shows me typing an internal wrapper script, &lt;code&gt;bash /lab/ssh-config.step-01.sh&lt;/code&gt;. You can't tell what the video is about. (I made it, and I couldn't tell either.)&lt;/p&gt;

&lt;p&gt;Here's v1 (47 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v1.mp4" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftao36goxaxl2y94gz7qi.jpg" alt="Bastion host hands-on video, v1 (47 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I showed v1 to K3 and Claude and asked what was wrong with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending video to K3, and what it costs
&lt;/h2&gt;

&lt;p&gt;The K3 API is OpenAI-compatible. You upload the video as a file first, then reference the returned ID with an &lt;code&gt;ms://&lt;/code&gt; prefix.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.moonshot.ai/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learn-ssh-key-auth.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kimi-k3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&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;type&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;video_url&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;video_url&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;text&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;text&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;Why would viewers drop off in the first 3 seconds?&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public URLs aren't accepted. The official docs describe video as a set of key frames, and higher resolution means more tokens (&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My first measurement used one 55-second, 1920×1080 video:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input tokens&lt;/td&gt;
&lt;td&gt;40,659 (almost all video)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output tokens&lt;/td&gt;
&lt;td&gt;2,500 (1,912 of them reasoning)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;about $0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;83.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Costs use $3 per million input tokens and $15 per million output tokens.&lt;/p&gt;

&lt;p&gt;Two practical tips from actually running it:&lt;/p&gt;

&lt;h3&gt;
  
  
  Assume it can't hear anything
&lt;/h3&gt;

&lt;p&gt;For this run I made a version without burned-in subtitles and asked K3 to transcribe the last sentence of the narration. It answered that it couldn't recognize any audio. Not pretending to hear something it can't is exactly what you want from a reviewer. But it means &lt;strong&gt;K3 can't see narration content or subtitle-audio drift.&lt;/strong&gt; It sees the screen and any subtitles burned into it, nothing else.&lt;/p&gt;

&lt;p&gt;It reads the screen well, and accurately. It listed the commands and output for all four steps in order, including the &lt;code&gt;600&lt;/code&gt; / &lt;code&gt;644&lt;/code&gt; permissions, the change in the rejection message, and an on-screen comment. The only misread was the wrapper script's filename.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give it a generous output budget
&lt;/h3&gt;

&lt;p&gt;K3 always reasons before it answers. With &lt;code&gt;max_tokens&lt;/code&gt; at 300, the reasoning used everything up and the answer came back empty. It thought hard and then left without saying a word. At 8,000, a full video review got cut off in the middle of the first question. For long answers like reviews, I use 32,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison setup
&lt;/h2&gt;

&lt;p&gt;K3 and Claude each reviewed the same two v1 videos, under matched conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How the video goes in&lt;/td&gt;
&lt;td&gt;The file, once&lt;/td&gt;
&lt;td&gt;Frames extracted and read as images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Not understood&lt;/td&gt;
&lt;td&gt;Not used (transcription turned off)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narration script&lt;/td&gt;
&lt;td&gt;Step JSON attached&lt;/td&gt;
&lt;td&gt;Same JSON attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Claude side ran as a subagent that never saw K3's answers. I had already read them, so if I'd done the review myself, I would have been anchored. That's not a comparison.&lt;/p&gt;

&lt;p&gt;For frame extraction I used &lt;code&gt;/watch&lt;/code&gt; from &lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;claude-video&lt;/a&gt;, and left the frame count and resolution up to Claude. It ended up re-extracting 4 times. For the opening it pulled frames at 0 / 0.5 / 1 / 2 / 3 seconds, then one every 2 seconds for the rest: 34 frames in total.&lt;/p&gt;

&lt;p&gt;The five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where would viewers drop off in the first 3 seconds?&lt;/li&gt;
&lt;li&gt;Up to six improvements, biggest impact first (what to change / why it works / which pipeline stage implements it)&lt;/li&gt;
&lt;li&gt;If you combined the article's diagrams with the terminal recording, which diagram goes where, for how long?&lt;/li&gt;
&lt;li&gt;Which video should go to social media first?&lt;/li&gt;
&lt;li&gt;What's already good?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Result: nearly identical findings
&lt;/h2&gt;

&lt;p&gt;They mostly matched. Lined up side by side, the answers overlap almost completely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seconds 0-2 show the wrapper script being typed; the topic is unclear&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The first narration line introduces the site, not a reason to watch&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Could not resolve hostname&lt;/code&gt; in the bastion video is a strong hook, buried&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open with the conclusion or a question (cold open)&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut the typing and the waits between steps&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bigger font, highlight the key output lines&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make a vertical version&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert the article's diagrams: permissions at &lt;code&gt;ls -l&lt;/code&gt;, the before/after at the climax&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End on the article's OG image&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post the bastion video first&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show a progress bar along the top&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewritten narration lines, as actual sentences&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The four &lt;code&gt;ssh-copy-id&lt;/code&gt; INFO lines are noise&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fixes themselves are standard short-video advice: open with the conclusion, cut silence, keep subtitles short, put the call to action at the end. Either model will give you that. &lt;strong&gt;This comparison didn't surface anything only K3 could find.&lt;/strong&gt; I expected a model that reads video directly to catch something Claude couldn't see. It didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where they differed: timestamps
&lt;/h3&gt;

&lt;p&gt;The biggest gap was in how precisely each one placed things in time.&lt;/p&gt;

&lt;p&gt;K3 wrote things like "the first meaningful output appears at 3.7 seconds" and "the error at 00:03.2 in the bastion video gets buried," sub-second throughout. When I later had it review an improved version, it came back with intervals: "from 30.5 to 36.1 seconds the screen is frozen after the output appears."&lt;/p&gt;

&lt;p&gt;Claude's timestamps were estimates from frames 2 seconds apart, and Claude said so at the end of its review: interval boundaries could be off by about ±1 second. You can sample more densely, but then there are more images to read. It's a trade-off.&lt;/p&gt;

&lt;p&gt;"Where exactly is the screen frozen?" is hard to answer from a handful of stills. That's where reading the video directly paid off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Effort and speed
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My effort&lt;/td&gt;
&lt;td&gt;Hand over the file&lt;/td&gt;
&lt;td&gt;Decide how to sample frames (Claude redid it 4 times)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;363 s&lt;/td&gt;
&lt;td&gt;185 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;84,461 input / 10,863 output tokens, about $0.20 (part of the input billed at the cache rate)&lt;/td&gt;
&lt;td&gt;111,313 tokens, 40 tool calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;K3 took less effort; Claude was faster. Each has its edge. Because K3 spends time reasoning, each review took 6-8 minutes. Enough time to make a coffee and come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The videos really did get better
&lt;/h2&gt;

&lt;p&gt;I made the fixes. Following the reviews, I added six changes to handson-cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open with a card: "Same key, but &lt;code&gt;ssh -J&lt;/code&gt; gets rejected. Why?"&lt;/li&gt;
&lt;li&gt;Cut the wrapper-script typing and the waits between steps&lt;/li&gt;
&lt;li&gt;Type commands out character by character, with a larger font&lt;/li&gt;
&lt;li&gt;Right after the rejection, show the article's diagram full-screen for 4.5 seconds&lt;/li&gt;
&lt;li&gt;Once the output is complete, slowly zoom into the top-left of the terminal&lt;/li&gt;
&lt;li&gt;End with the article's OG image and URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the final cut (v4, 41 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v4.mp4" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpfe4l14qkxuygg0dk46.jpg" alt="Bastion host hands-on video, v4 (41 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Frames from the same timestamps, side by side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8svlj27ufa8cskqh3j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8svlj27ufa8cskqh3j.png" alt="v1 vs v4. Top left: v1 at 1 second, a dark screen with a single half-typed line, bash /lab/ssh-config.step, in the corner. Top right: v4 at 1.8 seconds, a large Japanese headline asking why the same key is rejected with ssh -J, above a client-bastion-target reachability diagram. Bottom left: v1 at 34.5 seconds, small ssh -G output in the top-left corner. Bottom right: v4 at the same time, the same output shown much larger" width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bastion video went from 46.8 to 41.0 seconds, about 6 seconds shorter. I had K3 grade the improved cut against a 10-item checklist for technical short videos. It covers things like a hook in the first 3 seconds, something changing on screen every 1-3 seconds, and a short call to action only at the end. It scored 7 pass, 2 borderline, 1 pass (estimated). The two borderline items were a frozen stretch after the output appears and an overlong subtitle. Both are fixed now.&lt;/p&gt;

&lt;p&gt;I'd meant to have K3 research that checklist too, but that didn't work. The Kimi API has a built-in web search tool (&lt;code&gt;$web_search&lt;/code&gt;). With K3, though, the second call, the one that returns the search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. With kimi-k2.6 the first search went through, but after that it stopped searching and wrote text that only looked like search calls into its answer. The docs say this feature is scheduled for retirement on 2026-10-20 (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). I gave up. Claude's web search did the research, and I passed the results to K3.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll split the work
&lt;/h2&gt;

&lt;p&gt;Based on this, here's how I plan to use them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 when I need to know where in a video something happens.&lt;/strong&gt; Frozen intervals, the moment the screen gets cut off, how long a caption stays up: questions whose answer is a span of time. I don't have to think about how to sample frames&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Either one for deciding how to fix it.&lt;/strong&gt; The improvement ideas didn't differ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not K3 for anything involving audio.&lt;/strong&gt; For narration content or sync, I prepare a transcript separately and compare against it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No confidential screens.&lt;/strong&gt; The video goes to Moonshot AI's servers. These videos were going public anyway, so that was fine; internal screen recordings need different handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a small comparison: two videos, one set of questions, nothing statistical. Still, it gave me a working answer. Reading video directly didn't change the judgment; it changed timestamp precision and effort. Next I want to plant deliberate defects in a video and count how many each model finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/MoonshotAI/Kimi-K3" rel="noopener noreferrer"&gt;MoonshotAI/Kimi-K3 (GitHub)&lt;/a&gt;: model card. 2.8T total parameters, MoE with 104B active per token, 1M context&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision models&lt;/a&gt;: how video input works&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;: &lt;code&gt;$web_search&lt;/code&gt; and its retirement date&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;bradautomates/claude-video&lt;/a&gt;: the &lt;code&gt;/watch&lt;/code&gt; tool used for Claude's frame extraction&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;charmbracelet/vhs&lt;/a&gt;: terminal recording&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH: five ways to reach a remote machine, compared on seven axes&lt;/a&gt; (Japanese): the /learn/ series these videos were made for&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>video</category>
    </item>
    <item>
      <title>Shai-Hulud: 796 Packages, 1 Yes-Mash, and the Claude Code Auto-Approve Risk</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</link>
      <guid>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</guid>
      <description>&lt;p&gt;On November 24, 2025, JFrog Security Research disclosed that a self-replicating npm worm had quietly backdoored 796 unique packages. The industry called the second wave &lt;strong&gt;Shai-Hulud 2.0&lt;/strong&gt;, or "The Second Coming." It followed the September 16, 2025 original wave, which had already poisoned over 1,150 packages (&lt;a href="https://jfrog.com/blog/shai-hulud-npm-supply-chain-attack-new-compromised-packages-detected/" rel="noopener noreferrer"&gt;JFrog&lt;/a&gt;, &lt;a href="https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Over 25,000 GitHub repositories had credentials leaked by the campaign (&lt;a href="https://www.sysdig.com/blog/return-of-the-shai-hulud-worm-affects-over-25-000-github-repositories" rel="noopener noreferrer"&gt;Sysdig&lt;/a&gt;). The 796 is the count of poisoned npm packages. The 25,000 is the count of victim repositories whose tokens and secrets the worm harvested; the attacker-side drop points were separate public repos (random 18-character names, description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) created on victim accounts.&lt;/p&gt;

&lt;p&gt;The infection vector was the same thing every developer does fifty times a day. Run a package install. Hit Enter.&lt;/p&gt;

&lt;p&gt;Now add Claude Code, or any other coding agent, with "always allow" turned on for &lt;code&gt;npm install&lt;/code&gt;. The fifty-times-a-day moves to 500-times-a-day. The attacker now needs one of those 500 to land on a compromised version.&lt;/p&gt;

&lt;p&gt;That is the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Shai-Hulud 2.0 actually did
&lt;/h2&gt;

&lt;p&gt;The worm's loop was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Infect.&lt;/strong&gt; A victim ran &lt;code&gt;npm install&lt;/code&gt; on a poisoned version of a popular package. The payload fired as a &lt;code&gt;pre-install&lt;/code&gt; hook. (V1 used &lt;code&gt;post-install&lt;/code&gt;; V2 moved it earlier, so it ran even on failed installs.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steal.&lt;/strong&gt; The payload scanned the victim's machine for &lt;code&gt;~/.npmrc&lt;/code&gt; tokens, GitHub tokens from &lt;code&gt;~/.config/gh/&lt;/code&gt;, SSH keys, AWS and GCP credentials, and loose &lt;code&gt;.env&lt;/code&gt; files. Credentials were exfiltrated by creating a public GitHub repository on the victim's account (description stamped &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) that acted as the drop point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-replicate.&lt;/strong&gt; Using the stolen npm token, the worm published poisoned versions of every package the victim had publish rights to. The victim became the next publisher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist.&lt;/strong&gt; In some cases it enrolled the victim's host as a self-hosted GitHub Actions runner named &lt;code&gt;SHA1HULUD&lt;/code&gt;, so the attacker could execute code on the machine later (&lt;a href="https://www.netskope.com/blog/shai-hulud-2-0-aggressive-automated-one-of-fastest-spreading-npm-supply-chain-attacks-ever-observed" rel="noopener noreferrer"&gt;Netskope&lt;/a&gt;, &lt;a href="https://www.zscaler.com/blogs/security-research/shai-hulud-v2-poses-risk-npm-supply-chain" rel="noopener noreferrer"&gt;Zscaler&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three details matter for the agent-usage angle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The payload ran on install, not on first use. There was no runtime signal.&lt;/li&gt;
&lt;li&gt;The exfiltration channel was public GitHub, not an exotic webhook. Egress filters that block weird DNS wouldn't help.&lt;/li&gt;
&lt;li&gt;The runtime was Bun, not Node. Signature-based scanners trained on Node payloads lagged (&lt;a href="https://www.endorlabs.com/learn/shai-hulud-2-malware-campaign-targets-github-and-cloud-credentials-using-bun-runtime" rel="noopener noreferrer"&gt;Endor Labs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time npm removed the compromised versions, the token mesh was already seeded. The affected packages had roughly 132 million monthly downloads between them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "always allow" is the exploited behavior
&lt;/h2&gt;

&lt;p&gt;Claude Code's permission prompt is the exact friction Shai-Hulud needs you to remove.&lt;/p&gt;

&lt;p&gt;Default behavior: Claude Code asks before running &lt;code&gt;npm install &amp;lt;pkg&amp;gt;&lt;/code&gt;. You read the package name, you decide. One prompt per install. Annoying when you're installing twelve dependencies for a Next.js app, which is why &lt;code&gt;/permissions&lt;/code&gt; exists and why most Claude Code users have &lt;code&gt;Bash(npm install:*)&lt;/code&gt; on "always allow" by their third week. Mine was on by week two. I know this because I checked after reading the JFrog post.&lt;/p&gt;

&lt;p&gt;The attacker's threat model relies on this. The payload does not need to defeat your judgment. It needs you to never be asked.&lt;/p&gt;

&lt;p&gt;The equivalent risk on other agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers&lt;/strong&gt; with &lt;code&gt;auto-approve: true&lt;/code&gt; for all tools, so a server you installed last month can shell out and you would never see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex, Aider, or Cursor&lt;/strong&gt; running under &lt;code&gt;--yes&lt;/code&gt; or an auto-run shell wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt;: a bot account running &lt;code&gt;npm install&lt;/code&gt; without lockfile integrity, auto-publishing on merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case the pattern is the same. A human decided once, early, then stopped looking. The worm does not need to be smarter than Claude Code. It needs the human in the loop to have left the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  A defensive checklist that isn't "write a CLAUDE.md"
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md helps against prompt-injection and model behavior. It does not stop a signed, published, poisoned package from running &lt;code&gt;exec&lt;/code&gt; in a pre-install hook. By the time install scripts fire, the model is no longer in the loop.&lt;/p&gt;

&lt;p&gt;The checklist below targets the install surface itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-session, before you say "always allow":&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;/permissions&lt;/code&gt; and read what is on always-allow right now. If &lt;code&gt;Bash(npm:*)&lt;/code&gt; or &lt;code&gt;Bash(*)&lt;/code&gt; is on the list, that is your exposure.&lt;/li&gt;
&lt;li&gt;Scope allow rules to specific commands, not globs. &lt;code&gt;Bash(npm ci)&lt;/code&gt; or &lt;code&gt;Bash(npm install --package-lock-only)&lt;/code&gt; is survivable; &lt;code&gt;Bash(npm install:*)&lt;/code&gt; is not.&lt;/li&gt;
&lt;li&gt;For MCP: review &lt;code&gt;auto-approve&lt;/code&gt; arrays per server. New MCP servers get a sandbox project first, not production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the install layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin everything. &lt;code&gt;package-lock.json&lt;/code&gt; + &lt;code&gt;npm ci&lt;/code&gt; + &lt;code&gt;--ignore-scripts&lt;/code&gt; on CI where you can. Install scripts are the exact Shai-Hulud entrypoint.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;--ignore-scripts&lt;/code&gt; on globally in local dev (&lt;code&gt;npm config set ignore-scripts true&lt;/code&gt;), then whitelist the handful of packages that legitimately need scripts.&lt;/li&gt;
&lt;li&gt;Verify the registry you resolve from (&lt;code&gt;npm config get registry&lt;/code&gt;) and the integrity hashes in your lockfile. The worm published to the real npm registry, so this does not save you from the attack itself, but it does catch tampering between your lockfile and your &lt;code&gt;node_modules&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the detection layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Socket, Snyk, or GitHub Dependabot wired into every repo. Socket in particular flags behavioral risks like install scripts and unusual network calls, and was fast on both Shai-Hulud waves.&lt;/li&gt;
&lt;li&gt;Token hygiene. &lt;code&gt;~/.npmrc&lt;/code&gt; and &lt;code&gt;~/.config/gh/&lt;/code&gt; are what the worm wants. Rotate npm tokens quarterly, use &lt;code&gt;npm token create --read-only&lt;/code&gt; for CI, scope GitHub PATs down to the minimum.&lt;/li&gt;
&lt;li&gt;Watch your account for the Nov 2025 exfiltration markers: public repos with randomly-generated 18-character names and description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;, workflow files at &lt;code&gt;.github/workflows/discussion.yaml&lt;/code&gt; or &lt;code&gt;formatter_*.yml&lt;/code&gt;, and self-hosted runners registered as &lt;code&gt;SHA1HULUD&lt;/code&gt;. (The V1 wave in Sept 2025 used repos literally named &lt;code&gt;Shai-Hulud&lt;/code&gt; and a workflow at &lt;code&gt;.github/workflows/shai-hulud-workflow.yml&lt;/code&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the agent layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not leave &lt;code&gt;always allow&lt;/code&gt; on across restarts for &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&gt;, or anything that writes files outside the project. Per-session is survivable, forever is not.&lt;/li&gt;
&lt;li&gt;Have the agent list the package name and the latest publish date before any install. A fresh version of a popular package deserves a human look, even when you are tired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are novel. All of them are standard. What Shai-Hulud 2.0 showed is that the "I'll just hit Enter" discount was always being subsidized by someone else's attention budget. The second wave took the subsidy away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trust, but verify
&lt;/h2&gt;

&lt;p&gt;Reagan used it on arms control. It fits here. Claude Code is in the loop. Your other agents are in the loop. Both are useful. Neither replaces the one-second pause before you widen a trust boundary.&lt;/p&gt;

&lt;p&gt;The industry has, so far, caught Shai-Hulud waves inside 48 hours of publish (&lt;a href="https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem" rel="noopener noreferrer"&gt;CISA alert&lt;/a&gt;, &lt;a href="https://www.trellix.com/blogs/research/npm-account-hijacking-and-the-rise-of-supply-chain-attacks/" rel="noopener noreferrer"&gt;Trellix&lt;/a&gt;). The vulnerability window is small, but it is the exact window your "always allow" paves over.&lt;/p&gt;

&lt;p&gt;For a longer walk-through of Claude Code security patterns, including CLAUDE.md templates and data-retention policy per plan, I pulled these together in &lt;a href="https://kenimoto.dev/books/claude-code-mastery?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=shai-hulud-796" rel="noopener noreferrer"&gt;Practical Claude Code on Kindle&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Deepgram Nova-3 + Cartesia Sonic 3 + GPT-4o: Where 750ms of Voice AI TTFB Actually Hides</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</link>
      <guid>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</guid>
      <description>&lt;p&gt;Voice-AI conversations die the moment the agent takes too long to say the first word. Not the full answer. The first byte of audio.&lt;/p&gt;

&lt;p&gt;The industry calls this TTFB (time to first byte), or TTFA (time to first audio). I measure it from the moment the user stops speaking to the moment the first audio frame plays. Everything else, including total answer length and follow-up quality, happens after the user has already decided whether the agent feels alive.&lt;/p&gt;

&lt;p&gt;Below is a ~750ms streaming budget on a Deepgram Nova-3 + GPT-4o + Cartesia Sonic 3 stack, broken into what each hop actually costs. And where almost every vendor-quoted number stops matching the number you will measure in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  1,200ms to 750ms is the easy part
&lt;/h2&gt;

&lt;p&gt;Batch pipelines wait for the full response before TTS starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (full answer) → TTS (full) → [play]
                                                                 ^ &amp;gt;1,200ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming pipelines hand sentence 1 to TTS the moment the LLM emits it, while sentences 2 and 3 are still generating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (sentence 1) → TTS (sentence 1) → [play]
                               LLM (sentence 2) → TTS (sentence 2) → [continue]
                               LLM (sentence 3) → TTS (sentence 3) → [continue]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the stack above, the streaming budget breaks down roughly like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STT (Deepgram Nova-3 or Flux)&lt;/td&gt;
&lt;td&gt;350ms&lt;/td&gt;
&lt;td&gt;includes end-of-turn detection on Flux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM first sentence (GPT-4o)&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;first-token latency, best-case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS first chunk (Cartesia Sonic 3)&lt;/td&gt;
&lt;td&gt;170ms&lt;/td&gt;
&lt;td&gt;first audio frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network round-trips&lt;/td&gt;
&lt;td&gt;80ms&lt;/td&gt;
&lt;td&gt;user ↔ server + server ↔ vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (streaming TTFB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~750ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 1,200ms to 750ms move pays for itself: no code change beyond "do not wait for the full response." The hard part is everything below 750ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  STT: Nova-3 is 300ms, Flux adds end-of-turn for about 260ms
&lt;/h2&gt;

&lt;p&gt;Deepgram's public numbers, cross-referenced with &lt;a href="https://softcery.com/lab/how-to-choose-stt-tts-for-ai-voice-agents-in-2025-a-comprehensive-guide" rel="noopener noreferrer"&gt;Softcery's 2026 comparison&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Nova-3&lt;/strong&gt;: streaming latency under 300ms, 36 languages. General ASR; does not detect end-of-turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Flux&lt;/strong&gt;: around 260ms end-of-turn, English only. Flux folds ASR together with turn detection into a single model and emits &lt;code&gt;StartOfTurn&lt;/code&gt;, &lt;code&gt;EagerEndOfTurn&lt;/code&gt;, &lt;code&gt;EndOfTurn&lt;/code&gt; events (&lt;a href="https://deepgram.com/learn/introducing-flux-conversational-speech-recognition" rel="noopener noreferrer"&gt;Deepgram launch post&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination matters. Nova-3 transcribes, but you still need a separate endpointing step to know when the user is done. Flux folds endpointing in, which cuts a 400-500ms round-trip, but costs you multi-language support.&lt;/p&gt;

&lt;p&gt;For reference across other vendors: OpenAI &lt;code&gt;gpt-4o-transcribe&lt;/code&gt; is around 320ms with 100+ languages. AssemblyAI Universal-3 Pro Streaming is under 600ms. ElevenLabs Scribe v2 Realtime is around 150ms with 90 languages.&lt;/p&gt;

&lt;p&gt;Each of these is a vendor-quoted number. None of them is what you will measure from Tokyo over LTE to a US-East endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  LLM: GPT-4o claims 200-400ms, measured 719-1,608ms
&lt;/h2&gt;

&lt;p&gt;This is the hop where the vendor-vs-real gap gets ugly.&lt;/p&gt;

&lt;p&gt;Deepgram's own blog post listing &lt;a href="https://deepgram.com/learn/designing-voice-ai-workflows-using-stt-nlp-tts" rel="noopener noreferrer"&gt;reasons they chose GPT-4o for voice agents&lt;/a&gt; cites a first-token latency of 200-400ms. The same post's measured table, on a MacBook M1 Pro over Wi-Fi, reports GPT-4o first-token times of &lt;strong&gt;719ms to 1,608ms&lt;/strong&gt;. That is 2x to 5x the vendor's number, on a laptop, on consumer Wi-Fi.&lt;/p&gt;

&lt;p&gt;A separate &lt;a href="https://dev.to/cloudx/cracking-the-1-second-voice-loop-what-we-learned-after-30-stack-benchmarks-427"&gt;Dev.to benchmark from CloudX&lt;/a&gt; tested 30+ stacks and found GPT-4.1-nano landing at 0.30-0.40 seconds on 2nd-turn-onward, where the KV cache is warm. First turn is always worse.&lt;/p&gt;

&lt;p&gt;What the numbers teach: first-token latency is dominated by TTFT variance, which is dominated by model size, GPU type, quantization, input length, KV cache hit rate, inference backend load, and cold starts. A table of "model X equals N ms" is strictly less useful than one measurement from your own region to your own endpoint.&lt;/p&gt;

&lt;p&gt;Running a small LLM locally removes the LLM-side network round-trip entirely. On a tuned setup, that is where sub-500ms TTFB actually lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  TTS: Cartesia Sonic 3 says sub-90ms, measured median is 166-190ms
&lt;/h2&gt;

&lt;p&gt;Cartesia's two models get swapped in half the voice-AI blog posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic 3&lt;/strong&gt;: sub-90ms (vendor claim, measurement definition undisclosed), 40+ languages, emotion support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic Turbo&lt;/strong&gt;: 40ms (vendor claim, speed-specialized model), 9 languages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The widely-circulated 40ms number is &lt;strong&gt;Sonic Turbo, not Sonic 3&lt;/strong&gt;. Real Sonic 3 measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cartesia's own launch disclosure: median around 190ms.&lt;/li&gt;
&lt;li&gt;Vapi's &lt;a href="https://humannessindex.vapi.ai/models/cartesia-sonic-3" rel="noopener noreferrer"&gt;Humanness Index&lt;/a&gt; (50-run test, round-trip included): median 166ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both numbers are roughly 2x the vendor-quoted sub-90ms. Neither 166 nor 190 is "wrong." They measure different things: vendor model-inference vs client-side round-trip.&lt;/p&gt;

&lt;p&gt;ElevenLabs Flash v2.5 quotes around 75ms, with their own docs noting that 75ms refers to model inference only, and that end-to-end latency will vary with your location and endpoint type (&lt;a href="https://elevenlabs.io/docs/best-practices/latency-optimization" rel="noopener noreferrer"&gt;ElevenLabs latency docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Research on streaming TTS reaches sub-50ms TTFA (&lt;a href="https://arxiv.org/abs/2508.04721" rel="noopener noreferrer"&gt;arXiv 2508.04721&lt;/a&gt;). But the same paper's own end-to-end telecommunications voice agent, with the LLM in the loop, measured 678ms average TTFA. The TTS was fast. The stack still was not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3-level optimization ladder
&lt;/h2&gt;

&lt;p&gt;Treating these numbers as a budget, the ceiling moves in three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1, 500ms target:&lt;/strong&gt; fast TTS + sentence-level streaming + streaming STT. This is the "do not wait for the full response" move above; gets you from 1,200ms+ to roughly 500-750ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2, 300ms target:&lt;/strong&gt; Level 1 plus Flux-style end-of-turn detection, speculative LLM invocation on partial ASR, a smaller LLM, and a TTS whose &lt;em&gt;measured&lt;/em&gt; (not quoted) first byte is under 100ms. The emphasis is on measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3, 200ms target:&lt;/strong&gt; edge inference, a unified STT+LLM+TTS model, hardware tuning. At this point you stop composing vendor APIs and start building.&lt;/p&gt;

&lt;p&gt;The 300ms figure is not arbitrary. It is the UX cliff below which a voice agent starts feeling like a human conversation partner, as opposed to a very fast IVR. Everything above 500ms feels like an echo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three rules before you trust any TTFB number in a blog post
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Was the network included?&lt;/strong&gt; ElevenLabs says the quiet part out loud (75ms is model inference only). Most vendor numbers do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which model?&lt;/strong&gt; Cartesia's 40ms is Sonic Turbo; the Sonic 3 you probably want is 2x that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who measured it?&lt;/strong&gt; Vendor, third-party, or user-run on their own hardware? Each of the three routinely disagrees by a factor of 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Build your own measurement. Pick the stack above, or three alternatives on each row of the table, point at your actual region, record a 50-run distribution. The p95 is the number you should budget against. The vendor's median is marketing.&lt;/p&gt;




&lt;p&gt;The full chapter this article compresses, including the TTS vendor-vs-measurement table and the 3-level ladder in more detail, is in &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=voice-ttfb-750ms" rel="noopener noreferrer"&gt;The 300ms Threshold on Kindle&lt;/a&gt;. The book is built around voice-AI UX cliffs at 300, 500, and 800ms, and how to budget inside them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kernighan, Fowler, Torvalds: 7 Debugging Quotes Every Senior Still Repeats</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</link>
      <guid>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</guid>
      <description>&lt;p&gt;Some engineering lines refuse to fade.&lt;/p&gt;

&lt;p&gt;They survive because every senior ends up repeating them in code review, year after year, usually on a Thursday afternoon when a junior ships something that works and shouldn't.&lt;/p&gt;

&lt;p&gt;I spent the last few months decoding a hundred of these for a short Kindle book. Below are seven, from three names that keep surfacing when the topic is debugging: Brian Kernighan, Martin Fowler, Linus Torvalds. Each quote has its original public source. Each one has a modern code-review move I use when I quote it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;The Elements of Programming Style&lt;/em&gt;, 2nd ed., ch. 2, 1978.&lt;/p&gt;

&lt;p&gt;Half a century later, this is the single question I ask when a PR shows up with a one-line regex, a nested ternary, or a &lt;code&gt;reduce&lt;/code&gt; that collapses six operations into one line. Not "is it correct." &lt;em&gt;How will you ever debug it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI-pair-programming era rediscovers this one every quarter. Give Claude Code or Copilot a prompt like "make this shorter" and it will happily compress four lines into one with a currying trick. The code works. Six months later, nobody (human or model) can rewrite the same function from scratch, because the compressed form hides the steps.&lt;/p&gt;

&lt;p&gt;My review rule: if the author needs more than one sentence to explain what the line does, split it. I know, because I've been that author more than once.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. "The most effective debugging tool is still careful thought, coupled with judiciously placed print statements."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan&lt;/strong&gt;, &lt;em&gt;Unix for Beginners&lt;/em&gt;, 1979.&lt;/p&gt;

&lt;p&gt;I used to be embarrassed by &lt;code&gt;println&lt;/code&gt; debugging. Then I watched enough senior engineers reach for it before a debugger that I stopped apologizing.&lt;/p&gt;

&lt;p&gt;The quote still holds, with one update. The tool today is &lt;code&gt;console.log&lt;/code&gt;, &lt;code&gt;fmt.Println&lt;/code&gt;, &lt;code&gt;print()&lt;/code&gt;, or whatever your runtime offers, plus a tail of structured logs. Debuggers help when you can reproduce the bug and the state is static. Print statements help when the bug lives in a flow, in production, or at a boundary the debugger can't easily cross: gRPC, serverless cold start, worker queues, flaky async tests.&lt;/p&gt;

&lt;p&gt;The actual skill the quote names is &lt;strong&gt;careful thought&lt;/strong&gt;. The prints are only useful once you've guessed where to put them.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. "Controlling complexity is the essence of computer programming."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;Software Tools&lt;/em&gt;, p. 319, 1976.&lt;/p&gt;

&lt;p&gt;A junior and a senior will both call code "complex." The junior usually means "I don't understand this yet." The senior usually means "nobody will ever understand this again, including the person who wrote it."&lt;/p&gt;

&lt;p&gt;I keep this quote pinned when reviewing refactors. The question isn't "is this clever." It's "does the next on-call engineer, at 2 a.m., with no context, have a chance?" Most production bugs I've chased started as a cleverness-driven refactor that added coupling the diff didn't name.&lt;/p&gt;

&lt;p&gt;Complexity accrues. Most PRs make it worse. The ones that make it better are rare and should be celebrated.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. "Any fool can write code that a computer can understand. Good programmers write code that humans can understand."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;em&gt;Refactoring: Improving the Design of Existing Code&lt;/em&gt;, p. 15, Addison-Wesley, 1999.&lt;/p&gt;

&lt;p&gt;This is the one line of Fowler's that engineers who've never read &lt;em&gt;Refactoring&lt;/em&gt; can still recite. Fair, because it compresses the whole book: tests exist so humans can change code; names exist so humans can read it; small methods exist so a human can hold one in their head.&lt;/p&gt;

&lt;p&gt;The habit I lifted from this: whenever I'm tempted to leave a comment explaining &lt;em&gt;what&lt;/em&gt; a block does, I rename or split instead. If the code reads without the comment, the comment was a smell.&lt;/p&gt;

&lt;p&gt;LLM code generators love long comments. Rename the function.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. "If it hurts, do it more often."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;a href="https://martinfowler.com/bliki/FrequencyReducesDifficulty.html" rel="noopener noreferrer"&gt;martinfowler.com, FrequencyReducesDifficulty&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fowler surfaces this as one of his favorite soundbites. The surface reading sounds like masochism. The real reading: if deploys are painful, deploy daily; if merges are painful, merge every hour; if integration tests are painful, run them on every push.&lt;/p&gt;

&lt;p&gt;The pattern survives the current stack. Painful AI-code reviews? Review each PR as soon as it opens, not once a day in a batch. Painful dependency bumps? Automate them nightly instead of quarterly. Painful incident post-mortems? Hold them within 48 hours while people still remember.&lt;/p&gt;

&lt;p&gt;Every time I've watched a team avoid a painful activity, the pain compounded. Fowler's rule flips the compounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. "Talk is cheap. Show me the code."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, &lt;a href="https://lkml.org/lkml/2000/8/25/132" rel="noopener noreferrer"&gt;Linux Kernel Mailing List, 25 Aug 2000&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Linus wrote this in the middle of a kernel discussion where someone was proposing changes without an implementation. The adage has outlived its original thread.&lt;/p&gt;

&lt;p&gt;Modern application: architecture debates on Slack where no participant has opened the file in question. Design reviews on a tool nobody has prototyped. "We should migrate to X" threads with no benchmark attached. The quote doesn't ban design. It bans debating before anyone has sampled the code. Open a branch. Commit a 30-line proof of concept. Attach the diff to the thread.&lt;/p&gt;

&lt;p&gt;I keep a text expander snippet with this quote. I pull it out maybe once a month, and every time it ends the thread in under an hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. "Bad programmers worry about the code. Good programmers worry about data structures and their relationships."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, Git mailing list, 2006. (&lt;a href="https://lwn.net/Articles/193245/" rel="noopener noreferrer"&gt;git mailing list via LWN, 2006-07-27&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The underrated debugging quote on this list. Most bugs that reach production aren't in the control flow. They're in the shape of the data: a nullable field someone assumed was present, a one-to-many relationship someone modeled one-to-one, a cache that lies about freshness, a queue that quietly drops ordering guarantees.&lt;/p&gt;

&lt;p&gt;When a bug surfaces, the first question I ask is what the data actually looks like right now, and what the code assumed it would look like. More than half the time, the fix is a schema change or a type refinement, and the logic was never wrong. I lost a weekend last year "fixing" logic that was fine — the schema was lying about a nullable column.&lt;/p&gt;

&lt;p&gt;Modern echo: in LLM pipelines, the hardest bugs don't live in the prompt. They live in the context layout the prompt silently depends on: what the retriever returned, in what order, with what metadata.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these seven, out of a hundred
&lt;/h2&gt;

&lt;p&gt;The engineering world has more quotes than it has reviewed PRs. These seven survive translation across stacks: a 1978 line is still live in a 2026 Claude-Code-generated diff.&lt;/p&gt;

&lt;p&gt;If this slice landed, the full set (100 quotes across 10 chapters — code aesthetics, bugs and failure, design, teams, innovation, learning, craftsmanship, open source, the AI era, and an engineer's life) is in &lt;a href="https://kenimoto.dev/books/engineer-it-quotes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=debug-7-quotes" rel="noopener noreferrer"&gt;100 Engineering Quotes Decoded&lt;/a&gt;. One decoded quote at a time, so you can read a decoding on a coffee break and bring it into code review the same day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
      <category>productivity</category>
    </item>
    <item>
      <title>5 Code Review Biases: Why Senior PRs Pass Faster Even When They Shouldn't</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/5-code-review-biases-why-senior-prs-pass-faster-even-when-they-shouldnt-4h1j</link>
      <guid>https://dev.to/kenimo49/5-code-review-biases-why-senior-prs-pass-faster-even-when-they-shouldnt-4h1j</guid>
      <description>&lt;p&gt;Meta researchers suspected the bystander effect was slowing their code reviews when a group — rather than one person — got asked to look at a change. They tested it, confirmed it, and shipped BystanderRecRnd, a recommender that assigns a single reviewer so the "someone else will take it" delay stops happening. The paper is worth reading on its own. It is also one of five biases that quietly decide which PRs pass fast and which ones get picked apart, independent of code quality.&lt;/p&gt;

&lt;p&gt;The other four — halo, authority, framing, ownership — show up every day in reviews that look technical from the outside. This piece walks through each one with where it fires, and the mechanism that stops it. Not vigilance. A mechanism. Reminders to "be careful" survive about a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Halo effect: the senior PR passes faster
&lt;/h2&gt;

&lt;p&gt;The halo effect is the oldest finding in applied social psychology, replicated so many times it stopped being news. In code review it shows up as this: the same diff, submitted by a senior and by a new hire, gets different scrutiny. The senior's code is "they probably have a reason." The new hire's is "they haven't learned yet."&lt;/p&gt;

&lt;p&gt;The inverse also fires. An engineer who shipped a famous bug last year gets careful reviews on code that is objectively fine. The halo rots in both directions.&lt;/p&gt;

&lt;p&gt;I have been on both sides of this. The senior PR with a design flaw that got waved through, and the junior-era PR with a reasonable choice that got picked apart line by line. Neither reaction was really about the code.&lt;/p&gt;

&lt;p&gt;The amplifiers in a modern codebase are visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub commit count, PR frequency, OSS contributions on the sidebar&lt;/li&gt;
&lt;li&gt;Prior employer (ex-FAANG, maintainer of a known OSS project)&lt;/li&gt;
&lt;li&gt;How carefully written the PR description is — people assume the writing style predicts the code&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;p&gt;Hide the author from yourself for the first pass. Open the diff tab, not the PR summary. Form a technical read of the code before you look at who wrote it. The vigilance version — "I will try to be fair" — has been measured and does not work. The structural version — "I read the diff before I see the name" — does.&lt;/p&gt;

&lt;p&gt;Pair it with a fixed checklist (security, performance, test coverage, readability). A checklist does not stop you from being charitable to the senior; it stops you from skipping items for the senior and inventing items for the new hire.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Authority bias: tech leads get LGTM on auto-pilot
&lt;/h2&gt;

&lt;p&gt;Authority bias is subtle because it does not look like a bias. The tech lead's PR gets approved because, in most cases, the tech lead's PR is actually correct. The bias is the margin. It is the fraction of approvals where the reviewer thought "this looks off" and did not comment, because the reviewer assumed the lead had a reason.&lt;/p&gt;

&lt;p&gt;Over a year that margin compounds. The architectural choices that quietly went unchallenged are the ones that survive into legacy debt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to use it as a reviewee
&lt;/h3&gt;

&lt;p&gt;The flip side is a tool. When you want a proposal to land, borrow authority deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cite the official docs or RFC for your design choice in the PR description&lt;/li&gt;
&lt;li&gt;Reference industry patterns or a specific paper for an architectural change&lt;/li&gt;
&lt;li&gt;Ask a trusted senior for a pre-review, then mention the feedback was already applied&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ceiling of this technique is low. Borrowed authority does not improve the code; it only lowers the friction of the review. Use it where you are confident the proposal is right and the friction is costing you time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism against it
&lt;/h3&gt;

&lt;p&gt;For reviewers: when the author is a senior, switch to question-form comments. "Is this intentional?" lowers the social cost of a reply from the author, so you get the clarification instead of eating the uncertainty. "This is wrong" from a junior to a staff engineer rarely gets sent. "Why this instead of X?" gets sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Bystander effect: reviews that never start
&lt;/h2&gt;

&lt;p&gt;This is the Meta finding. When a review request fans out to a team instead of a person, the review starts later. Everyone assumes someone else will pick it up. Nobody does. The PR sits.&lt;/p&gt;

&lt;p&gt;Meta's team at Facebook rebuilt reviewer recommendation specifically to kill this. One reviewer, named at request time, with the group as fallback. Review start time dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Default to single-reviewer requests. Group requests are a fallback, not the primary mode.&lt;/li&gt;
&lt;li&gt;When you must request a group, name a point person in the first comment. "&lt;a class="mentioned-user" href="https://dev.to/alice"&gt;@alice&lt;/a&gt; primary, &lt;a class="mentioned-user" href="https://dev.to/team"&gt;@team&lt;/a&gt; as backup" removes the ambiguity even if the UI does not.&lt;/li&gt;
&lt;li&gt;A review older than 24 hours gets a direct ping, not a reshare into the group. Resharing distributes the "someone else" button to more people.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Framing effect: same bug, different reaction
&lt;/h2&gt;

&lt;p&gt;Dr. Michaela Greiler, who studied code review feedback at Microsoft and now consults on code review culture, notes that aggressive feedback tends to slow down fixes and drag down the quality of the author's next PR, even when the technical content of the comment is identical to a constructive version.&lt;/p&gt;

&lt;p&gt;The same N+1 bug, two framings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Negative frame&lt;/strong&gt;: "This N+1 query will cause performance problems. Fix it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Positive frame&lt;/strong&gt;: "If you switch this to eager loading, the query count drops from N+1 to 2. On production traffic the difference should be noticeable."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The technical content is identical. The receiver's reaction is not. One sounds like a problem report. The other sounds like a tip. Michael Lynch, formerly of Google, has a longer set of guidelines for code review comments; three of them do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Talk about the code, not the author. "This line can produce a NullPointerException" is reviewable. "You messed up here" is a feelings reply.&lt;/li&gt;
&lt;li&gt;Attach a why. "Change this" without a reason triggers a defensive reply. "Change this because X returns null in the pagination edge case" triggers a fix.&lt;/li&gt;
&lt;li&gt;Use questions where possible. "Could &lt;code&gt;foo&lt;/code&gt; return null here?" opens the conversation. "This is a bug" closes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;p&gt;A review comment template with three fields — "what", "why", "suggestion" — forces the framing. The field with "suggestion" at the end makes the comment hard to leave hostile without actively choosing to.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ownership bias: taking the review as criticism
&lt;/h2&gt;

&lt;p&gt;The last one lives on the receiving side. If you wrote the code five minutes ago, your sense of ownership is at its peak, and every comment reads as a judgment of you, not of the diff. The defensive-reply reflex fires. You start arguing with suggestions that, read a day later, you would just accept.&lt;/p&gt;

&lt;p&gt;I have the Slack history to prove it: suggestions I defended at 11pm, merged at 9am the next morning without changing a single argument against them. The code did not improve overnight. I did.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Submit the PR a few hours after you finish, or the next morning. Ownership fades with distance. Comments read differently after one night of sleep.&lt;/li&gt;
&lt;li&gt;Reframe the artifact. The codebase is a team's shared product, not a portfolio of personal creations. The reframe sounds abstract and feels silly, until the first time you accept a suggestion that an hour ago you would have fought.&lt;/li&gt;
&lt;li&gt;Reviewers: name one thing you liked in the PR before any comments. "The test naming is clean" is not politeness; it signals that the review is about the diff, not an attack on the author. Reviews that are 100% suggestions trip the ownership reflex.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why mechanisms, not reminders
&lt;/h2&gt;

&lt;p&gt;Across all five, the pattern is the same. Reminders to "be aware" of a bias do not survive the next sprint. Mechanisms do. Hiding the author on the first pass is a mechanism. A single-reviewer default is a mechanism. A comment template with a "suggestion" field is a mechanism. Submitting a PR the next morning is a mechanism.&lt;/p&gt;

&lt;p&gt;Code review is a technical filter and a social situation at the same time, and the social half is the one that decides whether the technical half ever lands. If you want the full set — 20 biases that distort estimates, meetings, and hiring, each with a mechanism rather than a reminder — the book collects them in one place:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/engineer-psychology-tricks?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=5-code-review-biases" rel="noopener noreferrer"&gt;Cognitive Bias for Software Engineers&lt;/a&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>codenewbie</category>
      <category>webdev</category>
      <category>codequality</category>
    </item>
    <item>
      <title>7 Steps to Ship a Knowledge Graph on Neo4j: Step 3 Ate Both My Weekends</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/7-steps-to-ship-a-knowledge-graph-on-neo4j-step-3-ate-both-my-weekends-3pih</link>
      <guid>https://dev.to/kenimo49/7-steps-to-ship-a-knowledge-graph-on-neo4j-step-3-ate-both-my-weekends-3pih</guid>
      <description>&lt;p&gt;Neo4j's official framework for building a knowledge graph is seven steps long. It reads like a well-behaved playbook: define the use case, pick your sources, design the ontology, model the data, ingest, query, operate. If you were reviewing it on a slide it would look completely reasonable.&lt;/p&gt;

&lt;p&gt;Then you sit down on a Friday night to actually build one, and Step 3 quietly reaches out and eats the whole weekend.&lt;/p&gt;

&lt;p&gt;Twice.&lt;/p&gt;

&lt;p&gt;The first time I built a knowledge graph on Neo4j, the ontology-design step ate two full weekends before I could even start writing the first Cypher &lt;code&gt;CREATE&lt;/code&gt; statement. Steps 1, 2, 4, 5, 6, and 7 combined took less time than Step 3 by itself. That is not because the other six steps are trivial. It is because Step 3 is where every unresolved assumption from Steps 1 and 2 comes home to be reconciled, and you cannot fake your way past it.&lt;/p&gt;

&lt;p&gt;Below is the 7-step arc as Neo4j teaches it, with what actually happens between the diagrams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 steps, at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What Neo4j calls it&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Define the use case&lt;/td&gt;
&lt;td&gt;You pick one query you want fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Identify data sources&lt;/td&gt;
&lt;td&gt;You classify structured / semi-structured / unstructured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Design the ontology&lt;/td&gt;
&lt;td&gt;You loop back to Step 1 three times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Model the data&lt;/td&gt;
&lt;td&gt;You write Cypher and remember indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Ingest and transform&lt;/td&gt;
&lt;td&gt;LOAD CSV, APOC, LLM extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Build queries and APIs&lt;/td&gt;
&lt;td&gt;Cypher becomes your business logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Operate and extend&lt;/td&gt;
&lt;td&gt;Freshness, quality monitoring, schema evolution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Steps 1 and 2 look easy on paper. Step 3 is the interesting one. Steps 4-7 are where the real technology sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: define the use case (or watch the whole project rot)
&lt;/h2&gt;

&lt;p&gt;The bad version of a use case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We want to put all our internal data into a knowledge graph."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The good version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A new engineer needs to know, in 30 seconds, which services depend on &lt;code&gt;/api/users&lt;/code&gt; before they change it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What separates the two versions is not scope but &lt;em&gt;specificity&lt;/em&gt;. A specific query pins down three things that would otherwise stay loose for months: which entities you need as nodes, which relationships you need as edges, and which query pattern has to be fast.&lt;/p&gt;

&lt;p&gt;The industry has a graveyard full of KG projects that started with "let us model everything." Neo4j's own guidance is blunt about this: if the use case is not concrete, the graph is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: identify data sources (structured, semi-structured, unstructured)
&lt;/h2&gt;

&lt;p&gt;The classification is the whole step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured&lt;/strong&gt; — CSVs, relational tables, customer masters, product catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-structured&lt;/strong&gt; — JSON API responses, config files, OpenAPI specs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstructured&lt;/strong&gt; — PDFs, meeting notes, source code, chat logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason this classification matters is that it decides how you get into Step 5. Structured data walks into Neo4j through &lt;code&gt;LOAD CSV&lt;/code&gt;. Semi-structured comes in via APOC. Unstructured needs an LLM extractor between the source and the graph.&lt;/p&gt;

&lt;p&gt;In September 2026 the LLM-extractor side of this has become a solved product problem: Neo4j's own &lt;a href="https://neo4j.com/labs/genai-ecosystem/llm-graph-builder/" rel="noopener noreferrer"&gt;LLM Knowledge Graph Builder&lt;/a&gt; requires Neo4j 5.23+ and now supports OpenAI, Gemini, Claude, Llama 3, Amazon Nova, Diffbot, and Qwen as the extraction model. The &lt;code&gt;neo4j-graphrag-python&lt;/code&gt; SimpleKGPipeline turns on structured-output extraction when the LLM supports it, which cuts the "the model returned invalid JSON" retries you would otherwise write yourself. If you are starting today, you build the extractor last and use these tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: ontology design (the one that ate the weekends)
&lt;/h2&gt;

&lt;p&gt;This is where the trouble happens. On paper, an ontology looks like a tiny document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node labels:
  - Service (name, version, team)
  - API (path, method, status)
  - Developer (name, email, team)
  - Repository (name, url, language)

Edge types:
  - EXPOSES:    Service -&amp;gt; API
  - CALLS:      API -&amp;gt; API
  - MAINTAINS:  Developer -&amp;gt; Service
  - HOSTED_IN:  Service -&amp;gt; Repository
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is maybe 15 lines. How did that eat two weekends?&lt;/p&gt;

&lt;p&gt;Because writing those 15 lines forces you to answer questions Step 1 quietly deferred. Is a deployed instance of a service a &lt;code&gt;Service&lt;/code&gt; or a &lt;code&gt;Deployment&lt;/code&gt;? Is a &lt;code&gt;Repository&lt;/code&gt; really one node, or one per branch? Does a &lt;code&gt;MAINTAINS&lt;/code&gt; edge point from the developer to the service or the other way around, and does the direction change what queries become expensive?&lt;/p&gt;

&lt;p&gt;The moment you start writing Cypher against a draft ontology, you find edges that need properties, node labels that need to split in two, and relationships whose direction you had wrong. You go back to Step 1, reread the use case, and change Step 3. Then you go back to Step 3 and change it again. The rough pattern I ended up with: Step 3 makes you loop back to Step 1 about three times before it settles.&lt;/p&gt;

&lt;p&gt;The lesson I wish I had absorbed earlier:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do not design the ontology in isolation. Sketch it in the same file as three sample Cypher queries you actually plan to run. If any of the three cannot be written cleanly, the ontology is wrong, not the query.&lt;/li&gt;
&lt;li&gt;Start small. Two node labels and one edge type is a legitimate first iteration. Expanding is easy; unpicking is a weekend.&lt;/li&gt;
&lt;li&gt;Read edges as sentences. "Service EXPOSES API." "Developer MAINTAINS Service." If you cannot say the edge out loud without it sounding weird, the direction is off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I would also, in retrospect, have used Neo4j's Graph Consolidation feature earlier. It rolls a sprawling schema into fewer, more meaningful node labels and relationship types automatically. On a first attempt this is exactly the kind of judgement I did not yet have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: data modelling in Cypher
&lt;/h2&gt;

&lt;p&gt;Once the ontology settles, Step 4 is mechanical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="s2"&gt;"2.1"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;team:&lt;/span&gt; &lt;span class="s2"&gt;"Platform"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"AuthService"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="s2"&gt;"1.5"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;team:&lt;/span&gt; &lt;span class="s2"&gt;"Security"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:API&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;method:&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;status:&lt;/span&gt; &lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;}),&lt;/span&gt;
      &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;     &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXPOSES&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;since:&lt;/span&gt; &lt;span class="s2"&gt;"2024-01-15"&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s.name&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a.path&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most common mistake at this step: forgetting the indexes. A KG with millions of nodes and no index on the query's start-node property will take seconds per query, and you will spend a week thinking your ontology is wrong when the real problem is a &lt;code&gt;CREATE INDEX&lt;/code&gt; line you never wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: ingestion and transformation
&lt;/h2&gt;

&lt;p&gt;For structured data, &lt;code&gt;LOAD CSV WITH HEADERS&lt;/code&gt; is the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="k"&gt;CSV&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;HEADERS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'file:///services.csv'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;name:&lt;/span&gt;    &lt;span class="n"&gt;row.name&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="n"&gt;row.version&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;team:&lt;/span&gt;    &lt;span class="n"&gt;row.team&lt;/span&gt;
&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For unstructured data, the modern move is to hand the LLM a schema-constrained extraction prompt rather than a free-form one. &lt;code&gt;SimpleKGPipeline&lt;/code&gt; in &lt;code&gt;neo4j-graphrag-python&lt;/code&gt; does this for you when the underlying model supports structured output; you get JSON that conforms to your ontology rather than JSON that hopes to. The retries you save at this step add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: queries and APIs
&lt;/h2&gt;

&lt;p&gt;This is where the graph starts paying rent. The three query shapes I ended up using most:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius — what breaks if I change this API?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;target:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CALLS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;caller:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXPOSES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;affected_service&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;caller.path&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;via_api&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shortest path between two services.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shortestPath&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;b:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"PaymentService"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Community detection — which services cluster together?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;gds.louvain.stream&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'service-graph'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;gds.util.asNode&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="n"&gt;.name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cypher is where a knowledge graph stops being a nice diagram and starts being business logic. Those three shapes cover most of what you actually reach for once the graph is live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: operate and extend
&lt;/h2&gt;

&lt;p&gt;The step nobody wants to write about, because it does not screenshot well.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Freshness.&lt;/strong&gt; The source data changes; your graph does not, unless you have a sync pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality.&lt;/strong&gt; Orphan nodes and duplicate entities creep in. Schedule a job to find and merge them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema evolution.&lt;/strong&gt; New use cases mean new node labels and new edges. Version your ontology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; Not every team should see every subgraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neo4j AuraDB shoulders most of the infrastructure side of this, which is why a lot of small teams end up on Aura even if they started on a self-hosted instance. The trade is real: less flexibility, less ops.&lt;/p&gt;

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

&lt;p&gt;If I were starting over today with the same use case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the three killer queries first, before the ontology.&lt;/strong&gt; The queries constrain the ontology far better than the ontology constrains the queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the first ontology at two node types and one edge type.&lt;/strong&gt; Expand only when a query forces the expansion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;SimpleKGPipeline&lt;/code&gt; (or the LLM Knowledge Graph Builder if you want a UI) instead of hand-writing extraction prompts.&lt;/strong&gt; The structured-output layer alone saves days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not skip the indexes.&lt;/strong&gt; Ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for Step 3 to take longer than Steps 1, 2, 4, 5 combined.&lt;/strong&gt; If it does not, either your use case is unusually narrow or you are about to find out you missed something.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 7-step arc is honest. It is the &lt;em&gt;shape of the effort&lt;/em&gt; inside each step that the diagram does not capture. Once you know Step 3 is going to be the weekend eater, you can plan around it — you sketch queries alongside the ontology, you keep the first pass small, and you accept that "the graph is designed" is a claim you cannot make until you have written Cypher against it.&lt;/p&gt;

&lt;p&gt;If you want the longer version, my Zenn book &lt;a href="https://kenimoto.dev/books/knowledge-graph-practical-guide?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=neo4j-kg-7-steps" rel="noopener noreferrer"&gt;Knowledge Graph Practical Guide&lt;/a&gt; walks the full arc, and Chapter 4 is exactly this 7-step build — including the ontology-loop story that turned my weekends into a lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/labs/genai-ecosystem/llm-graph-builder/" rel="noopener noreferrer"&gt;LLM Knowledge Graph Builder (Neo4j Labs)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/blog/developer/llm-knowledge-graph-builder-release/" rel="noopener noreferrer"&gt;LLM knowledge graph builder — First release of 2025&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/docs/neo4j-graphrag-python/current/user_guide_kg_builder.html" rel="noopener noreferrer"&gt;User Guide: Knowledge Graph Builder (neo4j-graphrag-python)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j Labs — &lt;a href="https://github.com/neo4j-labs/llm-graph-builder/releases" rel="noopener noreferrer"&gt;llm-graph-builder GitHub releases&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Local LLM vs Claude Code: 96% of My Requests Failed Locally. Half the Steps Didn't.</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 29 Sep 2026 19:53:48 +0000</pubDate>
      <link>https://dev.to/kenimo49/local-llm-vs-claude-code-96-of-my-requests-failed-locally-half-the-steps-didnt-1600</link>
      <guid>https://dev.to/kenimo49/local-llm-vs-claude-code-96-of-my-requests-failed-locally-half-the-steps-didnt-1600</guid>
      <description>&lt;p&gt;The pitch for running Claude Code on a local model goes like this: point it at Ollama, stop paying per token, keep your code on your machine. I have an RTX 4070 with &lt;code&gt;qwen3.5:4b&lt;/code&gt; on it, so I wanted that to be true.&lt;/p&gt;

&lt;p&gt;Before swapping anything, I counted. I took 100 requests I had actually sent to Claude Code and asked, for each one, whether a local model could have done it end to end.&lt;/p&gt;

&lt;p&gt;96 could not. So much for cancelling the subscription.&lt;/p&gt;

&lt;p&gt;Then I counted a different way, and the answer flipped. This post is about the second count and the small config file that came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting whole requests: 96 of 100 go to the frontier
&lt;/h2&gt;

&lt;p&gt;A request to Claude Code is usually one line. "Fix the failing test in the billing module." "Why is this deploy slow?" The request itself contains very little context. The work happens after that, when the agent reads files, runs commands, fetches docs, and decides what to try next.&lt;/p&gt;

&lt;p&gt;So when you judge the request as a whole, you are judging the hardest part of it: the planning. That part needs the frontier model almost every time. In my set, 96 of the 100 had to go there.&lt;/p&gt;

&lt;p&gt;If you route at the request level, a local model gets almost nothing. Swapping the whole agent onto a local model asks it to do exactly that planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting single steps: 97 of 200 run fine locally
&lt;/h2&gt;

&lt;p&gt;The agent's work is a chain of steps: tool calls, fetches, summaries, commit messages. I broke Claude Code sessions into 200 of those steps and asked the same question for each one.&lt;/p&gt;

&lt;p&gt;97 of 200 were fine on a local model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq852gmze1hevd3va8v1l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq852gmze1hevd3va8v1l.png" alt="Dot chart: 4 of 100 whole requests could run on a local LLM, versus 97 of 200 single steps" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clearest case was &lt;code&gt;WebFetch&lt;/code&gt;: fetch a page, pull out the part that answers a question. That is extraction, not planning. I tried it on 20 real fetches, and it was not perfect: some pages never loaded, and some answers came back partly wrong. That is why the example config sends a failed fetch to the frontier (&lt;code&gt;on_fetch_error: frontier&lt;/code&gt;). But when the page arrived, the local model usually got the extraction right.&lt;/p&gt;

&lt;p&gt;Claude Code already does a version of this. Its own &lt;code&gt;WebFetch&lt;/code&gt; tool description says the fetched page is processed with a small, fast model. That makes the tool itself an example of choosing a model per step.&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule beat a 9B router
&lt;/h2&gt;

&lt;p&gt;My first router asked a local model for a probability: "should this step go local?" It worked, sort of. Then I wrote a one-line rule, "WebFetch goes local", and the rule beat the 9B model's probability.&lt;/p&gt;

&lt;p&gt;That changed the design. Rules decide what rules can decide. When enabled, the probability judge only sees what is left. Here is the rules block from the companion repo's example config:&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# step rules, top to bottom, first match wins&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;WebFetch&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Agent&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;commit_message&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;         &lt;span class="c1"&gt;# frontier until a probability judge is added&lt;/span&gt;

&lt;span class="na"&gt;probability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;           &lt;span class="c1"&gt;# off until measured with the same judge model&lt;/span&gt;
&lt;span class="na"&gt;default_route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probability judge is &lt;strong&gt;off by default&lt;/strong&gt;. The routing function is short:&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;probability&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_threshold&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;probability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_threshold&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frontier&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;probability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my config, sub-agents (&lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Task&lt;/code&gt;) are pinned to the frontier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft240dpx0e6357yex1hbp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft240dpx0e6357yex1hbp.png" alt="Routing table checked top to bottom: WebFetch goes local, Agent and Task go to the frontier, commit messages go to the frontier, the probability judge is skipped, anything else defaults to the frontier" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a probability out of Ollama
&lt;/h2&gt;

&lt;p&gt;For the steps rules can't settle, you want a number, not a paragraph. The trick is to make the model answer with one letter (Y or N) and read that letter's probability from &lt;code&gt;logprobs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the request body I send to Ollama's native &lt;code&gt;/api/chat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"qwen3.5:4b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stream"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"logprobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"top_logprobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"num_predict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"... Answer with a single letter, Y or N."&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things fail silently here, with no error:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The OpenAI-compatible endpoint drops the probabilities.&lt;/strong&gt; Use the native API. The repo's scripts expect Ollama 0.12.11 or later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking models don't answer in the first token.&lt;/strong&gt; The first token is the start of their reasoning, so the Y/N you are looking for isn't there. &lt;code&gt;think: false&lt;/code&gt; and &lt;code&gt;num_predict: 1&lt;/code&gt; pin it to the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A candidate outside the top 20 looks like probability 0.&lt;/strong&gt; Also, &lt;code&gt;" Y"&lt;/code&gt; and &lt;code&gt;"Y"&lt;/code&gt; are different tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix for the third one is to add up the probability that landed on your candidate letters and refuse to trust the judgment when that total is small:&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lab&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logprobs&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_logprobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# " Y" and "Y" are the same answer
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logprob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;mass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# below 0.5: the answer wasn't where we looked
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  0.5 is a line nobody chose
&lt;/h2&gt;

&lt;p&gt;I used the same kind of judge as a gate in front of Claude Code: "does this request contain a secret?" It read a note with a production database password in it and said it was fine, with 69% confidence. The password was there with 100% confidence.&lt;/p&gt;

&lt;p&gt;The ranking was fine. Secrets scored higher than non-secrets. The scale was off: real secrets were landing well below 0.5. Cut at 0.5, the gate missed 20 secrets. When I drew the line from my own labelled examples instead, it missed 2.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F45s2mbt2c4uv1r07l1na.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F45s2mbt2c4uv1r07l1na.png" alt="A local secrets gate cut at 0.5 missed 20 secrets; with a line drawn from my own labelled examples it missed 2" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is why the gate threshold in the example config looks strange:&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;judge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen3.5:4b&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.0071&lt;/span&gt;    &lt;span class="c1"&gt;# example only. lowest P among your confidential examples x 0.3&lt;/span&gt;
  &lt;span class="na"&gt;at_or_above&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block&lt;/span&gt;
&lt;span class="na"&gt;on_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.0071&lt;/code&gt; is only an example from my setup, so don't copy it. The repo has a small CLI (&lt;code&gt;steprouter-threshold&lt;/code&gt;) that takes your labelled judgments and prints a line, AUROC, and 5-fold cross-validated misses and false alarms.&lt;/p&gt;

&lt;p&gt;A regex pass (gitleaks plus personal-data patterns) runs before the model. It catches secrets that have a recognizable shape. The model is there for the ones that don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free local worker cost the most
&lt;/h2&gt;

&lt;p&gt;One more result surprised me. I set up a strong model to plan and a free local model to do the work. That setup was the most expensive one I tested. Free, the way a puppy is free.&lt;/p&gt;

&lt;p&gt;The local model's tokens were free. The orchestrator re-reading everything the worker sent back was not. What comes back from a routed step is part of the routing, so the example config pins the return value to a small typed shape:&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;return&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;green&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mypy&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ruff&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pytest_failed&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;summary_max_lines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;full_log&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass/fail counts, the files touched, and one or two lines of what was done. The full log is written to a file instead of the reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own logs
&lt;/h2&gt;

&lt;p&gt;My numbers come from my work. To get yours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take a day of Claude Code sessions and list the steps, not the requests.&lt;/li&gt;
&lt;li&gt;Mark which step types always go one way. Those become rules.&lt;/li&gt;
&lt;li&gt;Leave the probability judge off until you have measured it on your own examples.&lt;/li&gt;
&lt;li&gt;Before you add a local worker, decide what it is allowed to send back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The companion code, including the gate, the logprobs judge, the threshold CLI, the router, and a made-up evaluation set, is on GitHub as &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;local-step-router&lt;/a&gt; (MIT). Clone it, run &lt;code&gt;pip install -e .&lt;/code&gt;, and start by editing &lt;code&gt;examples/routing.yaml&lt;/code&gt;. The README has an English summary at the bottom.&lt;/p&gt;




&lt;p&gt;The full measurement (550 of my own requests and steps on one RTX 4070, how big the judge model needs to be from 0.8B to 35B, why none of my six real secrets matched the evaluation set, and the final blueprint) is written up in &lt;a href="https://kenimoto.dev/books/local-llm-step-routing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=local-llm-vs-cc-96" rel="noopener noreferrer"&gt;Local LLM or Claude Code?&lt;/a&gt;. Chapters 2 and 3 cover the step count and the rules-first router; chapter 8 is the threshold worksheet.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>llm</category>
      <category>agents</category>
    </item>
    <item>
      <title>200ms Humans vs 700ms Voice Agents: What ACL IWSDS 2025 Says About the Turn-Taking Gap</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/200ms-humans-vs-700ms-voice-agents-what-acl-iwsds-2025-says-about-the-turn-taking-gap-2iap</link>
      <guid>https://dev.to/kenimo49/200ms-humans-vs-700ms-voice-agents-what-acl-iwsds-2025-says-about-the-turn-taking-gap-2iap</guid>
      <description>&lt;p&gt;The complaint that keeps showing up in every voice-agent beta test I've watched is not about the wrong answer. It's about the &lt;em&gt;timing&lt;/em&gt; of the right answer.&lt;/p&gt;

&lt;p&gt;"The AI cut me off." "It answered while I was still thinking." "I said 'um' and it started talking." Latency looks like a stopwatch problem, but users experience it as a manners problem, which is a much harder thing to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 200ms vs 700ms gap the field is trying to close
&lt;/h2&gt;

&lt;p&gt;The ACL IWSDS 2025 survey on turn-taking in spoken dialogue puts a concrete number on the mismatch. In natural human conversation, a listener starts their reply roughly &lt;strong&gt;200ms&lt;/strong&gt; after the speaker's turn ends. Current dialogue agents take &lt;strong&gt;700 to 1000ms&lt;/strong&gt; to do the same thing.&lt;/p&gt;

&lt;p&gt;Three to five times slower. If human dialogue is a tennis rally, current voice AI is playing chess by post.&lt;/p&gt;

&lt;p&gt;Closing that gap is not one problem, it's two. And they pull in opposite directions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too slow to reply&lt;/strong&gt;: awkward silence, dropped rapport, users start repeating themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too fast to reply&lt;/strong&gt;: the agent talks over the user, interrupts mid-word, kills trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both fail modes come from the same root cause: silence is not the same as end-of-turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plain VAD is not enough
&lt;/h2&gt;

&lt;p&gt;Most current systems still decide the user is done talking with a &lt;strong&gt;Voice Activity Detector&lt;/strong&gt;. VAD is cheap and fast, and it answers exactly one question: is there voice in this audio frame, yes or no.&lt;/p&gt;

&lt;p&gt;That question is not the one the agent actually needs answered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The 0.3–0.5s pause a speaker takes mid-sentence looks identical to end-of-turn.&lt;/li&gt;
&lt;li&gt;The silence after "uh" or "let me think" looks identical to end-of-turn.&lt;/li&gt;
&lt;li&gt;A cough or a sigh looks like voice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can tune the silence threshold, but you cannot tune your way out of the fact that VAD does not know what a sentence is. VoiceInfra's production data lands on &lt;strong&gt;300–500ms&lt;/strong&gt; of trailing silence as the least-bad setting: shorter and the agent chops off natural pauses, longer and the delay becomes obvious. The comfortable range depends on the use case. Call-center flows tolerate 400–500ms because callers pause to think; command interfaces want 200–300ms because utterances are short; long-form narration wants 500–600ms because the pauses inside the story are longer.&lt;/p&gt;

&lt;p&gt;The threshold is a compromise, not a fix. The real fix has to know what the words &lt;em&gt;mean&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic endpointing: Deepgram Flux and the "one model does both" bet
&lt;/h2&gt;

&lt;p&gt;The interesting move in 2026 is folding transcription and turn detection into the same model. Deepgram's &lt;strong&gt;Flux&lt;/strong&gt;, launched as their first ASR built for voice agents rather than for general transcription, does exactly that. The model outputs turn boundaries directly from audio, using the same joint architecture it uses to output text. Because the same weights see both the acoustic signal and the emerging transcript, the model has a shot at answering the harder question: "is this utterance semantically complete?"&lt;/p&gt;

&lt;p&gt;Flux exposes three knobs that make the tradeoff explicit rather than hidden inside a silence threshold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eot_threshold&lt;/code&gt; — how confident the model has to be before it commits to end-of-turn.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eager_eot_threshold&lt;/code&gt; — a lower bar for tentative end-of-turn, useful for starting inference speculatively.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eot_timeout_ms&lt;/code&gt; — the maximum silence duration before the model forces end-of-turn even if the confidence never crosses the threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also switch modes per-turn with a Configure message, so a barge-friendly interaction ("agent, wait") and a monologue turn ("read me the terms") do not have to share a threshold.&lt;/p&gt;

&lt;p&gt;The pattern is a hint at where the field is going. Turn-taking will stop being a pipeline stage after ASR and start being a joint output of the ASR itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Krisp's 6M-parameter turn model: same idea, edge-shaped
&lt;/h2&gt;

&lt;p&gt;Not everyone can afford a cloud round-trip on every silence check. Krisp ships two small audio-only models trained to handle the acoustic cues a naive VAD collapses — a &lt;strong&gt;9M-parameter&lt;/strong&gt; turn prediction model (~30MB) that scores end-of-turn from prosody and pausing patterns, and a &lt;strong&gt;6M-parameter&lt;/strong&gt; interruption model (~24MB) that separates real barge-ins from backchannels like "yeah" and "mhm". Together they cover the same failure modes VAD misclassifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;intentional speech vs a thinking pause&lt;/li&gt;
&lt;li&gt;filler words (um, uh, well) mid-sentence&lt;/li&gt;
&lt;li&gt;backchannels vs interruptions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that size it runs on-device in real time, which matters for privacy-sensitive deployments and for wearables where the round-trip to the cloud is itself the biggest source of latency.&lt;/p&gt;

&lt;p&gt;The Krisp and Flux paths look opposite. One shrinks the model, the other gives the ASR the extra job. But they are attacking the same VAD failure mode from opposite sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful abort: what to do when you're wrong
&lt;/h2&gt;

&lt;p&gt;No detector, semantic or acoustic, is going to be right every time. The interesting question is what your pipeline does when it decides "the user is done" and turns out to be wrong.&lt;/p&gt;

&lt;p&gt;Twilio's &lt;strong&gt;graceful abort&lt;/strong&gt; pattern is the cleanest version I've seen written up. When STT signals end-of-turn early, the LLM starts generating a response. If fresh audio arrives before the response reaches the speaker, the pipeline kills the generation in flight and swallows the partial output. The window for this is the few hundred milliseconds it takes STT → LLM → TTS to produce audible speech. If you can revoke the guess inside that window, the user never hears it.&lt;/p&gt;

&lt;p&gt;The economics matter here: you are paying for LLM tokens on turns that get thrown away. In exchange, you get to be aggressive on end-of-turn detection without punishing the user when you're aggressive-and-wrong. For most conversational products that tradeoff is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Barge-in: hearing the user through your own voice
&lt;/h2&gt;

&lt;p&gt;The other half of turn-taking is the reverse case: the user interrupts while the agent is still speaking. Detecting that seems trivial until you realize the AI's own audio is leaking into the microphone, and any naive VAD will happily flag that leak as a barge-in and abort the agent's own utterance.&lt;/p&gt;

&lt;p&gt;Sparkco's write-up on duplex barge-in handling is the clearest description of the fix I've read. Three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full-duplex audio&lt;/strong&gt; — keep the mic hot the entire time the agent is speaking, never gate it on TTS output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Echo cancellation&lt;/strong&gt; — subtract the speaker output (as a reference signal) from the mic input, so what remains is the user's voice minus the agent's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nuisance rejection&lt;/strong&gt; — filter environmental noise and short transients that a bare VAD would misclassify as speech onsets.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[agent is speaking]
  speaker out ──► reference signal ─┐
                                    ▼
  mic in ─────► echo canceller ────► user voice only ──► VAD / turn model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without echo cancellation you get the failure mode users describe as "the AI got startled by its own voice and stopped talking." The dog scared of its own reflection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bot-feel paradox and the 200-300ms delay trick
&lt;/h2&gt;

&lt;p&gt;Now the counterintuitive move. Once you've closed the acoustic and semantic gaps and the agent &lt;em&gt;can&lt;/em&gt; reply in 300ms, it turns out that replying in 300ms feels &lt;em&gt;worse&lt;/em&gt;. The response arrives before the user has finished processing their own sentence, and it registers as robotic rather than sharp.&lt;/p&gt;

&lt;p&gt;The fix is to inject &lt;strong&gt;200–300ms of intentional delay before the agent starts speaking, while continuing to run the LLM in the background&lt;/strong&gt;. You get the "thinking for a moment" cue humans read as attention, without paying real latency for it. The tokens are already streaming; you're just holding the TTS start.&lt;/p&gt;

&lt;p&gt;It's a UX trick, not a technical one. But it's the piece of the puzzle that gets forgotten when a team spends a quarter shaving milliseconds off ASR and then wonders why user ratings didn't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually closes the 500ms gap
&lt;/h2&gt;

&lt;p&gt;There is no single component that takes you from 700ms to 200ms. You get there by stacking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Semantic endpointing (Flux-class)&lt;/td&gt;
&lt;td&gt;Stops chopping mid-sentence, stops waiting for silence that already means end-of-turn.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small on-device turn + interruption models (Krisp-class)&lt;/td&gt;
&lt;td&gt;Score end-of-turn and separate backchannels from real barge-ins without a round-trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graceful abort&lt;/td&gt;
&lt;td&gt;Lets you be aggressive on early end-of-turn without punishing the user when wrong.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplex barge-in with AEC&lt;/td&gt;
&lt;td&gt;Lets you leave the mic hot without the agent interrupting itself.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200–300ms intentional delay&lt;/td&gt;
&lt;td&gt;Buys back the "thinking" cue that pure speed removes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The move that unlocks the stack is admitting that end-of-turn is a language problem, not a silence problem. Once you accept that, VAD-only pipelines look the way pre-BERT NLP pipelines look now: perfectly reasonable for their era, obviously incomplete in retrospect.&lt;/p&gt;

&lt;p&gt;The gap to 200ms is not going to close by tuning thresholds. It closes when the model that hears the audio is the same model that understands the sentence.&lt;/p&gt;




&lt;p&gt;The full 300ms-UX playbook (latency budgets by pipeline stage, when to pick Whisper vs Deepgram vs Piper, and how to design the wait states so users forgive the last 200ms you can't remove) is written up in &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=turn-taking-200-vs-700" rel="noopener noreferrer"&gt;Voice AI 300ms UX&lt;/a&gt;. Chapter 9 covers turn-taking end-to-end; chapters 4 and 5 cover the latency anatomy that decides whether closing the gap is even possible for your stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ACL IWSDS 2025. "A Survey of Recent Advances on Turn-taking Modeling in Spoken Dialogue."&lt;/li&gt;
&lt;li&gt;Krisp AI. "Audio-only, 6M weights Turn-Taking model for Voice AI Agents." 2025.&lt;/li&gt;
&lt;li&gt;Twilio. "Core Latency in AI Voice Agents." 2025.&lt;/li&gt;
&lt;li&gt;Deepgram. "Flux: End-of-Turn Detection Parameters." 2026.&lt;/li&gt;
&lt;li&gt;Sparkco. "Optimizing Voice Agent Barge-in Detection." 2025.&lt;/li&gt;
&lt;li&gt;VoiceInfra. "Voice AI Prompt Engineering: Complete Technical Guide." 2025.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>voiceai</category>
      <category>webrtc</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
