<?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: Naser Sadrmanesh</title>
    <description>The latest articles on DEV Community by Naser Sadrmanesh (@sadrmanesh).</description>
    <link>https://dev.to/sadrmanesh</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%2F4213859%2F32b251e9-b2fc-4e5f-85ff-47afae6fd311.jpg</url>
      <title>DEV Community: Naser Sadrmanesh</title>
      <link>https://dev.to/sadrmanesh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sadrmanesh"/>
    <language>en</language>
    <item>
      <title>Safe write tools for agents: preview, confirm, audit, and stale previews in a WooCommerce MCP server</title>
      <dc:creator>Naser Sadrmanesh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:59:14 +0000</pubDate>
      <link>https://dev.to/sadrmanesh/safe-write-tools-for-agents-preview-confirm-audit-and-stale-previews-in-a-woocommerce-mcp-server-2c66</link>
      <guid>https://dev.to/sadrmanesh/safe-write-tools-for-agents-preview-confirm-audit-and-stale-previews-in-a-woocommerce-mcp-server-2c66</guid>
      <description>&lt;p&gt;Reading data is the easy part of connecting an agent to a store. Listing orders, summarizing sales, finding low stock: if the model gets one of those wrong, you get a wrong answer and you ask again. Writes are different. "Raise the price of these 40 products by 10%" is one sentence for the user and 40 irreversible changes for the store.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/sadrmanesh/woocommerce-mcp" rel="noopener noreferrer"&gt;woocommerce-mcp&lt;/a&gt;, an open-source Model Context Protocol server for WooCommerce. It exposes products, orders, refunds, coupons, customers, stock, webhooks, and analytics as MCP tools, over stdio or authenticated streamable HTTP. This post is about one part of it: how the write tools are designed so that an agent cannot change a store in a single step, and what changed in v0.5.0 when I added stale preview detection.&lt;/p&gt;

&lt;p&gt;I will also be explicit about what the design does not guarantee, because a safety feature that overstates itself is worse than none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with one-step write tools
&lt;/h2&gt;

&lt;p&gt;The obvious way to write an MCP tool is: take arguments, call the API, return the result. For a write, that means the model's tool call &lt;em&gt;is&lt;/em&gt; the change. There is no point where a human sees what is about to happen.&lt;/p&gt;

&lt;p&gt;There are three separate failure modes hiding in that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The model asks for the wrong thing.&lt;/strong&gt; It picks the wrong product id, misreads "10" as a fixed amount instead of a percentage, or targets the wrong store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The arguments change between "I showed the user" and "I ran it".&lt;/strong&gt; A client shows a plan, the user approves, and then the model retries with slightly different arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The store changes underneath the plan.&lt;/strong&gt; The user approves "+10%" when a product costs 20.00. Before they click confirm, someone else edits the price to 25.00. The same arguments now produce 27.50, a number nobody approved.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first one is solved by showing the user the exact request. The second and third need something that ties the confirm to the preview. That is what the rest of this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: stores are read-only until you say otherwise
&lt;/h2&gt;

&lt;p&gt;Every store starts read-only. A write tool fails with &lt;code&gt;read_only&lt;/code&gt; before it contacts the store unless you set &lt;code&gt;WOOCOMMERCE_READ_ONLY=false&lt;/code&gt; (single store) or &lt;code&gt;"readOnly": false&lt;/code&gt; in a store list. Over HTTP, write tools also need the &lt;code&gt;woocommerce:write&lt;/code&gt; scope, so a read-only token can never change anything.&lt;/p&gt;

&lt;p&gt;This sounds trivial, but it decides the default. Someone who installs the server to ask "what sold best last week?" never has to think about writes at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: two calls instead of one
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;WOOCOMMERCE_REQUIRE_CONFIRMATION=true&lt;/code&gt; (the default), the first call to a write tool changes nothing. It returns the planned REST request:&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"confirmation_required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previewId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pv_4f1c9a2e7b3d0856"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The change was NOT applied. ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders/1015"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling the same tool again with the same arguments and &lt;code&gt;confirm: true&lt;/code&gt; applies it. &lt;code&gt;dryRun: true&lt;/code&gt; always previews, even if confirmation is turned off.&lt;/p&gt;

&lt;p&gt;The interesting engineering question is how you produce that preview without writing a second, parallel "describe what you would do" implementation for every tool. Two implementations drift. I did not want 19 write tools each with a real path and a preview path.&lt;/p&gt;

&lt;h3&gt;
  
  
  The planning client
&lt;/h3&gt;

&lt;p&gt;Every tool talks to the store through a &lt;code&gt;WooCommerceClient&lt;/code&gt;. The client has a &lt;code&gt;planning()&lt;/code&gt; view that behaves normally for reads and throws on the first write instead of sending it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Returns a client that performs reads normally but stops at the first write
 * and throws a {@link PlannedWrite} describing the request it would send.
 */&lt;/span&gt;
&lt;span class="nf"&gt;planning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PlanningOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;WooCommerceClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WooCommerceClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&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;Inside &lt;code&gt;request()&lt;/code&gt;, a non-GET on a planning client becomes a &lt;code&gt;PlannedWrite&lt;/code&gt; error carrying method, path, query, and body. The registry runs the tool's real &lt;code&gt;run()&lt;/code&gt; function against that client and catches the error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;AnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WooCommerceClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Plan&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RecordedRead&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;planner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;planning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;planner&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;captureState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PlannedWrite&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;captureState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the preview is produced by the same code that does the write. If &lt;code&gt;bulk_adjust_prices&lt;/code&gt; computes &lt;code&gt;regular_price&lt;/code&gt; from the current price, the preview contains the computed absolute value, because it is literally the body the tool tried to send. If the tool finishes without trying to write (for example every product was skipped), the response is &lt;code&gt;no_change&lt;/code&gt; instead of a confirmation request.&lt;/p&gt;

&lt;p&gt;The trade-off: a tool that makes several requests, such as &lt;code&gt;woocommerce_fulfill_order&lt;/code&gt; (record tracking, complete the order, notify the customer), is previewed only up to its first write. I document that rather than pretend the preview covers the whole sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the previewId
&lt;/h2&gt;

&lt;p&gt;Two calls are not enough on their own. Nothing stops the confirm call from carrying different arguments than the preview. So in v0.4.0 every &lt;code&gt;confirmation_required&lt;/code&gt;, &lt;code&gt;dry_run&lt;/code&gt;, and &lt;code&gt;no_change&lt;/code&gt; response started including a &lt;code&gt;previewId&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;previewIdFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;canonicalJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`pv_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canonicalJson&lt;/code&gt; sorts object keys and drops &lt;code&gt;undefined&lt;/code&gt; members, so &lt;code&gt;{ "id": 1, "status": "x" }&lt;/code&gt; and &lt;code&gt;{ "status": "x", "id": 1 }&lt;/code&gt; hash the same. &lt;code&gt;store&lt;/code&gt;, &lt;code&gt;dryRun&lt;/code&gt;, &lt;code&gt;confirm&lt;/code&gt;, and &lt;code&gt;previewId&lt;/code&gt; itself are excluded from the hash.&lt;/p&gt;

&lt;p&gt;On a confirm call that sends &lt;code&gt;previewId&lt;/code&gt;, the server recomputes the id from the call's own tool, store, and arguments. If it differs, the call is refused with &lt;code&gt;preview_mismatch&lt;/code&gt; and nothing is sent. This check reads nothing from the store, so it is cheap and runs first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a fingerprint and not a token
&lt;/h3&gt;

&lt;p&gt;I deliberately did not make &lt;code&gt;previewId&lt;/code&gt; a random one-time token stored on the server. A fingerprint has properties I wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No server state.&lt;/strong&gt; The server keeps no table of issued previews. It survives restarts, works across HTTP sessions, and works the same with one replica or several.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic.&lt;/strong&gt; The same call always gets the same id, which makes audit logs easy to join and tests easy to write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also has costs, and the docs list them: the server does not check that a preview was actually requested, how old it is, or whether it was already used. It is a fingerprint, not a secret. If you need single use approvals, you need state, and that is a different design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;previewId&lt;/code&gt; is also optional. A confirm without it still works, because breaking every existing client to add a safety check is a good way to make people turn the check off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: stale previews (new in v0.5.0)
&lt;/h2&gt;

&lt;p&gt;The previewId in v0.4.0 answered "did the caller change what it asked for?". It did not answer failure mode 3: "did the store change?". If a price moves from 20.00 to 25.00 between preview and confirm, the arguments &lt;code&gt;{ mode: "percent", change: 10 }&lt;/code&gt; are identical, so the arguments fingerprint matches, and the server would happily write 27.50.&lt;/p&gt;

&lt;p&gt;v0.5.0 adds a second part to the id for tools that read before they write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pv_e2a742fe51c807fc_a3510d17b8a51b93
   |&amp;lt;-- arguments --&amp;gt;| |&amp;lt;-- state --&amp;gt;|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Recording what the plan read
&lt;/h3&gt;

&lt;p&gt;The planning client now runs with &lt;code&gt;fresh: true&lt;/code&gt; (it bypasses the read cache) and an &lt;code&gt;onRead&lt;/code&gt; callback that records every successful read. After planning, &lt;code&gt;captureState&lt;/code&gt; fingerprints those reads. For every record returned it keeps the id, the &lt;code&gt;date_modified&lt;/code&gt;, the fields the write sets, and a fixed list of fields writes are commonly computed from: &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;regular_price&lt;/code&gt;, &lt;code&gt;sale_price&lt;/code&gt;, the sale dates, &lt;code&gt;manage_stock&lt;/code&gt;, &lt;code&gt;stock_quantity&lt;/code&gt;, &lt;code&gt;stock_status&lt;/code&gt;, and &lt;code&gt;total&lt;/code&gt;. Payloads that are not records are hashed whole.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dateModified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stateId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonicalJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not hash the whole response? Because WooCommerce responses contain fields that change for reasons unrelated to the write, and a state id that changes on every request would make every confirm stale. Hashing only what the write could plausibly depend on, plus &lt;code&gt;date_modified&lt;/code&gt; as a catch-all, keeps false positives low without ignoring real changes.&lt;/p&gt;

&lt;p&gt;The preview also shows those records, with &lt;code&gt;before&lt;/code&gt; and &lt;code&gt;after&lt;/code&gt; values, so "+10%" is no longer abstract:&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="nl"&gt;"preconditions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stateId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a3510d17b8a51b93"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recordCount"&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;"records"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"products/42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dateModified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-30T09:12:44"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20.00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22.00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A confirm with this previewId is refused with stale_preview if these records change before it is applied."&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;Up to 50 records are listed, with &lt;code&gt;truncated: true&lt;/code&gt; beyond that. The state id always covers all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking at confirm time
&lt;/h3&gt;

&lt;p&gt;When a confirm sends a two-part id and the arguments part matches, the registry plans the write again on fresh reads and compares state ids:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supplied&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stateId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-run the plan on fresh reads and compare the state it was computed from.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentStateId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stateId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stateMatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentStateId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;supplied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stateId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stateMatch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StalePreviewError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* both ids and current records */&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mismatch returns &lt;code&gt;stale_preview&lt;/code&gt; with both state ids and the current values, so a client can show the user what changed:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stale_preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"previewStateId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a3510d17b8a51b93"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"currentStateId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8bed7e78cf74f185"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"products/42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"25.00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"27.50"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server instructions tell clients what to do with it: request a fresh preview, show it again, and confirm with the new id.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two error codes on purpose
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;preview_mismatch&lt;/code&gt; and &lt;code&gt;stale_preview&lt;/code&gt; mean different things and I kept them separate. A mismatch means the caller changed what it asked for, which usually points at a client bug or a model retrying with different arguments. A stale preview means the world changed, which is normal in a busy store and just needs a re-preview. Arguments are checked first, so a call with both problems reports &lt;code&gt;preview_mismatch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: the audit log
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;WOOCOMMERCE_AUDIT_LOG&lt;/code&gt; to a file path or &lt;code&gt;stderr&lt;/code&gt; and every write attempt becomes one JSON line, with an &lt;code&gt;outcome&lt;/code&gt; of &lt;code&gt;applied&lt;/code&gt;, &lt;code&gt;dry_run&lt;/code&gt;, &lt;code&gt;confirmation_required&lt;/code&gt;, &lt;code&gt;refused&lt;/code&gt;, or &lt;code&gt;failed&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="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-10-30T14:02:11.120Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"woocommerce_update_order_status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"applied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1015&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"client"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"static:3f9a1c2b7d10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"previewId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"pv_4f1c9a2e7b3d0856"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"previewMatch"&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the preview entry and the applied entry share a &lt;code&gt;previewId&lt;/code&gt;, you can answer "who approved this change, and what did they see?" with a grep. When the id had a state part, the entry also has &lt;code&gt;stateMatch&lt;/code&gt;, and a &lt;code&gt;stale_preview&lt;/code&gt; refusal is logged as &lt;code&gt;refused&lt;/code&gt; with a &lt;code&gt;stale&lt;/code&gt; object holding both state ids.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not: it is not a lock
&lt;/h2&gt;

&lt;p&gt;This is the part I most want people to read. The WooCommerce REST API has no conditional writes. There is no &lt;code&gt;If-Match&lt;/code&gt; header I can send that makes the store reject a write if the record changed. So the sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;confirm arrives
  -&amp;gt; re-plan on fresh reads      (GET products?include=42)
  -&amp;gt; compare state ids           (match)
  -&amp;gt; send the write              (POST products/batch)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A change that lands between the fresh read and the write is not detected. The check narrows the window from "whenever the user gets around to approving" (seconds to hours) to the length of a round trip. It does not close it.&lt;/p&gt;

&lt;p&gt;Other limits, all in the &lt;a href="https://sadrmanesh.github.io/woocommerce-mcp/safe-writes/" rel="noopener noreferrer"&gt;safe writes guide&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only what the plan read is covered.&lt;/strong&gt; Tools that write without reading, such as &lt;code&gt;woocommerce_update_order_status&lt;/code&gt; or &lt;code&gt;woocommerce_create_product&lt;/code&gt;, keep the one-part id and behave as in v0.4.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is opt-in per call.&lt;/strong&gt; A confirm without &lt;code&gt;previewId&lt;/code&gt;, or with a one-part id, is not checked for staleness. Clients that want the guarantee have to send the id back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-request tools are checked against the reads before their first write.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The re-plan costs a read.&lt;/strong&gt; A confirm with a two-part id makes the same GETs as the preview. For a 100-product batch that is one extra list request, which I think is a fair price.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;The server has 142 tests (&lt;code&gt;npm test&lt;/code&gt; runs them with Node's built-in test runner against a mocked WooCommerce API). The stale preview tests use a one-product store whose price the test can change between calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shows absolute before and after values and a state part in the previewId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mockStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;testRuntime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;adjustSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raise&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confirmation_required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sr"&gt;/^pv_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{16}&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{16}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern that matters is preview, mutate the mock, confirm, and assert that no write request reached the mock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More tools on the state check.&lt;/strong&gt; Anything that reads before writing benefits, so new write tools should be planned through the same client from the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional single-use approvals&lt;/strong&gt; for deployments that are happy to keep state, layered on top of the fingerprint rather than replacing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better client guidance.&lt;/strong&gt; The protocol gives me tool descriptions and server instructions, but how a host renders &lt;code&gt;preconditions&lt;/code&gt; to a human is up to the host. I want to document a reference rendering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building MCP tools for any system with writes, I think the general pattern carries over: produce the preview from the same code path as the write, fingerprint both the request and the data it was computed from, give the two failures different names, and write down exactly where the guarantee stops.&lt;/p&gt;

&lt;p&gt;On the search side of commerce, I also maintain &lt;a href="https://github.com/sadrmanesh/llm-search-eval" rel="noopener noreferrer"&gt;llm-search-eval&lt;/a&gt;, which asks a different question: is a ranking change actually better, or just noise?&lt;br&gt;
 I wrote it up in &lt;a href="https://dev.to/sadrmanesh/is-your-new-search-actually-better-label-gaps-llm-judge-panels-and-paired-statistics-for-offline-490p"&gt;Is your new search actually better?&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; @sadrmanesh/woocommerce-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Or with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MCP_TRANSPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stdio &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; WOOCOMMERCE_URL &lt;span class="nt"&gt;-e&lt;/span&gt; WOOCOMMERCE_CONSUMER_KEY &lt;span class="nt"&gt;-e&lt;/span&gt; WOOCOMMERCE_CONSUMER_SECRET &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/sadrmanesh/woocommerce-mcp:0.5.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/sadrmanesh/woocommerce-mcp" rel="noopener noreferrer"&gt;https://github.com/sadrmanesh/woocommerce-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://sadrmanesh.github.io/woocommerce-mcp/" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/woocommerce-mcp/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@sadrmanesh/woocommerce-mcp" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@sadrmanesh/woocommerce-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is MIT licensed. Issues and feedback on the stale preview design are very welcome, especially from anyone who has found a way to get conditional writes out of WooCommerce.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>woocommerce</category>
      <category>typescript</category>
      <category>security</category>
    </item>
    <item>
      <title>Why RAG breaks on Persian text, and what I built to fix it: normalization, half-spaces, spelling variants, and a regression bench</title>
      <dc:creator>Naser Sadrmanesh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:56:37 +0000</pubDate>
      <link>https://dev.to/sadrmanesh/why-rag-breaks-on-persian-text-and-what-i-built-to-fix-it-normalization-half-spaces-spelling-2jgm</link>
      <guid>https://dev.to/sadrmanesh/why-rag-breaks-on-persian-text-and-what-i-built-to-fix-it-normalization-half-spaces-spelling-2jgm</guid>
      <description>&lt;p&gt;Most RAG tutorials assume the hard parts are the embedding model and the vector database. For Persian, a lot of the damage happens before either of them sees a byte. Two strings that look identical on screen are different code points. A word can be written three ways depending on how the author typed a half-space. A PDF can give you every word backwards. None of these raise an error. Retrieval just quietly gets worse.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/sadrmanesh/persian-rag" rel="noopener noreferrer"&gt;persian-rag&lt;/a&gt; as a Persian-first RAG toolkit for Python. The core install is pure Python with no dependencies, and models, vector stores, parsers, and the HTTP server are optional extras. This post walks through the Persian-specific problems in pipeline order, the design decision behind each fix, and a small benchmark that shows where the fixes help and where they do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the same letter, different code points
&lt;/h2&gt;

&lt;p&gt;Persian is written in an extended Arabic script, and several letters have an Arabic twin with a different code point that renders almost the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Arabic Yeh &lt;code&gt;ي&lt;/code&gt; (U+064A) and Alef Maksura &lt;code&gt;ى&lt;/code&gt; versus Persian Yeh &lt;code&gt;ی&lt;/code&gt; (U+06CC)&lt;/li&gt;
&lt;li&gt;Arabic Kaf &lt;code&gt;ك&lt;/code&gt; (U+0643) versus Persian Keheh &lt;code&gt;ک&lt;/code&gt; (U+06A9)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Text copied from Arabic-locale keyboards, older websites, or some CMS exports uses the Arabic forms. A user typing on a Persian keyboard uses the Persian ones. To BM25, and to many tokenizers, they are different words.&lt;/p&gt;

&lt;p&gt;On top of that come three digit systems (ASCII &lt;code&gt;0-9&lt;/code&gt;, Persian &lt;code&gt;۰-۹&lt;/code&gt;, Arabic-Indic &lt;code&gt;٠-٩&lt;/code&gt;), the tatweel (kashida) used for stretching words, optional diacritics, and Arabic presentation forms from PDFs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;normalize&lt;/code&gt; handles all of it, driven by one config object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;persian_rag&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NormalizeConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;

&lt;span class="nf"&gt;normalize&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="c1"&gt;# 'یک کتاب با اعداد ۴۵۶'
&lt;/span&gt;
&lt;span class="nf"&gt;normalize&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="nc"&gt;NormalizeConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remove_diacritics&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ascii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# 'کتاب'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order of operations inside &lt;code&gt;normalize&lt;/code&gt; is deliberate:&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;if&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compatibility_forms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_INVISIBLE&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unify_arabic_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_ARABIC_TO_PERSIAN&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remove_tatweel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&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="n"&gt;_TATWEEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remove_diacritics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_DIACRITICS_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_map_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clean_punctuation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_clean_punctuation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;punctuation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_apply_zwnj_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_normalize_whitespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NFKC runs first because it folds presentation forms (contextual glyphs such as U+FEE3) into base letters, and every later step expects base letters. Invisible characters such as bidirectional marks are stripped next so they cannot hide inside words. Half-space handling runs after punctuation cleanup, and whitespace is collapsed last, so neither step can undo the other.&lt;/p&gt;

&lt;p&gt;Two design rules hold the whole thing together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;normalize&lt;/code&gt; is idempotent for a fixed config.&lt;/strong&gt; &lt;code&gt;normalize(normalize(t)) == normalize(t)&lt;/code&gt;. That makes it safe to call at every layer without worrying about double application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documents and queries must use the same config.&lt;/strong&gt; &lt;code&gt;build_retriever&lt;/code&gt; and &lt;code&gt;HybridRetriever&lt;/code&gt; apply it to both automatically, and a saved index stores the config and restores it on load. A mismatch here is the single easiest way to lose recall without noticing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What it deliberately does &lt;em&gt;not&lt;/em&gt; do: &lt;code&gt;آ&lt;/code&gt; and &lt;code&gt;ئ&lt;/code&gt; stay as they are. Folding them would merge words that are genuinely different, and that is a spelling question, not a code point question. It is handled later, at query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the half-space
&lt;/h2&gt;

&lt;p&gt;The zero-width non-joiner (ZWNJ, U+200C), which Persian speakers call the half-space, is the most Persian-specific problem in the pipeline. It separates parts of a word visually without breaking it into two words. The verb prefix &lt;code&gt;می&lt;/code&gt;, the plural suffix &lt;code&gt;ها&lt;/code&gt;, and the comparative suffixes &lt;code&gt;تر&lt;/code&gt; and &lt;code&gt;ترین&lt;/code&gt; are joined to their word with it.&lt;/p&gt;

&lt;p&gt;In practice people type it three ways: with a half-space, with a normal space, or attached with nothing in between. A search for the plural of "book" can miss a document because the author used a space where the query used a half-space.&lt;/p&gt;

&lt;p&gt;persian-rag does two things. First, it corrects the common cases toward the half-space form, with regular expressions anchored on known prefixes and suffixes:&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;_PREFIX_SPACE_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;![\w\u200c])(نمی|می|بی)[ \t]+(?=[\u0600-\u06FF])&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_SUFFIX_SPACE_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;=[\u0600-\u06FF])[ \t]+(هایی|های|ها|ترین|تر)(?![\w\u200c])&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;Then it cleans up the half-spaces themselves: repeated ones collapse into one, spaces around them are removed, and half-spaces next to punctuation or at the edges of a string are dropped.&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="nf"&gt;normalize&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="c1"&gt;# 'کتاب\u200cهای می\u200cروم ۴۵۶'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arabic letters, spaced suffix, spaced prefix, and Arabic-Indic digits all land on one canonical form.&lt;/p&gt;

&lt;p&gt;The policy is configurable with &lt;code&gt;zwnj="normalize"&lt;/code&gt; (the default), &lt;code&gt;"remove"&lt;/code&gt;, or &lt;code&gt;"space"&lt;/code&gt;, because some downstream systems cannot handle the character at all. That choice has consequences in the tokenizer, which is the next problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: tokenization
&lt;/h2&gt;

&lt;p&gt;A tokenizer that splits on whitespace and punctuation, or one that treats U+200C as a separator, turns a single Persian word into two tokens. For BM25 that is a real loss: the suffix &lt;code&gt;ها&lt;/code&gt; (plural) is extremely common, so it carries almost no IDF weight, and the stem token is shared with the singular form.&lt;/p&gt;

&lt;p&gt;The tokenizer in persian-rag keeps the half-space inside the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Letters only. Digits, diacritics, and Arabic punctuation stay out of words
# so "سلام،" does not become a single token.
&lt;/span&gt;&lt;span class="n"&gt;_TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[0-9۰-۹٠-٩]+(?:[.\u066b][0-9۰-۹٠-٩]+)?|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z]+(?:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z]+)?|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\u0621-\u063A\u0641-\u064A\u066E\u0671-\u06D3\u06FA-\u06FF\u200c]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because normalization runs first, the spaced and half-spaced forms converge:&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="nf"&gt;tokenize&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="se"&gt;\u200c&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="c1"&gt;# ['می\u200cروم']
&lt;/span&gt;&lt;span class="nf"&gt;tokenize&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="c1"&gt;# ['می\u200cروم']
&lt;/span&gt;&lt;span class="nf"&gt;tokenize&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="c1"&gt;# ['میروم']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two become the same token. The third, written fully attached, does not. I chose not to split or join attached forms in the tokenizer, because doing that reliably needs a morphological analyzer, and a wrong split is worse than a missed match. Attached forms are handled by query expansion instead.&lt;/p&gt;

&lt;p&gt;If you choose &lt;code&gt;zwnj="space"&lt;/code&gt;, you get what most generic tokenizers give you:&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="nf"&gt;tokenize&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="se"&gt;\u200c&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="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NormalizeConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zwnj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ['کتاب', 'ها']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Numbers are separate tokens, including decimals with the Arabic decimal separator (U+066B), and Latin tokens are lowercased so mixed Persian and English product names still match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: PDFs that store Persian backwards
&lt;/h2&gt;

&lt;p&gt;Many Persian PDFs, especially older ones, store lines in visual order: the glyphs are in the order they appear on screen from left to right, so every Persian word comes out of the extractor reversed, while numbers and Latin words inside the line read normally. Indexing that text produces an index of reversed words that no query will ever match.&lt;/p&gt;

&lt;p&gt;The loader detects this per page with a score built from two signals:&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;visual_order_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_WORD_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="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;word&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="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="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&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="nf"&gt;_form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FINAL&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;last&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INITIAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INITIAL&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;last&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FINAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;word&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;folded&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REVERSED_COMMON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first signal uses presentation forms: a word that &lt;em&gt;starts&lt;/em&gt; with a final-form glyph or &lt;em&gt;ends&lt;/em&gt; with an initial-form glyph is almost certainly reversed. The second uses a short list of very frequent words (&lt;code&gt;از&lt;/code&gt;, &lt;code&gt;به&lt;/code&gt;, &lt;code&gt;که&lt;/code&gt;, &lt;code&gt;در&lt;/code&gt;, &lt;code&gt;این&lt;/code&gt;, and so on): seeing them reversed is strong evidence. Positive means visual order.&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;persian_rag.loaders&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;clean_extracted_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visual_order_score&lt;/span&gt;

&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;دش جراخ پاچ زا 1402 لاس رد باتک نیا&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;visual_order_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# 6
&lt;/span&gt;&lt;span class="nf"&gt;clean_extracted_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 'این کتاب در سال 1402 از چاپ خارج شد'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line is reversed and the left-to-right runs inside it, like &lt;code&gt;1402&lt;/code&gt;, are restored. On the ingest CLI this is &lt;code&gt;--rtl-fix auto&lt;/code&gt; (the default), with &lt;code&gt;reverse&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt; for when you know better than the heuristic.&lt;/p&gt;

&lt;p&gt;The heuristic can be wrong on very short lines or lines with no frequent words and no presentation forms. Detecting per page rather than per line is the compromise: there is more evidence on a page, and a PDF rarely mixes orders within one page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: spelling variants that normalization must not fix
&lt;/h2&gt;

&lt;p&gt;Persian has many words with more than one accepted spelling. Room can be &lt;code&gt;اتاق&lt;/code&gt; or &lt;code&gt;اطاق&lt;/code&gt;. Tehran is &lt;code&gt;تهران&lt;/code&gt; or the older &lt;code&gt;طهران&lt;/code&gt;. Board is &lt;code&gt;هیئت&lt;/code&gt;, &lt;code&gt;هیات&lt;/code&gt;, or &lt;code&gt;هیأت&lt;/code&gt;. Ticket is &lt;code&gt;بلیت&lt;/code&gt; or &lt;code&gt;بلیط&lt;/code&gt;. These are not encoding problems, so normalization should not touch them. Rewriting documents would destroy the original text you later want to cite.&lt;/p&gt;

&lt;p&gt;Instead, persian-rag expands the &lt;em&gt;query&lt;/em&gt;. &lt;code&gt;QueryRewriter&lt;/code&gt; produces a short, ordered list of variants: the original query, spelling variants from a built-in table of groups, orthographic variants (hamza seat, alef madda, &lt;code&gt;ئی&lt;/code&gt; and &lt;code&gt;یی&lt;/code&gt;), half-space spacing variants of prefixes and suffixes, and optionally rewrites from a language model.&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;persian_rag&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QueryRewriter&lt;/span&gt;

&lt;span class="nc"&gt;QueryRewriter&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;variants&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="c1"&gt;# ['اطاق های هتل طهران', 'اطاق های هتل تهران', 'اتاق های هتل طهران',
#  'اطاق\u200cهای هتل طهران', 'اطاقهای هتل طهران']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spacing variants are how the fully attached form from Problem 3 gets found.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RewritingRetriever&lt;/code&gt; wraps any retriever, runs every variant, and fuses the rankings with reciprocal rank fusion:&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;weights&lt;/span&gt; &lt;span class="o"&gt;=&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;original_weight&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&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="n"&gt;rrf_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions here matter. The original query counts double (&lt;code&gt;original_weight=2.0&lt;/code&gt;), so expansion adds recall without pushing exact matches down. And every result records which variants found it in &lt;code&gt;metadata["matched_queries"]&lt;/code&gt;, so you can see why a document was retrieved. Debuggable expansion is much easier to trust.&lt;/p&gt;

&lt;p&gt;The same reciprocal rank fusion merges BM25 and dense results in &lt;code&gt;HybridRetriever&lt;/code&gt;, with &lt;code&gt;rrf_k=60&lt;/code&gt; and separate weights for each side. RRF works on ranks, not scores, so I never have to calibrate a BM25 score against a cosine similarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it: a small Persian bench
&lt;/h2&gt;

&lt;p&gt;I wanted a check that fails when a change breaks Persian-specific behavior, so the repository ships a benchmark in &lt;code&gt;benchmarks/&lt;/code&gt;: 34 short documents (several are distractors that share words with other documents) and 42 labeled questions. Twelve questions are tagged &lt;code&gt;spelling&lt;/code&gt;: they use a different accepted spelling than the corpus. The data is also on the Hugging Face Hub as &lt;code&gt;sadrmanesh/persian-rag-bench&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python benchmarks/run_benchmark.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the numbers I get with the offline hashing embedder (512 dimensions, character 2 to 4 grams), chunks of 400 characters, and &lt;code&gt;k=10&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Configuration    | Hit@1 | Hit@5 | MRR@10 | nDCG@10 | Hit@1 (spelling) |
|------------------|------:|------:|-------:|--------:|-----------------:|
| sparse           | 0.881 | 0.881 |  0.881 |   0.879 |            0.583 |
| dense            | 0.952 | 0.976 |  0.957 |   0.962 |            0.917 |
| hybrid           | 0.952 | 0.976 |  0.964 |   0.967 |            0.833 |
| sparse + rewrite | 0.952 | 0.976 |  0.964 |   0.967 |            0.833 |
| dense + rewrite  | 0.952 | 0.976 |  0.957 |   0.962 |            0.917 |
| hybrid + rewrite | 0.952 | 0.976 |  0.964 |   0.967 |            0.833 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I read from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BM25 alone misses five of the twelve misspelled queries&lt;/strong&gt;, because a different spelling is a different token. The rewriter recovers most of them: sparse Hit@1 on the spelling subset goes from 0.583 to 0.833, and overall Hit@1 from 0.881 to 0.952. That is the effect the feature exists for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dense numbers flatter the embedder.&lt;/strong&gt; The hashing embedder compares character n-grams, so it is naturally tolerant of one-letter differences like &lt;code&gt;ت&lt;/code&gt; versus &lt;code&gt;ط&lt;/code&gt;. A semantic embedding model behaves differently. The script takes &lt;code&gt;--embedder sentence-transformers --model ...&lt;/code&gt; so you can measure yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid is not always best.&lt;/strong&gt; On the spelling subset, hybrid (0.833) is below dense alone (0.917). Fusing in BM25, which misses those queries, pulls some correct dense results down. I left this in the docs on purpose. It is the kind of result that a benchmark is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewriting is not free.&lt;/strong&gt; Each variant is another retrieval call. In-memory it stays in the low milliseconds, but against a remote search engine it multiplies requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The set is small and the scores are high by design. It is a regression check for Persian behavior, not a leaderboard, and a test runs the script offline on every change so it cannot quietly rot. For decisions that matter, extend &lt;code&gt;qa.jsonl&lt;/code&gt; with questions from your own domain.&lt;/p&gt;

&lt;p&gt;For retrieval metrics on your own data, &lt;code&gt;evaluate_retrieval&lt;/code&gt; reports hit rate, MRR, recall, and nDCG at several cut-offs, and &lt;code&gt;persian-rag eval&lt;/code&gt; does the same from the CLI. If you need statistically careful comparisons between two retrieval systems, that is the problem I tackled separately in &lt;a href="https://dev.to/sadrmanesh/is-your-new-search-actually-better-label-gaps-llm-judge-panels-and-paired-statistics-for-offline-490p"&gt;Is your new search actually better?&lt;/a&gt; (&lt;a href="https://github.com/sadrmanesh/llm-search-eval" rel="noopener noreferrer"&gt;llm-search-eval&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What else is in the box
&lt;/h2&gt;

&lt;p&gt;Briefly, since this post is about the Persian-specific parts: loaders for text, Markdown, HTML (with main-content extraction), PDF, Word, and URLs; fixed, sentence, and recursive chunking; in-memory, FAISS, Qdrant, pgvector, Elasticsearch, and OpenSearch stores (the last two also serve BM25 with the &lt;code&gt;persian&lt;/code&gt; analyzer); incremental indexing by content hash; cross-encoder or LLM reranking; citation-grounded answers that resolve &lt;code&gt;[n]&lt;/code&gt; markers, including Persian and Arabic-Indic digits, to source spans; conversation memory; a FastAPI server; and a CLI for &lt;code&gt;ingest&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;, &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt;, and &lt;code&gt;serve&lt;/code&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No morphological analysis.&lt;/strong&gt; Attached forms, broken plurals, and Arabic-origin plural patterns are not unified by the tokenizer. Query expansion covers common spacing cases, not morphology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spelling table is hand-curated.&lt;/strong&gt; It covers common groups, and you can add your own with &lt;code&gt;QueryRewriter(extra_groups=[...])&lt;/code&gt;, but it is not exhaustive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF order detection is a heuristic.&lt;/strong&gt; It is good on normal prose and weaker on tables, short headings, and pages with little Persian text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The benchmark is small and the default embedder is not semantic.&lt;/strong&gt; Treat its numbers as a regression check, not as evidence about your model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;More spelling groups from real query logs, an optional morphological stemmer behind an extra so the core stays dependency-free, and larger evaluation sets with semantic embedders, published with the same honesty as the current table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;persian-rag
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"persian-rag[pdf,docx,qdrant,server]"&lt;/span&gt;   &lt;span class="c"&gt;# pick the extras you need&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;persian_rag&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HashingEmbedder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RAGPipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;build_retriever&lt;/span&gt;

&lt;span class="n"&gt;texts&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;حافظ در شهر شیراز زاده شد و دیوان او مشهور است.&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="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;retriever&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_retriever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HashingEmbedder&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RAGPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;prompt&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="nf"&gt;query&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP API is also published as &lt;code&gt;ghcr.io/sadrmanesh/persian-rag:0.3.1&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/sadrmanesh/persian-rag" rel="noopener noreferrer"&gt;https://github.com/sadrmanesh/persian-rag&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs (English and Persian): &lt;a href="https://sadrmanesh.github.io/persian-rag/" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/persian-rag/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://huggingface.co/spaces/sadrmanesh/persian-rag-demo" rel="noopener noreferrer"&gt;https://huggingface.co/spaces/sadrmanesh/persian-rag-demo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. If you work with Persian text and have a spelling group or a PDF that breaks the heuristics, please open an issue with an example.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>nlp</category>
      <category>unicode</category>
    </item>
    <item>
      <title>Is your new search actually better? Label gaps, LLM judge panels, and paired statistics for offline search evaluation</title>
      <dc:creator>Naser Sadrmanesh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:53:50 +0000</pubDate>
      <link>https://dev.to/sadrmanesh/is-your-new-search-actually-better-label-gaps-llm-judge-panels-and-paired-statistics-for-offline-490p</link>
      <guid>https://dev.to/sadrmanesh/is-your-new-search-actually-better-label-gaps-llm-judge-panels-and-paired-statistics-for-offline-490p</guid>
      <description>&lt;p&gt;Every search team eventually ships a ranking change on the strength of a table where the new system's nDCG@10 is a little higher than the old one's. Then it goes to production and nothing measurable happens.&lt;/p&gt;

&lt;p&gt;Usually nothing went wrong in the code. The evaluation was answering a different question from the one people thought they were asking. I built &lt;a href="https://github.com/sadrmanesh/llm-search-eval" rel="noopener noreferrer"&gt;llm-search-eval&lt;/a&gt; to make the evaluation answer the right one: &lt;em&gt;is the new system better for shoppers, and how sure are we?&lt;/em&gt; This post is about the design decisions behind it, in the order the problems show up.&lt;/p&gt;

&lt;p&gt;All numbers below come from the small bilingual electronics example that ships in the repository (10 queries, 22 products, 30 gold labels). It is far too small to make product decisions with, which is exactly why it is useful for showing where a naive evaluation goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: labels run out, and the gap is not neutral
&lt;/h2&gt;

&lt;p&gt;Offline evaluation needs relevance labels: for each (query, product) pair, how relevant is it? Teams label a set once and reuse it. A retriever built later surfaces products that were never labeled.&lt;/p&gt;

&lt;p&gt;The common default is to count an unlabeled result as not relevant. That sounds conservative, but it is not neutral. The system that is &lt;em&gt;more different&lt;/em&gt; from whatever produced the labeled pool gets more unlabeled results and is penalized more. A new dense retriever is often the one being compared, and it is often the one that finds products the old pool never saw.&lt;/p&gt;

&lt;p&gt;So the first thing llm-search-eval reports is coverage. Here is the example run with gold labels only and no judge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llm-search-eval run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; examples/electronics/queries.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; examples/electronics/products.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--qrels&lt;/span&gt; examples/electronics/qrels.csv &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;bm25&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;examples/electronics/runs/bm25.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;hybrid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;examples/electronics/runs/hybrid.trec &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; reports/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## Label coverage

| System | Queries answered | Labeled in top 10 | Unknown products |
| :--- | ---: | ---: | ---: |
| `bm25` | 9/10 | 48.3% | 0 |
| `hybrid` | 10/10 | 80.6% | 0 |

Gold labels used: 29, new judgments: 0, from cache: 0, failed: 0, still unlabeled: 18.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less than half of BM25's top 10 has a label. Any number computed on that is partly a measurement of which system the labelers happened to look at. Whatever else you do, look at this table before the summary table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filling gaps without touching gold labels
&lt;/h3&gt;

&lt;p&gt;The fix is to label the gaps, and an LLM judge is the cheapest way to do it at scale. The resolution order for every pair in each system's top &lt;code&gt;depth&lt;/code&gt; is strict:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gold label&lt;/strong&gt; from &lt;code&gt;--qrels&lt;/code&gt;, if present. Gold labels are never re-judged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached judgment&lt;/strong&gt;, if the same judge configuration already saw this exact query and product content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge&lt;/strong&gt;, if &lt;code&gt;--judge&lt;/code&gt; is set. Otherwise the pair stays unlabeled, and the report shows the gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then every system is scored against the same merged label pool. That detail matters: the ideal ranking used by nDCG and the recall denominator are identical for every system, so a judgment added because of system B also counts when scoring system A.&lt;/p&gt;

&lt;p&gt;Finding the gaps is a short function:&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;pending_pairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;``(query_id, product_id)`` pairs in the runs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; top ``depth`` that have no gold label.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;pending&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="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# ordered set
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&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;qid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queries&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;pid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ranking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gold_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&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="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Judges grade on a 0 to 3 scale (3 exact match, 2 relevant, 1 partially relevant, 0 irrelevant), the same scale as the gold labels, so the two can be compared directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: one judge is one opinion
&lt;/h2&gt;

&lt;p&gt;An LLM judge has its own biases. Some are lenient, some ignore attributes like size or color, some behave differently on Persian queries than on English ones. Swapping human labels for a single model's opinion just moves the bias somewhere you cannot see it.&lt;/p&gt;

&lt;p&gt;llm-search-eval handles this in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panels.&lt;/strong&gt; Pass &lt;code&gt;--judge&lt;/code&gt; more than once and the judges form a panel. Grades are combined with &lt;code&gt;--aggregate&lt;/code&gt; (&lt;code&gt;majority&lt;/code&gt;, &lt;code&gt;mean&lt;/code&gt;, &lt;code&gt;median&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, or &lt;code&gt;max&lt;/code&gt;), and &lt;code&gt;--min-votes&lt;/code&gt; sets how many members must answer, so one failing endpoint does not stop the run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agreement and calibration.&lt;/strong&gt; The report measures how much the panel agrees with itself (Fleiss' kappa, Krippendorff's alpha, pairwise Cohen's kappa plain and quadratic-weighted) and, with &lt;code&gt;--calibrate&lt;/code&gt;, how much each judge agrees with your gold labels. Gold labels still decide the scores; calibration pairs are judged only to measure the judge.&lt;/p&gt;

&lt;p&gt;To show what that looks like without an API key, here is a panel of three mock judges, two of them with injected noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llm-search-eval run ... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--judge&lt;/span&gt; mock &lt;span class="nt"&gt;--judge&lt;/span&gt; &lt;span class="s2"&gt;"mock=noise=0.2,seed=1"&lt;/span&gt; &lt;span class="nt"&gt;--judge&lt;/span&gt; &lt;span class="s2"&gt;"mock=noise=0.2,seed=2"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--calibrate&lt;/span&gt; &lt;span class="nt"&gt;--bootstrap&lt;/span&gt; 2000 &lt;span class="nt"&gt;-o&lt;/span&gt; reports/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### Inter-judge agreement

| Statistic | Value |
| :--- | ---: |
| Items with every vote | 48 |
| Unanimous | 72.9% |
| Fleiss' kappa | 0.732 |
| Krippendorff's alpha (ordinal) | 0.873 |

### Agreement with gold labels

| Rater | n | Exact | Within one | Binary | Weighted kappa | Mean bias |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: |
| `panel(majority: ...)` | 30 | 33.3% | 66.7% | 63.3% | 0.257 | -1.000 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This output is a good illustration of why calibration exists. The three judges agree with &lt;em&gt;each other&lt;/em&gt; quite well (Fleiss' kappa 0.732). They agree with the &lt;em&gt;gold labels&lt;/em&gt; badly: 33.3% exact, weighted kappa 0.257, and a mean bias of -1.0, meaning they grade a full point harsher than the humans. The mock judge is deterministic term coverage, meant for tests and CI, not a quality signal, and the calibration table says so. High inter-judge agreement only tells you the judges share a bias. You need the gold comparison to know whether it is the right one.&lt;/p&gt;

&lt;p&gt;With a real model the same flags apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llm-search-eval run ... &lt;span class="nt"&gt;--judge&lt;/span&gt; &lt;span class="nv"&gt;openai&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpt-4o-mini &lt;span class="nt"&gt;--cache&lt;/span&gt; .judge-cache.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--price&lt;/span&gt; gpt-4o-mini&lt;span class="o"&gt;=&lt;/span&gt;0.15,0.60 &lt;span class="nt"&gt;--max-cost&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;openai&lt;/code&gt; judge works with any OpenAI-compatible chat completions endpoint, including local servers such as Ollama or vLLM (&lt;code&gt;--judge "openai=qwen2.5:14b,base_url=http://localhost:11434/v1"&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: a cache that lies about which prompt produced a label
&lt;/h2&gt;

&lt;p&gt;A judgment cache is essential. Re-running an evaluation on every pull request should not pay for the same (query, product) pair twice. But a cache keyed on ids is subtly wrong: if a product description is edited, the old label is served for the new text.&lt;/p&gt;

&lt;p&gt;So keys hash content, not ids:&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;cache_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;material&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x1f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other half is the &lt;code&gt;fingerprint&lt;/code&gt;, which identifies the judge. Until v0.4.0 it included the endpoint, model, temperature, and a hand-bumped prompt version. The flaw: if someone edits the prompt text and forgets to bump the version, every cached label from the old prompt keeps being served as if it came from the new one. Nothing fails. The numbers just quietly mix two different judges.&lt;/p&gt;

&lt;p&gt;v0.4.0 adds a prompt hash computed from the text itself:&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;prompt_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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 12 hex characters of the SHA-256 of the templates, joined by a unit separator.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x1f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;#: Hash of the rendered system prompt and the user prompt template.
&lt;/span&gt;&lt;span class="n"&gt;PROMPT_HASH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prompt_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;USER_PROMPT_TEMPLATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and puts it in the fingerprint, and so in every cache key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@property&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &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;openai:&lt;/span&gt;&lt;span class="si"&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;base_url&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:t=&lt;/span&gt;&lt;span class="si"&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;temperature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PROMPT_VERSION&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;PROMPT_HASH&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;Any edit to the prompt changes the hash, and a judgment made with a different prompt is never reused. The version (&lt;code&gt;relevance-v1&lt;/code&gt;, &lt;code&gt;pairwise-v1&lt;/code&gt;), hash, provider, and model are also written to &lt;code&gt;report.json&lt;/code&gt;, the Markdown report, and the dashboard, so a result always says which prompt produced it. The trade-off is that upgrading to v0.4.0 re-judges old cache entries once. I chose a one-time cost over silent contamination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: averages hide noise
&lt;/h2&gt;

&lt;p&gt;With labels fixed, the next mistake is reading a mean difference as a result. Ten or fifty queries is a small sample, and per-query nDCG varies a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paired randomization test
&lt;/h3&gt;

&lt;p&gt;Every system is compared to a baseline with a two-sided paired sign-flip randomization test on the per-query values of the primary metric. Paired, because both systems ran on the same queries, and pairing removes the variation between easy and hard queries. Randomization, because it makes no normality assumption about nDCG, which is bounded and often skewed.&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;paired_randomization_test&lt;/span&gt;&lt;span class="p"&gt;(&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;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;permutations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Two-sided paired sign-flip randomization test on the mean difference.&lt;/span&gt;&lt;span class="sh"&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;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paired samples must have equal length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;diffs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;diffs&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;permutations&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&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="n"&gt;diffs&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;observed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;extreme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permutations&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&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;observed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1e-12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;extreme&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extreme&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permutations&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea: if the two systems were interchangeable, the sign of each per-query difference would be a coin flip. Flip signs at random many times and count how often the total is at least as extreme as what you observed. The &lt;code&gt;+1&lt;/code&gt; on both sides keeps the p-value from ever being exactly zero, which it should not be with a finite number of permutations. The seed is fixed so the same inputs give the same p-value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paired bootstrap intervals
&lt;/h3&gt;

&lt;p&gt;A p-value says whether a difference is distinguishable from zero. It does not say how big it plausibly is. So every mean gets a percentile bootstrap interval over queries, and every delta gets a paired bootstrap interval, where queries are resampled as units so both systems see the same queries in each resample:&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;paired_bootstrap_ci&lt;/span&gt;&lt;span class="p"&gt;(&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;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_CONFIDENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resamples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_RESAMPLES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Interval&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Bootstrap interval for the mean paired difference ``a - b``.

    Queries are resampled as units, so both systems see the same queries in
    each resample. This is the interval that matches the paired randomization
    test used for p-values.
    &lt;/span&gt;&lt;span class="sh"&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;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paired samples must have equal length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resamples&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resamples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seed&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;On the example data, with the mock panel from above filling the gaps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| System | Δ ndcg@10 | Δ 95% CI | Relative | Win / tie / loss | p-value |
| :--- | ---: | ---: | ---: | ---: | ---: |
| `hybrid` | +0.4470 | [+0.3401, +0.5862] | +84.3% | 10 / 0 / 0 | 0.0020 (significant) |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hybrid system wins all 10 queries, so even 10 queries are enough. Note the p-value: with 10 queries and all differences positive, the smallest possible two-sided p-value is 2/1024, about 0.002. That is a property of the sample size, not of the systems, and it is worth knowing before you look for p &amp;lt; 0.001 on a small set.&lt;/p&gt;

&lt;p&gt;The report also prints a line I think every evaluation tool should print: &lt;em&gt;narrow intervals need more queries, not more judgments.&lt;/em&gt; Filling label gaps fixes bias. It does not fix noise. Only more queries do.&lt;/p&gt;

&lt;p&gt;That is the honest answer to the question I get most often: "my new system is +2% on 50 queries, what now?" Check coverage first. If many top results are unjudged, fill them and re-run, because the +2% may be a labeling artifact. Then look at the paired interval. If it still crosses zero, you need more queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pairwise judging as a second opinion
&lt;/h3&gt;

&lt;p&gt;Pointwise grades can miss ordering differences when two systems are close. With &lt;code&gt;--pairwise-judge&lt;/code&gt;, a judge sees the top &lt;code&gt;--pairwise-k&lt;/code&gt; results of both systems as two lists and picks the one a shopper would prefer. Each query is judged in both orders by default, and a system wins only when both orders agree, which cancels position bias. Opposite verdicts are counted as inconsistent, a direct read on how stable the judge is. Results come with a Wilson interval on the win rate and an exact sign test (ties ignored).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: cost surprises
&lt;/h2&gt;

&lt;p&gt;Judging thousands of pairs with a large model adds up. Two features exist so that nobody learns the cost from an invoice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Estimate before you run.&lt;/strong&gt; &lt;code&gt;llm-search-eval estimate&lt;/code&gt; takes the same arguments as &lt;code&gt;run&lt;/code&gt;, works out which pairs still need a judge after gold labels and the cache, and predicts calls, tokens, and cost without calling any model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;llm-search-eval estimate &lt;span class="nt"&gt;-q&lt;/span&gt; queries.jsonl &lt;span class="nt"&gt;-p&lt;/span&gt; products.jsonl &lt;span class="nt"&gt;--qrels&lt;/span&gt; qrels.csv &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -s bm25=runs/bm25.jsonl -s hybrid=runs/hybrid.trec \
    --judge openai=gpt-4o-mini --price gpt-4o-mini=0.15,0.60 --max-cost 0.01
queries: 10
result pairs: 47 (29 gold, 0 unknown products)
pairs to judge: 18 (0 already cached)
&lt;/span&gt;&lt;span class="gp"&gt;  gpt-4o-mini: 18 calls, 5624 in + 720 out tokens, $&lt;/span&gt;0.0013
&lt;span class="gp"&gt;total: 18 calls, about 6344 tokens, $&lt;/span&gt;0.0013 &lt;span class="o"&gt;(&lt;/span&gt;assumes 40 output tokens per call&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;budget: fits
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Input tokens are counted from the real prompts that would be sent, using a rough rule (about four characters per token for Latin text and two for Persian), so the estimate is approximate. It exits with &lt;code&gt;1&lt;/code&gt; when the budget is too small, which makes it usable as a CI pre-check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Budgets during the run.&lt;/strong&gt; &lt;code&gt;--max-cost&lt;/code&gt;, &lt;code&gt;--max-tokens&lt;/code&gt;, and &lt;code&gt;--max-judgments&lt;/code&gt; stop judging when a limit is hit. The run still completes, the remaining pairs stay unlabeled, and the coverage table shows how many were skipped. Prices are never built in, because they change and differ between providers. A model without a price still counts tokens, and the total cost is reported as unknown rather than as a misleading partial number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it in CI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llm-search-eval gate&lt;/code&gt; turns a report into a pass or fail check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llm-search-eval gate reports/report.json &lt;span class="nt"&gt;--min&lt;/span&gt; ndcg@10&lt;span class="o"&gt;=&lt;/span&gt;0.6 &lt;span class="nt"&gt;--max-drop&lt;/span&gt; 0.01 &lt;span class="nt"&gt;--significant-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--significant-only&lt;/code&gt; fails the build only when a drop is statistically significant, which stops noisy small regressions from blocking every pull request. The repository is also a GitHub Action that runs the evaluation, applies the gate, and keeps one pull request comment updated on every push.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM labels are still model opinions.&lt;/strong&gt; Calibration tells you how far off they are on the pairs you have gold labels for. It cannot tell you about query types your gold set does not cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token estimate is a heuristic&lt;/strong&gt;, not a tokenizer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small query sets stay small.&lt;/strong&gt; No amount of statistics turns 10 queries into a decision. The tool reports the uncertainty; it cannot remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline is not online.&lt;/strong&gt; This measures relevance against labels. It does not replace an A/B test on conversion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The roadmap in the README lists what I want to do next: pairwise results as a gate condition, active selection of the queries most worth labeling next (the direct answer to "you need more queries, but which ones?"), a Vespa connector with multi-page fetching for deep runs, and interleaving and click log metrics for online comparison.&lt;/p&gt;

&lt;p&gt;If your retrieval is in Persian, I also wrote about the language-specific side of that problem in &lt;a href="https://dev.to/sadrmanesh/why-rag-breaks-on-persian-text-and-what-i-built-to-fix-it-normalization-half-spaces-spelling-2jgm"&gt;Why RAG breaks on Persian text&lt;/a&gt; (&lt;a href="https://github.com/sadrmanesh/persian-rag" rel="noopener noreferrer"&gt;persian-rag&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;llm-search-eval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has zero runtime dependencies and supports Python 3.10 to 3.13. There is also a Docker image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/work"&lt;/span&gt; sadrmanesh/llm-search-eval:0.4.0 run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt;/queries.jsonl &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt;/products.jsonl &lt;span class="nt"&gt;--qrels&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt;/qrels.csv &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;bm25&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;eval&lt;/span&gt;/runs/bm25.jsonl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;hybrid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;eval&lt;/span&gt;/runs/hybrid.trec &lt;span class="nt"&gt;--judge&lt;/span&gt; mock &lt;span class="nt"&gt;-o&lt;/span&gt; reports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/sadrmanesh/llm-search-eval" rel="noopener noreferrer"&gt;https://github.com/sadrmanesh/llm-search-eval&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://sadrmanesh.github.io/llm-search-eval/" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/llm-search-eval/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample dashboard: &lt;a href="https://sadrmanesh.github.io/llm-search-eval/demo/report.html" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/llm-search-eval/demo/report.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. If you run it on a real catalog and the calibration table surprises you, I would like to hear about it.&lt;/p&gt;

</description>
      <category>search</category>
      <category>python</category>
      <category>statistics</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Treat agent skills like code: evals, a strict validator, and an npx installer for commerce skills</title>
      <dc:creator>Naser Sadrmanesh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:51:06 +0000</pubDate>
      <link>https://dev.to/sadrmanesh/treat-agent-skills-like-code-evals-a-strict-validator-and-an-npx-installer-for-commerce-skills-13dk</link>
      <guid>https://dev.to/sadrmanesh/treat-agent-skills-like-code-evals-a-strict-validator-and-an-npx-installer-for-commerce-skills-13dk</guid>
      <description>&lt;p&gt;The most expensive webhook bug I know is boring. Your order handler receives &lt;code&gt;order.paid&lt;/code&gt;, stores the event, creates the fulfillment, sends the confirmation email, and then takes a little too long to answer. WooCommerce times out and posts the same event again. Your handler sees a new delivery id, decides this is a new event, and ships the order twice.&lt;/p&gt;

&lt;p&gt;A coding agent asked to "make the webhook idempotent" will often write exactly the wrong fix: store &lt;code&gt;X-WC-Webhook-Delivery-ID&lt;/code&gt; and skip repeats. That header is a fresh log id for every attempt, so the fix does nothing for this case.&lt;/p&gt;

&lt;p&gt;That gap, between what a capable generalist writes and what someone who has debugged a store at 2 a.m. writes, is what I tried to close with &lt;a href="https://github.com/sadrmanesh/commerce-agent-skills" rel="noopener noreferrer"&gt;commerce-agent-skills&lt;/a&gt;. It is a library of 29 skills and matching rules for coding agents that build e-commerce and marketplace products: WooCommerce, Shopify, Stripe, Iranian payment gateways, inventory, returns, tax, feeds, search, accessibility, and more.&lt;/p&gt;

&lt;p&gt;This post is not a tour of the catalog. It is about the engineering around it: how a skill is structured, how I test that it teaches what it claims to teach, how the validator keeps 29 skills consistent, and how the installer gets them into a project. I will use the webhook redelivery case throughout, because it is the clearest example of why this needs tests at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a skill is
&lt;/h2&gt;

&lt;p&gt;The skills follow the &lt;a href="https://agentskills.io/specification" rel="noopener noreferrer"&gt;Agent Skills&lt;/a&gt; format: a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; that has &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; frontmatter, plus optional references and scripts. An agent reads the description to decide whether a skill applies, reads the body when it does, and opens references and scripts only when the body points to them.&lt;/p&gt;

&lt;p&gt;The webhook skill looks like this on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skills/order-and-webhook-reliability/
  SKILL.md
  references/woocommerce-webhooks.md
  scripts/verify-hmac-signature.mjs
  evals/tasks.json
  evals/examples/verify-raw-body.md
  evals/examples/dedupe-resource-version.md
  evals/examples/redelivery-after-timeout.md
rules/order-and-webhook-reliability.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the frontmatter:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order-and-webhook-reliability&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;webhooks&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;raw-body&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;durable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deduplication,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reconciliation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;against&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;truth.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;handling&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;events,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;notifications,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outbound&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;webhook&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;delivery."&lt;/span&gt;
&lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MIT&lt;/span&gt;
&lt;span class="na"&gt;compatibility&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;checker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;requires&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Node.js&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;newer.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;No&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;network&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;access."&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Naser Sadrmanesh&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.3.2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every skill also has a &lt;strong&gt;rule&lt;/strong&gt;: a short plain-Markdown file of constraints for agents that keep always-on guidance in context. A skill is for a task. A rule is for every task in that area. The webhook rule includes this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Treat a redelivery after a timeout as a duplicate: if the inbox row is already &lt;span class="sb"&gt;`applied`&lt;/span&gt;, return 2xx and run no side effect again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Write for the failure, not the happy path
&lt;/h3&gt;

&lt;p&gt;The skill body is a procedure, then the details, then Gotchas, Verification, and Evaluation sections. The part about redelivery is written around the failure, because the happy path is what the agent already knows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most common duplicate is a redelivery after a timeout. The handler stored and applied the event, but the sender timed out before the 2xx arrived and posts the same event again, often with a new delivery id. The retry must find the inbox row already &lt;code&gt;applied&lt;/code&gt;, return 2xx, and run no side effect: no second fulfillment, no second confirmation email, no second stock decrement. This only holds when each side effect is recorded in the same transaction that marks the row &lt;code&gt;applied&lt;/code&gt;, or is keyed on the event id where it runs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It also gives a usable dedupe key instead of a principle. Use the provider event id when it is stable across retries. Otherwise, hash the topic, the resource id, and the resource's &lt;code&gt;date_modified_gmt&lt;/code&gt;. And it records the platform facts an agent cannot infer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WooCommerce counts a delivery as failed when the status is outside &lt;code&gt;2xx&lt;/code&gt;, &lt;code&gt;301&lt;/code&gt;, and &lt;code&gt;302&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After more than five consecutive failures it disables the webhook. The threshold comes from the &lt;code&gt;woocommerce_max_webhook_delivery_failures&lt;/code&gt; filter, which defaults to 5.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WC_Webhook::deliver()&lt;/code&gt; posts with redirects disabled, so a 301 does not mean the JSON reached the next URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters operationally. A handler that keeps returning 500 because of a bug does not just lose events. Five more failures and the store stops sending webhooks at all, which is why the skill says to return a retryable error only when the event was not stored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scripts where a check beats a paragraph
&lt;/h3&gt;

&lt;p&gt;Some things are easier to check than to describe. WooCommerce signs &lt;code&gt;base64(hmac_sha256(secret, raw_body))&lt;/code&gt;, but it HTML-decodes the secret first with &lt;code&gt;wp_specialchars_decode( $secret, ENT_QUOTES )&lt;/code&gt;. A secret containing &lt;code&gt;&amp;amp;&lt;/code&gt; or a quote produces a mismatch that looks like a bug in your code. The skill ships a small checker with no dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeWebhookSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#039;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;woocommerceSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decodeWebhookSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signaturesMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt; node scripts/verify-hmac-signature.mjs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; payload.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--signature&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGNATURE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the secret from the environment or &lt;code&gt;--secret-file&lt;/code&gt; and never prints it. Exit code 0 means the signature matches, 1 means a mismatch, and 2 means a usage error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evals: making a skill falsifiable
&lt;/h2&gt;

&lt;p&gt;A skill that "covers idempotency" is not a claim you can check. I wanted every skill to make claims that can fail. Each skill has an &lt;code&gt;evals/tasks.json&lt;/code&gt; with at least two tasks, and each task lists text that a correct answer must contain and text it must not contain:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"redelivery-after-timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The order.paid handler stored and applied the event and created the fulfillment, but WooCommerce timed out before the 2xx arrived and posted the same event again. Make the retry safe."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"must_include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"applied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2xx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"side effect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"must_not_include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-WC-Webhook-Delivery-ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;must_not_include&lt;/code&gt; is the important half. It encodes the specific wrong answer I described at the top. The validator requires at least one negative check per skill, because a test that only looks for good words is easy to pass with a long, vague answer.&lt;/p&gt;

&lt;p&gt;Each task also has a worked example under &lt;code&gt;evals/examples/&lt;/code&gt;, a short answer that shows what a passing response looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Look up the inbox row by topic, order id, and &lt;span class="sb"&gt;`date_modified_gmt`&lt;/span&gt;. When its status is already &lt;span class="sb"&gt;`applied`&lt;/span&gt;, return 2xx with the original result and run no side effect: no second fulfillment, no second confirmation email, and no second stock decrement.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scorer is deliberately simple. Every &lt;code&gt;must_include&lt;/code&gt; and &lt;code&gt;must_not_include&lt;/code&gt; entry is one check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;expects&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;must_include&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`missing required text: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;expects&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;must_not_include&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`contains forbidden text: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;errors&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;h3&gt;
  
  
  Running it
&lt;/h3&gt;

&lt;p&gt;Without arguments, the runner scores every worked example and requires all checks to pass. That is a self-check: it proves the fixtures and examples agree. It runs in CI on every pull request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/run-evals.mjs --skill order-and-webhook-reliability
 100%  order-and-webhook-reliability (3/3 tasks)

Overall 100%: 10/10 checks, 3/3 tasks passed (examples).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across the library it reports &lt;code&gt;Overall 100%: 211/211 checks, 60/60 tasks passed (examples).&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The more useful mode is scoring real answers. You give your agent the task text, save each answer as &lt;code&gt;answers/&amp;lt;skill&amp;gt;/&amp;lt;task-id&amp;gt;.md&lt;/code&gt;, and run the scorer with a threshold. Here is the wrong fix from the top of this post, scored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/run-evals.mjs --answers ./answers --skill order-and-webhook-reliability --min-score 0.8
  60%  order-and-webhook-reliability (2/3 tasks)
         redelivery-after-timeout: missing required text: applied; missing required text: 2xx; missing required text: side effect; contains forbidden text: X-WC-Webhook-Delivery-ID

Overall 60%: 6/10 checks, 2/3 tasks passed (answers).
$ echo $?
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer file said: "Store each X-WC-Webhook-Delivery-ID in a table and skip the request if the id was seen before. Make the handler faster so the timeout does not happen." It fails on the forbidden header, and it fails on three missing concepts. It exits with status 1 below the threshold, so you can put it in a pipeline that compares agent output with and without the skill installed.&lt;/p&gt;

&lt;p&gt;Notice what it did &lt;em&gt;not&lt;/em&gt; catch: the answer gets credit for &lt;code&gt;timeout&lt;/code&gt; just by mentioning it. That is the honest limit of substring scoring, and I come back to it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validator: consistency at 29 skills
&lt;/h2&gt;

&lt;p&gt;With one skill, you can keep everything straight by reading it. With 29, drift starts on the first release. &lt;code&gt;scripts/validate-skills.mjs&lt;/code&gt; runs in CI and before publish, and fails on any of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- metadata.version must match package.json exactly
- description must be between 100 and 320 characters
- SKILL.md must have Gotchas, Verification, and Evaluation sections
- a skill with scripts/ must set compatibility
- every relative link must resolve and stay inside the skill directory
- every file under references/ and scripts/ must be referenced from SKILL.md
- every script must pass `node --check`
- the rule must exist, be plain Markdown, start with the same heading as SKILL.md,
  and be 8 to 80 lines
- evals: at least two tasks, kebab-case ids, a worked example per task that passes
  its own checks, and at least one must_not_include per skill
- every must_include term must appear in the skill, its rule, or its references
- every skill must be in the catalog order and in exactly one website category
- no em dashes and no zero-width non-joiners in prose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of these deserve a word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every eval term must be taught.&lt;/strong&gt; This is the check I am most glad I added. Without it, it is easy to write an eval that expects &lt;code&gt;date_modified_gmt&lt;/code&gt; while the skill only says "use the resource version". Then the eval tests the agent's prior knowledge, not the skill. The check is a case-insensitive search through the skill, rule, and references:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;untaughtTerms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taughtText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;taughtText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;expects&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;must_include&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;term&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The description length window.&lt;/strong&gt; The description is what the agent uses to choose a skill, so it has to name both what the skill does and when to use it. Under 100 characters, it is usually missing the "use when". Over 320, it starts to crowd out other skills in the selection step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unreferenced files are errors.&lt;/strong&gt; An agent opens references only when the skill points to them. A reference nobody links to is dead weight that will never be read, so the validator treats it as a bug.&lt;/p&gt;

&lt;p&gt;The release workflow ties it together. A &lt;code&gt;vX.Y.Z&lt;/code&gt; tag must match &lt;code&gt;package.json&lt;/code&gt;, then the validator, evals, and tests run before anything is published. &lt;code&gt;npm test&lt;/code&gt; runs 50 tests covering the installer, scorer, validator, and catalog generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The installer
&lt;/h2&gt;

&lt;p&gt;The format does not name an install path, and different agents look in different places. So the package ships a small CLI with no dependencies, which copies files and downloads nothing after the package itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sadrmanesh/commerce-agent-skills init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That copies every skill to &lt;code&gt;.agents/skills/&lt;/code&gt; and every rule to &lt;code&gt;.agents/rules/&lt;/code&gt;. The rest of the surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commerce-agent-skills list [--json]
commerce-agent-skills init [options]
commerce-agent-skills add &amp;lt;skill&amp;gt; [&amp;lt;skill&amp;gt; ...] [options]

--target &amp;lt;dir&amp;gt;       Project directory to install into
--layout &amp;lt;name&amp;gt;      agents (.agents/skills and .agents/rules, default) or root (skills/ and rules/)
--skills-dir &amp;lt;path&amp;gt;  Skills directory, relative to --target
--rules-dir &amp;lt;path&amp;gt;   Rules directory, relative to --target
--only &amp;lt;a,b&amp;gt;         init: install only these skills
--no-rules           Do not copy the matching rules
--force              Replace skills that are already installed
--dry-run            Print what would be copied and write nothing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx @sadrmanesh/commerce-agent-skills add order-and-webhook-reliability --dry-run
Would add order-and-webhook-reliability at .agents/skills/order-and-webhook-reliability and .agents/rules/order-and-webhook-reliability.md
Planned: 1 of 1 skills. Skills: .agents/skills, rules: .agents/rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions about existing files. &lt;code&gt;init&lt;/code&gt; is meant to be rerun, so it skips skills that are already installed. &lt;code&gt;add&lt;/code&gt; names specific skills, so an existing one is an error unless you pass &lt;code&gt;--force&lt;/code&gt;. Both validate every name and build the full plan before writing anything, so a typo in the third name does not leave you with two of three skills installed. Exit codes are 0 for success, 1 for a skill or file error, and 2 for a usage error.&lt;/p&gt;

&lt;p&gt;Installed skills are copies, not links. You can edit them for your shop, and you can review them in a pull request like any other file in the repository. The trade-off is that upgrades are explicit: rerun with &lt;code&gt;--force&lt;/code&gt; and read the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this connects to MCP
&lt;/h2&gt;

&lt;p&gt;One of the skills, &lt;code&gt;mcp-store-tooling&lt;/code&gt;, covers exposing a store to an agent through the Model Context Protocol: tool annotations, input and output schemas, scopes, and dry runs with confirmation for risky writes. I wrote it alongside a real server, &lt;a href="https://github.com/sadrmanesh/woocommerce-mcp" rel="noopener noreferrer"&gt;woocommerce-mcp&lt;/a&gt;, where risky writes go through a preview, a confirmation, and an audit log.&lt;/p&gt;

&lt;p&gt;The design is in &lt;a href="https://dev.to/sadrmanesh/safe-write-tools-for-agents-preview-confirm-audit-and-stale-previews-in-a-woocommerce-mcp-server-2c66"&gt;Safe write tools for agents&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;I want to be precise about what the evals prove.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scoring is substring matching.&lt;/strong&gt; It checks that the right concepts appear and that known wrong answers do not. It does not check that the reasoning is correct. An answer can mention &lt;code&gt;applied&lt;/code&gt;, &lt;code&gt;2xx&lt;/code&gt;, &lt;code&gt;side effect&lt;/code&gt;, and &lt;code&gt;timeout&lt;/code&gt; in a wrong order and pass. The negative checks catch the common wrong answer, not every wrong answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 100% figure is a self-check.&lt;/strong&gt; 211 of 211 checks pass against worked examples I wrote. It shows the fixtures are consistent, not that any agent performs well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runner does not call an agent.&lt;/strong&gt; You collect answers yourself and score them. That keeps it provider-neutral and free to run in CI, but it means a real comparison takes work on your side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform facts drift.&lt;/strong&gt; Header names, limits, and filters are documented against WooCommerce and Shopify behavior I checked, and the reference notes say to confirm against your version when you debug a live problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;From the roadmap: graded eval rubrics beyond substring checks, more store platforms written to the same level of API detail as the WooCommerce and Shopify skills, more payment providers written like the Stripe and Zarinpal skills, and runnable reference services for the payout ledger and a feed upload, with the same checks the local scripts apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sadrmanesh/commerce-agent-skills list
npx @sadrmanesh/commerce-agent-skills add order-and-webhook-reliability stripe-payments
npx @sadrmanesh/commerce-agent-skills init &lt;span class="nt"&gt;--layout&lt;/span&gt; root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node.js 20 or newer. The package is on npm and, under the same name, on GitHub Packages. The skills are also published as a dataset on Hugging Face at &lt;code&gt;sadrmanesh/commerce-agent-skills&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/sadrmanesh/commerce-agent-skills" rel="noopener noreferrer"&gt;https://github.com/sadrmanesh/commerce-agent-skills&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://sadrmanesh.github.io/commerce-agent-skills/" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/commerce-agent-skills/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. If you have a commerce bug that an agent keeps getting wrong, open an issue with the task and the wrong answer. That is exactly the shape of a new eval.&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>webhooks</category>
      <category>node</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Four open-source tools for AI in e-commerce, and what building them taught me</title>
      <dc:creator>Naser Sadrmanesh</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:01:31 +0000</pubDate>
      <link>https://dev.to/sadrmanesh/four-open-source-tools-for-ai-in-e-commerce-and-what-building-them-taught-me-5abl</link>
      <guid>https://dev.to/sadrmanesh/four-open-source-tools-for-ai-in-e-commerce-and-what-building-them-taught-me-5abl</guid>
      <description>&lt;p&gt;I'm Naser Sadrmanesh, an AI product engineer and founder working on online stores. Over the last months I turned the tools I kept rebuilding for clients into four open-source projects. They are small, MIT licensed, and each one exists because a real project needed it. This post explains what each does and the design decision behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. llm-search-eval: is the new search actually better?
&lt;/h2&gt;

&lt;p&gt;Every store eventually changes its search: keyword to vectors, a new reranker, query rewriting, synonyms. The usual offline check is to run 50 queries, compare nDCG, and ship if the number went up. That check fails in predictable ways.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Labels go stale.&lt;/strong&gt; Gold labels were written for the old system. A new retriever surfaces products nobody labeled, so they count as misses and the new system looks worse than it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small deltas are noise.&lt;/strong&gt; +2% on 50 queries is often chance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One LLM judge is one opinion.&lt;/strong&gt; If you fill gaps with a model, someone has to check it against people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/sadrmanesh/llm-search-eval" rel="noopener noreferrer"&gt;llm-search-eval&lt;/a&gt; builds one label pool per run: your gold labels first, LLM judgments only for the gaps, and every system scored on the same pool. You can run a panel of judges and see Cohen's kappa, Fleiss' kappa and Krippendorff's alpha between them, plus how each judge agrees with your human labels. Every comparison gets a paired randomization test and bootstrap confidence intervals. Pairwise A/B judging runs in both orders to cancel position bias.&lt;/p&gt;

&lt;p&gt;The part teams like most is boring: an &lt;code&gt;estimate&lt;/code&gt; command predicts the number of judge calls, tokens and cost before you run anything, and budgets stop a run before it overspends. A GitHub Action posts the result as a pull request comment and fails the check when ranking regresses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;llm-search-eval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has no runtime dependencies and works with any OpenAI-compatible endpoint, including local models. There is a &lt;a href="https://sadrmanesh.github.io/llm-search-eval/demo/report.html" rel="noopener noreferrer"&gt;sample report&lt;/a&gt; and a &lt;a href="https://huggingface.co/spaces/sadrmanesh/llm-search-eval-demo" rel="noopener noreferrer"&gt;live demo on Hugging Face&lt;/a&gt; that runs in the browser with no API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. woocommerce-mcp: let an agent touch the store, carefully
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol makes it easy to give an AI agent tools. For a live store, the hard question is not "can it call the API" but "what happens when it is wrong".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sadrmanesh/woocommerce-mcp" rel="noopener noreferrer"&gt;woocommerce-mcp&lt;/a&gt; answers that with a few rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stores are &lt;strong&gt;read-only&lt;/strong&gt; until you allow writes.&lt;/li&gt;
&lt;li&gt;Every write tool first returns a &lt;strong&gt;preview&lt;/strong&gt; of the exact REST request. It is applied only when the tool is called again with &lt;code&gt;confirm: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every write attempt, applied or refused, goes to an &lt;strong&gt;audit log&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Customer data can be &lt;strong&gt;redacted&lt;/strong&gt;, and tools can be allowed or denied by pattern, for example hiding &lt;code&gt;delete_*&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within those rules it covers 47 tools: catalog search, orders and fulfillment with tracking, refunds, bulk price and stock changes, coupons and scheduled sales, customer segments, shipping and tax, webhooks, and sales analytics. It runs over stdio or streamable HTTP with bearer tokens or OAuth 2.0, and it is on npm, GHCR, Docker Hub and the official MCP Registry. You can browse every tool and its schema in the &lt;a href="https://huggingface.co/spaces/sadrmanesh/woocommerce-mcp-explorer" rel="noopener noreferrer"&gt;tool explorer&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; @sadrmanesh/woocommerce-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: the preview and confirm loop costs one extra call, and it is the single feature store owners ask about first.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. commerce-agent-skills: teach agents the domain, not just the syntax
&lt;/h2&gt;

&lt;p&gt;Coding agents write plausible checkout code that misses things an experienced e-commerce engineer would never miss: idempotent webhooks, refunds that do not match the original currency unit, stock that oversells across warehouses, inaccessible product pages.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sadrmanesh/commerce-agent-skills" rel="noopener noreferrer"&gt;commerce-agent-skills&lt;/a&gt; is a library of 29 skills written in plain Markdown, each with rules, reference notes, gotchas, a verification checklist and evaluation fixtures. Topics include Stripe and Iranian payment gateways, returns and refunds, inventory across warehouses, B2B pricing, catalog migration, WCAG 2.2 accessibility, Core Web Vitals, WooCommerce and Shopify development, and MCP store tooling. A small scorer checks answers against the fixtures, so a skill change can be measured instead of guessed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sadrmanesh/commerce-agent-skills init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skills are tool neutral: they are just files in &lt;code&gt;.agents/skills/&lt;/code&gt; that any agent can read. They are also published as a &lt;a href="https://huggingface.co/datasets/sadrmanesh/commerce-agent-skills" rel="noopener noreferrer"&gt;Hugging Face dataset&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. persian-rag: retrieval that respects the language
&lt;/h2&gt;

&lt;p&gt;Persian breaks naive pipelines quietly. Arabic and Persian Yeh and Kaf look identical but are different code points. Half-spaces come and go. Three digit systems appear in one document. PDFs often store Persian in reversed order. Common words have several accepted spellings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sadrmanesh/persian-rag" rel="noopener noreferrer"&gt;persian-rag&lt;/a&gt; handles those first, then provides a normal RAG toolkit: loaders, chunking, BM25 plus dense retrieval with rank fusion, a spelling-variant query rewriter, reranking, answers with citations resolved to source spans, chat memory, evaluation, a FastAPI server and a Docker image. The core install is pure Python with no dependencies.&lt;/p&gt;

&lt;p&gt;On its small bundled benchmark, the rewriter raises BM25 Hit@1 on misspelled queries from 0.58 to 0.83. That set is tiny, so I treat it as a regression test rather than a claim. You can try it in the &lt;a href="https://huggingface.co/spaces/sadrmanesh/persian-rag-demo" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;, and the benchmark is on &lt;a href="https://huggingface.co/datasets/sadrmanesh/persian-rag-bench" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;persian-rag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What ties them together
&lt;/h2&gt;

&lt;p&gt;All four follow the same habits: docs sites for every project, CI on every change, tagged releases that publish to PyPI, npm, GHCR, Docker Hub or the MCP Registry automatically, a GitLab mirror, and honest limits written in the README. None of them is finished. If one of them solves a problem you have, I'd be glad to hear about it, and issues and pull requests are welcome.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://sadrmanesh.github.io/" rel="noopener noreferrer"&gt;https://sadrmanesh.github.io/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/sadrmanesh" rel="noopener noreferrer"&gt;https://github.com/sadrmanesh&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>ecommerce</category>
      <category>python</category>
    </item>
  </channel>
</rss>
