<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://subhadipmitra.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://subhadipmitra.com/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-15T08:56:39+00:00</updated><id>https://subhadipmitra.com/feed.xml</id><title type="html">Subhadip Mitra</title><subtitle>Data platforms. AI systems. The infrastructure between them. Engineering Leader at Google Cloud. AI Systems Architect.</subtitle><author><name>Subhadip Mitra</name><email>contact@subhadipmitra.com</email></author><entry><title type="html">Your OpenAPI Spec Is Not an Agent Interface: Compiling Governed MCP Tool Surfaces</title><link href="https://subhadipmitra.com/blog/2026/api-mcp-compiler/" rel="alternate" type="text/html" title="Your OpenAPI Spec Is Not an Agent Interface: Compiling Governed MCP Tool Surfaces"/><published>2026-08-12T18:00:00+00:00</published><updated>2026-08-13T00:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/api-mcp-compiler</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/api-mcp-compiler/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/bassrehab/api-mcp-compiler">github.com/bassrehab/api-mcp-compiler</a> · <strong>Docs:</strong> <a href="https://api-mcp.subhadipmitra.com/">api-mcp.subhadipmitra.com</a> · <strong>Install:</strong> <code class="language-plaintext highlighter-rouge">pip install api-mcp-compiler</code></p> </blockquote> <blockquote> <p><strong>TL;DR:</strong> <code class="language-plaintext highlighter-rouge">pip install api-mcp-compiler</code> compiles OpenAPI 3.x, Swagger 2.0, and WSDL 1.1 into MCP tool surfaces that are designed rather than transliterated: tools named for tasks, transport plumbing and deprecated operations dropped, lookup-then-act sequences composed. Every field carries provenance back to the source. Governance is a separate reviewable manifest, with narrowest sufficient scopes and rate budgets, and destructive tools are emitted disabled until a person approves them by name, group, or risk class. Approvals are bound by digest to the exact revision of the spec, so they do not silently survive a change to it.</p> </blockquote> <p>Every gateway vendor now ships a button that turns an OpenAPI spec into an MCP server. Press it and your 87-operation service becomes 87 tools, each one a faithful transliteration of an endpoint that was designed for a frontend team in 2019. The conversion is real, the demo works, and the hard problem has not been touched.</p> <p>The hard problem is deciding which tools should exist at all, what they should be called, what they should accept and return, and which of them must never run without a human saying so. That is interface design, and the interface’s consumer is a language model choosing its next action from names and descriptions. I spent the past few months building a compiler around that problem: <a href="https://github.com/bassrehab/api-mcp-compiler">api-mcp-compiler</a>, which takes OpenAPI 3.x, Swagger 2.0, or WSDL 1.1 and produces an MCP tool surface with provenance on every field, a governance manifest as a separate reviewable artifact, and destructive operations compiled in disabled until a person approves them.</p> <p>I will also show you the part that did not go the way I expected. I pre-registered four evaluations to prove that a semantically planned surface beats naive one-tool-per-operation, and all four came back underpowered. I think the numbers are more useful than the claim would have been, and I would rather report them precisely than bury them. But the reason the project exists is the governance half, and that half was never on trial.</p> <h2 id="the-transliteration-trap">The transliteration trap</h2> <p>Take a real specification and convert it operation by operation. Here is what the agent inherits:</p> <ul> <li><strong>Colliding names.</strong> One API I ingested had ten operations whose summaries all read “Get Details.” An agent selects tools by name and description. Ten tools that describe themselves identically is not a tool surface, it is a lottery.</li> <li><strong>Transport plumbing promoted to interface.</strong> Pagination cursors, response envelopes, and format toggles become tool arguments the model is invited to guess at, even where the server has perfectly good defaults.</li> <li><strong>Dead surface.</strong> Deprecated operations arrive with the same standing as live ones, spending the model’s attention on endpoints the provider intends to withdraw.</li> <li><strong>Destructive parity.</strong> <code class="language-plaintext highlighter-rouge">DELETE /accounts/{id}</code> shows up enabled, right next to the harmless reads, because the spec never said which operations should make a reviewer nervous. Specs describe capability, not intent.</li> </ul> <p>None of this is the spec’s fault. An OpenAPI document is a contract between machines that already know what they want. An agent tool surface is an interface for a consumer that decides what it wants by reading the interface. Those are different artifacts, and I have come to think the second should be compiled from the first the way object code is compiled from source: through explicit, inspectable stages, not string substitution. I have written before about <a href="/blog/2025/mcp-maturity-model/">where organizations sit on context maturity</a> and <a href="/blog/2026/agent-protocol-stack/">how the protocol stack is settling</a>; this project is what I think the bottom layer of that stack should look like when it grows up.</p> <p>The axes that separate the two artifacts, stated plainly. I am not naming products, because this is a difference of category rather than a scoreboard, and any specific tool can close any specific row tomorrow:</p> <table> <thead> <tr> <th style="text-align: left">Axis</th> <th style="text-align: left">Conversion</th> <th style="text-align: left">Compilation</th> </tr> </thead> <tbody> <tr> <td style="text-align: left">Tool names</td> <td style="text-align: left">Mirror the handler that happened to implement the endpoint</td> <td style="text-align: left">Derived from what the operation says it does</td> </tr> <tr> <td style="text-align: left">Arguments</td> <td style="text-align: left">Every parameter the endpoint accepts, transport included</td> <td style="text-align: left">Transport-only arguments withheld where the server has a default</td> </tr> <tr> <td style="text-align: left">Deprecated operations</td> <td style="text-align: left">Carried through at the same standing as live ones</td> <td style="text-align: left">Proposed for omission, with the reason recorded</td> </tr> <tr> <td style="text-align: left">Field origin</td> <td style="text-align: left">Untracked once the output is written</td> <td style="text-align: left">Provenance on every field, back to a pointer in the source</td> </tr> <tr> <td style="text-align: left">Unresolvable constructs</td> <td style="text-align: left">Dropped, or a warning in a log</td> <td style="text-align: left">A first-class ambiguity record in the output</td> </tr> <tr> <td style="text-align: left">Destructive operations</td> <td style="text-align: left">Enabled, sitting next to the reads</td> <td style="text-align: left">Emitted disabled, carrying the reason they were refused</td> </tr> <tr> <td style="text-align: left">Governance</td> <td style="text-align: left">A runtime flag someone can flip in production</td> <td style="text-align: left">A separate manifest, compiled into the artifact</td> </tr> <tr> <td style="text-align: left">Approvals</td> <td style="text-align: left">Ambient, or absent</td> <td style="text-align: left">Bound by digest to the exact revision of the spec</td> </tr> </tbody> </table> <h2 id="a-compiler-not-a-converter">A compiler, not a converter</h2> <p>The pipeline has five stages, each emitting an artifact with its own versioned JSON Schema contract, so a change in one stage cannot be silently misread by the next.</p> <pre><code class="language-mermaid">flowchart LR
    A[OpenAPI / Swagger / WSDL] --&gt; B[Ingestion&lt;br/&gt;Semantic IR]
    B --&gt; C[Planning&lt;br/&gt;proposals + overlay]
    C --&gt; D[Policy&lt;br/&gt;governance manifest]
    D --&gt; E[Generation&lt;br/&gt;MCP server]
    E --&gt; F[Evaluation&lt;br/&gt;state oracles]
</code></pre> <p>Ingestion is deliberately paranoid. No network calls happen during it, remote <code class="language-plaintext highlighter-rouge">$ref</code> resolution is denied, and file loads are restricted to explicitly allowed directories. Every field in the resulting IR carries provenance: where in the source it came from, how it was derived, and with what confidence. Nothing is silently dropped. A construct the compiler cannot resolve becomes an explicit <code class="language-plaintext highlighter-rouge">Ambiguity</code> record, a first-class output rather than a warning scrolling past in a log.</p> <p>A document that splits its schemas across URLs still compiles, because fetching is a separate command rather than a flag on ingestion. <code class="language-plaintext highlighter-rouge">vendor-refs</code> fetches each remote document once, pins it by digest and writes a lock; a compile then reads the pinned files, verifies them, and refuses anything the lock does not name. Ingestion still performs no network access, and an upstream edit surfaces as a digest mismatch rather than as a surface that quietly became something else.</p> <p>The whole flow is a CLI:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python <span class="nt">-m</span> api_mcp_compiler.cli inspect  examples/openapi/inventory_service.yaml  <span class="c"># IR, side effects, provenance</span>
python <span class="nt">-m</span> api_mcp_compiler.cli review   examples/openapi/inventory_service.yaml  <span class="c"># planner proposals, as markdown</span>
python <span class="nt">-m</span> api_mcp_compiler.cli policy   examples/openapi/inventory_service.yaml  <span class="c"># governance manifest</span>
python <span class="nt">-m</span> api_mcp_compiler.cli generate examples/openapi/inventory_service.yaml  <span class="c"># surface, destructive tools disabled</span>
python <span class="nt">-m</span> api_mcp_compiler.cli report   examples/openapi/inventory_service.yaml  <span class="c"># self-contained HTML approval doc</span>
python <span class="nt">-m</span> api_mcp_compiler.cli approve  examples/openapi/inventory_service.yaml <span class="se">\</span>
    <span class="nt">--overlay</span> build/inventory.overlay.json <span class="nt">--risk</span> destructive                    <span class="c"># decisions land in an overlay</span>
python <span class="nt">-m</span> api_mcp_compiler.cli serve    examples/openapi/inventory_service.yaml <span class="se">\</span>
    <span class="nt">--overlay</span> build/inventory.overlay.json <span class="nt">--out</span> build/inventory_server.py      <span class="c"># runnable MCP server</span>
</code></pre></div></div> <h2 id="the-planner-proposes-you-dispose">The planner proposes, you dispose</h2> <p>The planning stage looks at the IR and proposes changes to the baseline one-tool-per-operation mapping. Seven kinds of proposal exist:</p> <ol> <li><strong>Rename</strong>: name tools around tasks instead of handler methods, so <code class="language-plaintext highlighter-rouge">listWarehouseItems</code> becomes <code class="language-plaintext highlighter-rouge">list_items_held_warehouse</code>, derived from the operation’s own summary.</li> <li><strong>Group</strong>: group by the coarsest structure the specification actually states (the first path segment), not a taxonomy the planner invents.</li> <li><strong>Project</strong>: drop optional transport arguments that have server defaults, pagination cursors being the classic case.</li> <li><strong>Describe</strong>: rewrite descriptions for a model reader, stripping web formatting and foregrounding actual side effects.</li> <li><strong>Reclassify</strong>: turn addressable reads into MCP resources so lookups stop consuming tool slots.</li> <li><strong>Omit</strong>: drop deprecated operations, so agent attention is not spent on surface the provider intends to withdraw.</li> <li><strong>Compose</strong>: propose workflow tools for lookup-then-act sequences.</li> </ol> <p>An operation answering 202 has been accepted, not performed. A surface silent about that lets an agent read acceptance as completion, report the goal met, and move on before the work has started, and every part of that failure looks like success. The compiler records it and the tool description says so, naming the header to poll where the document declares one and saying plainly that it does not where the document is silent.</p> <p>Every proposal is recorded with a kind, a target, a rationale written for a person, and a confidence derived from signals in the specification rather than the planner’s self-regard.</p> <p>The important part is what the planner does not get to do: decide. A plan is a set of proposals. Decisions live in an overlay file that the <code class="language-plaintext highlighter-rouge">approve</code> command generates, bound by digest to the exact revision of the specification. When the spec changes, the approvals do not silently carry over. Nobody hand-edits JSON, and the tool surface never shrinks without a recorded reason.</p> <p>Easier to operate than to describe. Below is one specification converted both ways, with the panes fed by an actual compiler run rather than an illustration: each side is <code class="language-plaintext highlighter-rouge">generate</code> over the same document, once with the baseline planner and once with the semantic one, and the rationales, confidences, and governance are the records the compiler itself emitted. Pick any operation to see what changed and why.</p> <section class="qa-inst qa-ms" id="inst-mcp-surface"> <p class="qa-inst-eyebrow"><span class="b">Instrument</span> / Agent tool surfaces</p> <h2>One document, two tool surfaces</h2> <p class="qa-inst-thesis">One document, two conversions. On the left, one tool per operation, which is what a converter emits. On the right, the surface the compiler designs from the same bytes. Pick any operation to see what changed, the rationale the planner recorded, and what the governance manifest says before the tool is allowed to run.</p> <div class="qa-inst-controls"> <div class="qa-inst-ctrl qa-inst-ctrl-wide"> <label for="ms-svc"><span class="qa-inst-lbl">Specification</span><span class="val" id="ms-digest"></span></label> <select id="ms-svc"></select> </div> </div> <div class="qa-ms-panes"> <div class="qa-ms-pane"> <p class="qa-ms-panehead">One tool per operation <span>transliterated</span></p> <ul class="qa-ms-list" id="ms-before"></ul> </div> <div class="qa-ms-pane"> <p class="qa-ms-panehead">Compiled surface <span>designed</span></p> <ul class="qa-ms-list" id="ms-after"></ul> </div> </div> <div class="qa-ms-detail" id="ms-detail" aria-live="polite"></div> <div class="qa-inst-readouts"> <div class="qa-inst-ro"> <div class="k">Source operations</div> <div class="v" id="ms-ops">&mdash;</div> </div> <div class="qa-inst-ro"> <div class="k">Kept as tools</div> <div class="v" id="ms-tools">&mdash;</div> </div> <div class="qa-inst-ro"> <div class="k">Moved to resources</div> <div class="v" id="ms-res">&mdash;</div> </div> <div class="qa-inst-ro"> <div class="k">Emitted disabled</div> <div class="v" id="ms-dis">&mdash;</div> </div> </div> <details> <summary>Where these numbers come from</summary> <p>Every value in this instrument is output from an actual compiler run, not an illustration. Each pane is <code>generate</code> over the same example specification shipped in the repository, once with <code>--planner baseline</code> and once with <code>--planner semantic</code>. The rationales and confidences are the planner's own decision records from <code>plan</code>, and the governance block is the manifest from <code>policy</code>. The specifications are small on purpose: they are the fixtures, and they make the transformation legible in a way a 400-operation estate would not.</p> <p>Nothing here calls a network. The data is compiled into the page.</p> </details> </section> <script type="application/json" id="ms-data">{"services":[{"composites":[],"digest":"3a0ae2f79c0d","id":"inventory","label":"Synthetic inventory service","ops":3,"rows":[{"after":{"args":["warehouse_id"],"blocker_detail":"","blockers":[],"desc":"Returns one page of stocked items.","emission":"executable","kind":"tool","name":"list_items_held_warehouse","risk":"read"},"before":{"args":["page","warehouse_id"],"blockers":[],"desc":"Returns one page of stocked items.","emission":"executable","kind":"tool","name":"listWarehouseItems","risk":"read"},"changes":[{"applied":true,"conf":0.75,"from":"listWarehouseItems","kind":"rename","members":[],"to":"list_items_held_warehouse","why":"Derived from the operation summary 'List items held in a warehouse' rather than the source identifier 'listWarehouseItems', which names a tool after the API instead of the task."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"warehouses","why":"Grouped by the first path segment 'warehouses', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":null,"kind":"project","members":["page"],"to":null,"why":"Withholds page carry transport rather than task concerns. Each is optional and is left off the wire, so the service applies its own value. Confirm no caller needs to set them explicitly."}],"op":"listWarehouseItems","pointer":"openapi:#/paths/~1warehouses~1{warehouse_id}~1items/get","policy":{"approval":"none","envs":["development","staging","production"],"log_class":"standard","rate":{"concurrent":4,"daily":5000,"per_min":60},"retry":"safe","rollback":null,"schemes":["inventoryOAuth"],"scopes":["inventory.read"],"sensitivity":"internal"}},{"after":{"args":["warehouse_id"],"blocker_detail":"a write, destructive or privileged tool requires explicit human approval","blockers":["awaiting_approval"],"desc":"Permanently remove every item record for a warehouse. This removes data and cannot be undone.","emission":"disabled","kind":"tool","name":"permanently_remove_item_record_warehouse","risk":"destructive"},"before":{"args":["warehouse_id"],"blockers":["awaiting_approval"],"desc":"Permanently remove every item record for a warehouse","emission":"disabled","kind":"tool","name":"purgeWarehouseItems","risk":"destructive"},"changes":[{"applied":true,"conf":0.75,"from":"purgeWarehouseItems","kind":"rename","members":[],"to":"permanently_remove_item_record_warehouse","why":"Derived from the operation summary 'Permanently remove every item record for a warehouse' rather than the source identifier 'purgeWarehouseItems', which names a tool after the API instead of the task."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"warehouses","why":"Grouped by the first path segment 'warehouses', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"Permanently remove every item record for a warehouse","kind":"describe","members":[],"to":"Permanently remove every item record for a warehouse. This removes data and cannot be undone.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."}],"op":"purgeWarehouseItems","pointer":"openapi:#/paths/~1warehouses~1{warehouse_id}~1items/delete","policy":{"approval":"user_confirmation","confirm":{"effect":"permanently_remove_item_record_warehouse performs a destructive action that may not be reversible.","required":true,"ttl":300},"envs":["development","staging"],"log_class":"standard","rate":{"concurrent":1,"daily":20,"per_min":2},"retry":"safe","rollback":"No automated compensation exists. Confirm the effect can be reversed manually before enabling in production.","schemes":["inventoryOAuth"],"scopes":["inventory.write"],"sensitivity":"internal"}},{"after":{"args":["warehouse_id"],"blocker_detail":"","blockers":[],"desc":"List items using the retired v1 projection.","emission":"executable","kind":"resource","name":"list_items_using_retired_v1","risk":"read"},"before":{"args":["warehouse_id"],"blockers":[],"desc":"List items using the retired v1 projection","emission":"executable","kind":"tool","name":"listWarehouseItemsLegacy","risk":"read"},"changes":[{"applied":true,"conf":0.75,"from":"listWarehouseItemsLegacy","kind":"rename","members":[],"to":"list_items_using_retired_v1","why":"Derived from the operation summary 'List items using the retired v1 projection' rather than the source identifier 'listWarehouseItemsLegacy', which names a tool after the API instead of the task."},{"applied":true,"conf":0.6,"from":"tool","kind":"reclassify","members":[],"to":"resource","why":"A read whose only inputs identify what to fetch is addressable, so a resource avoids spending a tool slot on a lookup."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"warehouses","why":"Grouped by the first path segment 'warehouses', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"List items using the retired v1 projection","kind":"describe","members":[],"to":"List items using the retired v1 projection.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."},{"applied":false,"conf":0.6,"from":null,"kind":"omit","members":[],"to":null,"why":"The specification marks this operation deprecated, so exposing it spends agent attention on a surface the provider intends to withdraw."}],"op":"listWarehouseItemsLegacy","pointer":"openapi:#/paths/~1warehouses~1{warehouse_id}~1items-v1/get","policy":{"approval":"none","envs":["development","staging","production"],"log_class":"standard","rate":{"concurrent":4,"daily":5000,"per_min":60},"retry":"safe","rollback":null,"schemes":["inventoryOAuth"],"scopes":["inventory.read"],"sensitivity":"internal"}}]},{"composites":[{"changes":[{"applied":false,"conf":0.5,"from":null,"kind":"compose","members":["createRefund","approveRefund"],"to":null,"why":"'createRefund' prepares a resource that 'approveRefund' then approves. Exposing them separately lets an agent take the irreversible step without the step that makes it meaningful."},{"applied":false,"conf":0.45,"from":null,"kind":"compose","members":["createRefund","approveRefund"],"to":null,"why":"'approveRefund' cannot be called without a refunds identifier, and 'createRefund' is what yields one. The specification states the dependency; exposing the two separately makes the agent rediscover it."}],"name":"createRefund_then_approveRefund"}],"digest":"02e9bb0eda3e","id":"orders","label":"Synthetic order and refund service","ops":4,"rows":[{"after":{"args":["customer_id"],"blocker_detail":"","blockers":[],"desc":"Get a customer.","emission":"executable","kind":"resource","name":"get_customer","risk":"read"},"before":{"args":["customer_id"],"blockers":[],"desc":"Get a customer","emission":"executable","kind":"tool","name":"getCustomer","risk":"read"},"changes":[{"applied":true,"conf":0.75,"from":"getCustomer","kind":"rename","members":[],"to":"get_customer","why":"Derived from the operation summary 'Get a customer' rather than the source identifier 'getCustomer', which names a tool after the API instead of the task."},{"applied":true,"conf":0.6,"from":"tool","kind":"reclassify","members":[],"to":"resource","why":"A read whose only inputs identify what to fetch is addressable, so a resource avoids spending a tool slot on a lookup."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"customers","why":"Grouped by the first path segment 'customers', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"Get a customer","kind":"describe","members":[],"to":"Get a customer.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."},{"applied":true,"conf":0.55,"from":null,"kind":"project","members":["id","name"],"to":null,"why":"Projects the 2 field(s) the response declares required and drops email, internal_account_ref, so an agent is not handed the whole payload. Confirm nothing dropped is needed downstream."}],"op":"getCustomer","pointer":"openapi:#/paths/~1customers~1{customer_id}/get","policy":{"approval":"none","envs":["development","staging","production"],"log_class":"sensitive","projected":["id","name"],"rate":{"concurrent":4,"daily":5000,"per_min":60},"retry":"safe","rollback":null,"schemes":[],"scopes":[],"sensitivity":"personal"}},{"after":{"args":["customer_id"],"blocker_detail":"","blockers":[],"desc":"List customer orders.","emission":"executable","kind":"resource","name":"list_customer_orders","risk":"read"},"before":{"args":["customer_id"],"blockers":[],"desc":"List customer orders","emission":"executable","kind":"tool","name":"listCustomerOrders","risk":"read"},"changes":[{"applied":true,"conf":0.75,"from":"listCustomerOrders","kind":"rename","members":[],"to":"list_customer_orders","why":"Derived from the operation summary 'List customer orders' rather than the source identifier 'listCustomerOrders', which names a tool after the API instead of the task."},{"applied":true,"conf":0.6,"from":"tool","kind":"reclassify","members":[],"to":"resource","why":"A read whose only inputs identify what to fetch is addressable, so a resource avoids spending a tool slot on a lookup."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"customers","why":"Grouped by the first path segment 'customers', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"List customer orders","kind":"describe","members":[],"to":"List customer orders.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."}],"op":"listCustomerOrders","pointer":"openapi:#/paths/~1customers~1{customer_id}~1orders/get","policy":{"approval":"none","envs":["development","staging","production"],"log_class":"sensitive","rate":{"concurrent":4,"daily":5000,"per_min":60},"retry":"safe","rollback":null,"schemes":[],"scopes":[],"sensitivity":"personal"}},{"after":{"args":["body"],"blocker_detail":"a write, destructive or privileged tool requires explicit human approval; policy metadata could not be derived, so the tool cannot be shown to be governed","blockers":["awaiting_approval","policy_unresolved"],"desc":"Create a refund request. This changes stored state. The service accepts this and returns before the work is done, so the result is not final. The document does not say where progress can be read.","emission":"disabled","kind":"tool","name":"create_refund_request","risk":"write"},"before":{"args":["body"],"blockers":["awaiting_approval","policy_unresolved"],"desc":"Create a refund request","emission":"disabled","kind":"tool","name":"createRefund","risk":"write"},"changes":[{"applied":true,"conf":0.75,"from":"createRefund","kind":"rename","members":[],"to":"create_refund_request","why":"Derived from the operation summary 'Create a refund request' rather than the source identifier 'createRefund', which names a tool after the API instead of the task."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"refunds","why":"Grouped by the first path segment 'refunds', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"Create a refund request","kind":"describe","members":[],"to":"Create a refund request. This changes stored state. The service accepts this and returns before the work is done, so the result is not final. The document does not say where progress can be read.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."},{"applied":true,"conf":0.55,"from":null,"kind":"project","members":["refund_id"],"to":null,"why":"Projects the 1 field(s) the response declares required and drops status, so an agent is not handed the whole payload. Confirm nothing dropped is needed downstream."}],"op":"createRefund","pointer":"openapi:#/paths/~1refunds/post","policy":{"approval":"human_approval","envs":["development","staging"],"log_class":"sensitive","projected":["refund_id"],"rate":{"concurrent":2,"daily":200,"per_min":10},"retry":"with_idempotency_key","rollback":"No automated compensation exists. Confirm the effect can be reversed manually before enabling in production.","schemes":[],"scopes":[],"sensitivity":"financial"}},{"after":{"args":["refund_id"],"blocker_detail":"a write, destructive or privileged tool requires explicit human approval; policy metadata could not be derived, so the tool cannot be shown to be governed","blockers":["awaiting_approval","policy_unresolved"],"desc":"Approve a refund and release the payment. This changes stored state.","emission":"disabled","kind":"tool","name":"approve_refund_and_release_payment","risk":"write"},"before":{"args":["refund_id"],"blockers":["awaiting_approval","policy_unresolved"],"desc":"Approve a refund and release the payment","emission":"disabled","kind":"tool","name":"approveRefund","risk":"write"},"changes":[{"applied":true,"conf":0.75,"from":"approveRefund","kind":"rename","members":[],"to":"approve_refund_and_release_payment","why":"Derived from the operation summary 'Approve a refund and release the payment' rather than the source identifier 'approveRefund', which names a tool after the API instead of the task."},{"applied":true,"conf":0.65,"from":null,"kind":"group","members":[],"to":"refunds","why":"Grouped by the first path segment 'refunds', which is the coarsest grouping the specification states rather than one this planner invents."},{"applied":true,"conf":0.7,"from":"Approve a refund and release the payment","kind":"describe","members":[],"to":"Approve a refund and release the payment. This changes stored state.","why":"Rewrites the description for an agent reading it in a tool list, dropping formatting written for a rendered page, and states the side effect where the model is actually looking. The source text is unchanged in the IR."}],"op":"approveRefund","pointer":"openapi:#/paths/~1refunds~1{refund_id}~1approve/post","policy":{"approval":"human_approval","envs":["development","staging"],"log_class":"sensitive","rate":{"concurrent":2,"daily":200,"per_min":10},"retry":"with_idempotency_key","rollback":"No automated compensation exists. Confirm the effect can be reversed manually before enabling in production.","schemes":[],"scopes":[],"sensitivity":"financial"}}]}]}</script> <script>!function(){function e(e){return String(null==e?"":e).replace(/[&<>"]/g,function(e){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[e]})}function s(){return d.services[g.svc]}function n(e){for(var n=s().rows,t=0;t<n.length;t++)if(n[t].op===e)return n[t];return null}function t(s){return'<span class="qa-ms-rk '+("destructive"===s?"danger":"read"===s?"comp":"mem")+'">'+e(s)+"</span>"}function a(n,a){var o=s().rows.map(function(s){var n=s[a],o=g.op===s.op;return'<li><button type="button" class="qa-ms-row'+(o?" on":"")+'" data-op="'+e(s.op)+'" aria-pressed="'+(o?"true":"false")+'"><span class="nm">'+e(n.name)+'</span><span class="meta">'+t(n.risk)+("resource"===n.kind?'<span class="qa-ms-kind">resource</span>':"")+'<span class="qa-ms-em '+("disabled"===n.emission?"off":"ok")+'">'+("disabled"===n.emission?"disabled":"executable")+"</span></span></button></li>"}).join("");n.innerHTML=o}function o(s){if(!s)return'<p class="qa-ms-none">No policy entry was derived for this artifact.</p>';var n=[];return n.push(["Approval",s.approval]),s.scopes&&s.scopes.length&&n.push(["Scopes",s.scopes.join(", ")]),s.schemes&&s.schemes.length&&n.push(["Scheme",s.schemes.join(", ")]),s.envs&&s.envs.length&&n.push(["Environments",s.envs.join(", ")]),s.rate&&n.push(["Rate budget",s.rate.per_min+"/min, "+s.rate.concurrent+" concurrent, "+s.rate.daily+"/day"]),s.confirm&&n.push(["Confirmation","required, token expires in "+s.confirm.ttl+"s"]),s.projected&&s.projected.length&&n.push(["Response projected to",s.projected.join(", ")]),s.retry&&n.push(["Retry",s.retry]),s.log_class&&n.push(["Log class",s.log_class]),'<div class="qa-ms-policy">'+n.map(function(s){return'<div class="qa-ms-pk">'+e(s[0])+'</div><div class="qa-ms-pv">'+e(s[1])+"</div>"}).join("")+"</div>"+(s.rollback?'<p class="qa-ms-roll">'+e(s.rollback)+"</p>":"")}function r(){if(g.op){var s=n(g.op);if(s){var t=s.before.args.filter(function(e){return-1===s.after.args.indexOf(e)}),a='<div class="qa-ms-dhead"><div class="qa-ms-names"><code class="was">'+e(s.before.name)+'</code><span class="arw" aria-hidden="true">&rarr;</span><code class="now">'+e(s.after.name)+'</code></div><div class="qa-ms-ptr">'+e(s.pointer)+"</div></div>",r="";s.before.desc!==s.after.desc&&(r='<div class="qa-ms-desc"><span class="k">Description the agent reads</span><p class="was">'+e(s.before.desc)+'</p><p class="now">'+e(s.after.desc)+"</p></div>");var c=t.length?'<div class="qa-ms-desc"><span class="k">Arguments withheld</span><p class="now"><code>'+t.map(e).join("</code>, <code>")+"</code> left off the wire, so the service applies its own value.</p></div>":"",i=s.changes.length?'<ul class="qa-ms-changes">'+s.changes.map(function(s){return'<li><span class="qa-ms-ck">'+e(b[s.kind]||s.kind)+'</span><span class="qa-ms-cst'+(s.applied?"":" prop")+'">'+(s.applied?"applied":"proposed, not applied")+'</span><span class="qa-ms-cf">confidence '+e(s.conf)+"</span><p>"+e(s.why)+"</p></li>"}).join("")+"</ul>":'<p class="qa-ms-none">The planner proposed no change to this operation.</p>',p=s.after.blockers&&s.after.blockers.length?'<div class="qa-ms-blocked"><span class="k">Emitted disabled</span><p>'+e(s.after.blocker_detail||s.before.blocker_detail||"")+"</p></div>":"";h.innerHTML=a+r+c+'<span class="qa-ms-sec">What the planner decided</span>'+i+p+'<span class="qa-ms-sec">Governance manifest</span>'+o(s.policy)}}else h.innerHTML='<p class="qa-ms-hint">Select an operation above to see what the compiler changed and why.</p>'}function c(){var e=s(),n=0,t=0,a=0;e.rows.forEach(function(e){"resource"===e.after.kind?t++:n++,"disabled"===e.after.emission&&a++}),document.getElementById("ms-ops").textContent=e.ops,document.getElementById("ms-tools").textContent=n,document.getElementById("ms-res").textContent=t,document.getElementById("ms-dis").textContent=a,u.textContent=e.digest}function i(){a(f,"before"),a(v,"after"),r(),c()}function p(e){var s=e.target.closest(".qa-ms-row");s&&(g.op=s.dataset.op===g.op?null:s.dataset.op,i())}if(document.getElementById("inst-mcp-surface")){var l=document.getElementById("ms-data");if(l){var d=JSON.parse(l.textContent),m=document.getElementById("ms-svc"),u=document.getElementById("ms-digest"),f=document.getElementById("ms-before"),v=document.getElementById("ms-after"),h=document.getElementById("ms-detail"),g={svc:0,op:null},b={rename:"Rename",group:"Group",project:"Project",describe:"Describe",reclassify:"Reclassify",omit:"Omit",compose:"Compose",surface_kind:"Reclassify",output_projection:"Project",argument_projection:"Project"};f.addEventListener("click",p),v.addEventListener("click",p),d.services.forEach(function(e,s){var n=document.createElement("option");n.value=String(s),n.textContent=e.label,m.appendChild(n)}),m.addEventListener("change",function(){g.svc=Number(m.value),g.op=null,i()}),g.op=d.services[0].rows.length?d.services[0].rows[0].op:null,i()}}}();</script> <h2 id="tools-that-earn-the-right-to-execute">Tools that earn the right to execute</h2> <p>Generation runs everything through an emission gate. The docs state the rule plainly: a tool is emitted executable only if it earned it. Reads pass automatically once validated, because a reviewer clicking through twenty-three read tools one at a time is doing data entry, not governance. Writes and destructive operations need a recorded approval, granted by risk class, by group, or by individual name.</p> <p>Everything that fails the gate is still emitted, but disabled, carrying the reason it was refused. This sounds like a small choice and is not: a surface that silently omitted an operation would be indistinguishable from one where the operation never existed, and nobody reviews an absence. The refusal is compiled into the artifact, not enforced by a runtime configuration flag that someone can quietly flip in production.</p> <p>Two more properties do a lot of the safety work:</p> <ul> <li><strong>Inference is safety-monotone.</strong> Analysis may reclassify an operation as more restricted than the spec suggests, never less. A write that cannot demonstrate its authorization story fails closed.</li> <li><strong>Confirmation does not transfer, and does not keep.</strong> An approval token for a destructive call is bound to a digest of the specific arguments and is single use with an expiry: a lapsed token is refused and a fresh confirmation is required (TTL enforcement shipped in 0.2.0). A confirmation for one operation cannot be replayed to authorize a different one.</li> </ul> <p>Alongside the code, the policy stage emits a per-tool governance manifest: narrowest sufficient scopes rather than a union of alternatives, approval class, rate budgets (calls per minute, concurrency, daily budget), and rollback guidance for destructive operations. Policy is a separate artifact precisely so that the people who review governance never have to read generated code to find it.</p> <p>Separating the manifest from the code has a failure mode I walked into. Four values were derived by the policy manifest, written into every generated server, and acted on by nothing. The confirmation time to live above is one of them: tokens never expired. Credential placement was another, and the compiler had parsed the correct placement and discarded it at the last step, so every scheme was sent as <code class="language-plaintext highlighter-rouge">Authorization: Bearer</code> and an API key in a service-named header, or HTTP basic, produced a 401 on every call. A retry policy of <code class="language-plaintext highlighter-rouge">never</code> retried nothing only because nothing retried at all, which looks identical to compliance from the outside. And a destructive tool carrying a budget of two calls a minute would make two hundred. All four have been fixed since this post first went up.</p> <p>All four passed every test, because every test of generated code asserted on its source text. <code class="language-plaintext highlighter-rouge">requires_confirmation</code> and <code class="language-plaintext highlighter-rouge">token_ttl_seconds</code> were both provably in the file. Reading a file proves a value was written into it; it never proves anything reads it. The tests now execute the emitted module and inspect the request it actually makes, and that change is what found all four.</p> <h2 id="the-soap-path-is-not-a-footnote">The SOAP path is not a footnote</h2> <p>If you have a legacy estate, this is the part you actually care about. WSDL 1.1 is a first-class input, not a translation layer over the REST pipeline: namespaces, XSD types, SOAP headers, faults, and bindings all survive ingestion. XSD types translate to JSON Schema with their facets intact, including pattern, enumeration, and length, and minOccurs and maxOccurs cardinality. Document/literal and rpc/literal bindings are both ingested. Section 5 encoding is refused rather than approximated, because it serializes values as a reference graph the compiler does not write.</p> <p>SOAP also changes what inference is allowed to do. WSDL carries no signal equivalent to an HTTP method, so a SOAP operation is never classified by inference: every one arrives unclassified, and the emission gate blocks it until a reviewer records the side effect. This is deliberate. An operation named DeleteCustomer might delete, and might return a receipt.</p> <p>The path is verified against 40 WSDL documents from the public SAWSDL test collection, fetched and never redistributed, and against two live public SOAP services that answered real calls through generated servers.</p> <h2 id="the-part-i-could-not-prove">The part I could not prove</h2> <p>The obvious question: does any of the planning actually help the agent? I assumed the answer was yes and set out to demonstrate it. Because “assumed the answer was yes” is exactly the condition under which people fool themselves, the harness enforces pre-registration. Before a model-backed comparison runs, the hypothesis, corpus, arms, model, success criteria, budget, primary statistical test, threshold, and falsification conditions are written down and digested. Runs record the digest of their registration, the harness refuses to combine runs whose digests differ, and registrations are append-only. A result cannot be attached to a hypothesis written after the fact, including by me.</p> <p>Scoring is deterministic. A task succeeds if the service ends in the state the goal asked for, judged by an oracle against the final store, not by a model grading a transcript. Oracles name source operations rather than tool names, so one corpus scores both arms without favoring either. The primary test was McNemar’s exact test, two-sided, alpha 0.05, which on a 24-task corpus needs at least six discordant pairs, all favoring the same arm.</p> <p>Four registrations ran to completion:</p> <table> <thead> <tr> <th style="text-align: left">Registration</th> <th style="text-align: center">Baseline (one tool per op)</th> <th style="text-align: center">Semantic plan</th> <th style="text-align: center">Discordant pairs</th> <th style="text-align: center">Needed</th> <th style="text-align: left">Verdict</th> </tr> </thead> <tbody> <tr> <td style="text-align: left">spotify-002</td> <td style="text-align: center">13/24</td> <td style="text-align: center">14/24</td> <td style="text-align: center">1</td> <td style="text-align: center">6</td> <td style="text-align: left">inconclusive</td> </tr> <tr> <td style="text-align: left">spotify-003</td> <td style="text-align: center">17/24</td> <td style="text-align: center">16/24</td> <td style="text-align: center">1</td> <td style="text-align: center">6</td> <td style="text-align: left">inconclusive</td> </tr> <tr> <td style="text-align: left">spotify-004</td> <td style="text-align: center">20/24</td> <td style="text-align: center">21/24</td> <td style="text-align: center">1</td> <td style="text-align: center">6</td> <td style="text-align: left">inconclusive</td> </tr> <tr> <td style="text-align: left">tmdb-002</td> <td style="text-align: center">28/34</td> <td style="text-align: center">28/34</td> <td style="text-align: center">0</td> <td style="text-align: center">6</td> <td style="text-align: left">inconclusive</td> </tr> </tbody> </table> <p>Let me be precise about what the table says. It is not evidence that planning works: every point difference is one discordant pair against a threshold of six. It is also not evidence that planning is useless: an underpowered test that fails to reject tells you about the test, not the world. At the discordance rate the runs actually observed, a 55-task corpus expects about 2.3 discordant pairs, and roughly 150 tasks would be needed to expect six. These corpora were never going to answer the question, and the specs where I expect design to matter most, the 400-operation enterprise WSDLs with a decade of accretion, are exactly the ones that are never public.</p> <p>If I had run one comparison, gotten 14 against 13, and skipped the registration step, this section would be a triumphant chart. Pre-registration is the reason it is a table with “inconclusive” written four times, and I have come around to thinking that is the more useful artifact.</p> <p>The most interesting measurement in the project is hiding in the TMDB row. Both arms scored 28 of 34 with zero discordant pairs, but they did not get there the same way. The agent reached for a composite tool on 29 of 34 tasks, 18 of the 29 composites the plan offered were used at least once, and total calls fell from 312 to 289, a reduction of 7.4 percent. Context bytes rose about 20 percent, because a composite returns the last step’s payload while the baseline agent often stopped at the smaller one it needed. That is a finding: composition changed what the agent did without changing what it achieved, and the trade it made was calls for context. (Secondary, and it stays secondary: the semantic arm used fewer calls on 17 tasks and more on 9, a two-sided sign test at p = 0.169. Suggestive, not evidence.)</p> <h2 id="what-the-harness-caught-instead">What the harness caught instead</h2> <p>The evaluations refused to resolve the headline hypothesis, and paid rent anyway. Building oracles that check final state instead of model behavior turned the eval harness into an instrument, and the instrument found defects the demos never would have:</p> <ul> <li>Redaction deleted a live service’s answer because the field name <code class="language-plaintext highlighter-rouge">TitleCaseWordsWithTokenResult</code> contains the word “token”. The tool returned <code class="language-plaintext highlighter-rouge">[redacted]</code> with HTTP 200 and no hint that anything had been withheld. Sensitivity is now decided on the noun a field name ends on, not on any overlap with a secret vocabulary.</li> <li>A generated SOAP body used the message part’s own name instead of the element the part references. Every local fixture passed. Every real service returned a fault.</li> <li>22 of 40 generated tools carried schemas that no JSON Schema validator would accept, because the source specification writes <code class="language-plaintext highlighter-rouge">"maximum": "50"</code> as a string: valid JSON, an invalid schema, and invisible until something checked it mechanically.</li> </ul> <p>This is the general lesson I keep relearning, most recently while <a href="/blog/2026/loop-engineering-verifier/">building eval loops for verifiers</a>: deterministic evaluation infrastructure is worth building even when the experiment is inconclusive, because the harness audits everything it touches.</p> <p>And it is why the inconclusive table does not undermine the project. Provenance, digest-bound approvals, fail-closed writes, and non-transferable confirmation tokens were never claims about task success rates. They are claims about what an agent may do without a human, and they hold whether semantic planning turns out to be a win, a wash, or a rounding error.</p> <h2 id="what-this-is-not">What this is not</h2> <p>The compiler documents its own limits, and I will repeat them here. A generated artifact cannot provide server-side authorization, protection against confused-deputy designs, or end-user identity propagation. Those are properties of the deployed service, and a tool surface that claims to solve them from the client side is lying to you. The evaluation corpus is two public APIs and small task sets, which is a floor, not a ceiling.</p> <h2 id="try-it">Try it</h2> <p>The <a href="https://github.com/bassrehab/api-mcp-compiler">repo</a> is Apache 2.0, typed strictly, and ships with an executable notebook, golden artifacts for reproducibility, and the full <a href="https://api-mcp.subhadipmitra.com/">docs</a>, including the <a href="https://api-mcp.subhadipmitra.com/evaluation/">evaluation methodology</a> with every registration public. It is on PyPI:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>api-mcp-compiler
</code></pre></div></div> <p>Two things would genuinely move this forward. First, larger task corpora: the arithmetic above says roughly 150 tasks, and that is breadth I cannot generate alone. Second, ugly specifications. The hypothesis that tool surfaces should be designed rather than transliterated is at its most plausible on large, old, colliding surfaces, and those never appear in public benchmarks. If you have a WSDL from 2009 that you are willing to share even in redacted form, I would like to compile it.</p>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="mcp"/><category term="agents"/><category term="openapi"/><category term="governance"/><category term="code-generation"/><summary type="html"><![CDATA[api-mcp-compiler turns OpenAPI, Swagger, and WSDL specifications into MCP tool surfaces with field-level provenance, human approval gates, and compiled-in refusals. Four pre-registered evaluations could not resolve whether semantic planning beats one tool per operation. Here is the design argument, the numbers, and what the evaluation harness caught anyway.]]></summary></entry><entry><title type="html">Beating FP16 with 4-bit Weights: A Portable W4A16 GEMM in Triton</title><link href="https://subhadipmitra.com/blog/2026/w4a16-weight-only-gemm-triton/" rel="alternate" type="text/html" title="Beating FP16 with 4-bit Weights: A Portable W4A16 GEMM in Triton"/><published>2026-07-20T09:00:00+00:00</published><updated>2026-07-22T09:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/w4a16-weight-only-gemm-triton</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/w4a16-weight-only-gemm-triton/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/bassrehab/triton-kernels">github.com/bassrehab/triton-kernels</a> · <strong>Kernel:</strong> <a href="https://huggingface.co/kernels/bassrehab/w4a16">huggingface.co/kernels/bassrehab/w4a16</a></p> </blockquote> <p><a href="/blog/2026/fused-moe-dispatch-triton/">Last time</a> I fused an entire Mixture-of-Experts forward pass into Triton and it beat Megablocks. That kernel went after the routing. This one goes after the other half of the memory-bound inference problem: the weights.</p> <p>LLM inference is bottlenecked on weight loading. A 7B model in FP16 is 14GB that you drag across the memory bus on every forward pass. Quantize those weights to 4 bits and you move a quarter of the bytes. That is the entire pitch behind GPTQ and AWQ, and it is why 4-bit weight-only quantization is how most people actually deploy LLMs in 2026.</p> <p>There is a catch. The fast 4-bit kernels, Marlin and AWQ and exllama, are all CUDA. If you run on AMD, or you just do not want to bet your inference stack on a single vendor, you are stuck.</p> <p>So I wrote a W4A16 GEMM in pure Triton. FP16 activations, 4-bit weights, dequantized inside the kernel. It runs on NVIDIA and AMD with no CUDA and no vendor intrinsics.</p> <p>Here is the honest version of the result. My first correct kernel was three times slower than a plain FP16 matmul. Portable, numerically perfect, and completely useless. Getting it to actually beat FP16 took two rewrites and taught me more about memory bandwidth than the MoE kernel did. The final version beats cuBLAS FP16 by 1.1 to 1.3x in the decode regime, which is exactly the regime that matters when you generate tokens one at a time.</p> <h2 id="why-4-bit-weight-only-is-the-right-lever">Why 4-bit weight-only is the right lever</h2> <p>When you decode text, you produce one token at a time. Batch size is tiny, often one. At that size the GPU has almost nothing to compute, so the whole forward pass is a race to read weights out of HBM. The math is nearly free. The memory is everything.</p> <p>This is the <a href="/instruments/roofline/">memory-bound regime</a>, and it is where quantization pays off. Four-bit weights are a quarter the size of FP16, so you move a quarter of the bytes. If the kernel were perfectly bandwidth-bound, that would be a clean 4x.</p> <p>One number kept me honest the entire time. At batch size 1, cuBLAS FP16 already hits about 71% of the A100’s peak memory bandwidth. That is not a lazy baseline. To beat it you have to move less data <em>and</em> keep the pipes just as full. Moving less data is trivial. Keeping the pipes full while unpacking 4-bit weights on the fly is the hard part, and it is where all the difficulty lives.</p> <h2 id="the-design">The design</h2> <p>The weights are quantized to 4 bits with one scale and one zero-point per group of 128 rows, GPTQ and AWQ style. Eight 4-bit values pack into a single int32 along the contraction dimension. Dequantization happens inside the GEMM loop, so the weights cross the memory bus at 4 bits and only expand to FP16 once they are already on chip, in registers.</p> <p>The inner loop is the whole kernel:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Load one packed int32 (8 weights), unpack all 8 nibbles in registers,
# dequantize with the group's scale, then matmul. No 4-bit weight is read twice.
</span><span class="n">pk</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">b_ptrs</span><span class="p">)</span>                                    <span class="c1"># (BLOCK_K // 8, BLOCK_N) int32
</span><span class="n">w</span>  <span class="o">=</span> <span class="p">(</span><span class="n">pk</span><span class="p">[:,</span> <span class="bp">None</span><span class="p">,</span> <span class="p">:]</span> <span class="o">&gt;&gt;</span> <span class="n">shifts</span><span class="p">[</span><span class="bp">None</span><span class="p">,</span> <span class="p">:,</span> <span class="bp">None</span><span class="p">])</span> <span class="o">&amp;</span> <span class="mh">0xF</span>    <span class="c1"># (BLOCK_K // 8, 8, BLOCK_N)
</span><span class="n">w</span>  <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">reshape</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="p">(</span><span class="n">BLOCK_K</span><span class="p">,</span> <span class="n">BLOCK_N</span><span class="p">)).</span><span class="nf">to</span><span class="p">(</span><span class="n">tl</span><span class="p">.</span><span class="n">float16</span><span class="p">)</span>   <span class="c1"># row 8*i + j maps to K = 8*i + j
</span><span class="n">w</span>  <span class="o">=</span> <span class="p">(</span><span class="n">w</span> <span class="o">-</span> <span class="n">zero</span><span class="p">)</span> <span class="o">*</span> <span class="n">scale</span>                                 <span class="c1"># dequant, still in registers
</span><span class="n">acc</span> <span class="o">+=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">dot</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">out_dtype</span><span class="o">=</span><span class="n">tl</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>               <span class="c1"># FP16 tensor cores, FP32 accumulate
</span></code></pre></div></div> <p>That “no 4-bit weight is read twice” comment is doing a lot of work. It is the single change that took the kernel from useless to competitive, and I will come back to why in the war-stories section.</p> <p>The other half of the design is a split-K path for decode. A skinny matmul, one or a few rows tall, launches very few thread blocks, so most of the GPU sits idle and you never saturate HBM. Splitting the contraction dimension across many blocks, each computing a partial sum and accumulating into the output, brings the parallelism back. The kernel dispatches to split-K for small batches and to the plain tiled kernel once the batch is large enough to fill the machine on its own.</p> <style>.w4a-fig{margin:2rem 0;padding:1.4rem 1.2rem;border:1px solid var(--qa-border);border-radius:8px;background:var(--qa-bg-subtle)}.w4a-lane{margin-bottom:1.4rem}.w4a-lane:last-of-type{margin-bottom:.4rem}.w4a-lane-label{font-family:var(--qa-font-mono,monospace);font-size:.68rem;letter-spacing:.08em;text-transform:uppercase;color:var(--qa-text-muted);margin-bottom:.55rem}.w4a-flow{display:flex;align-items:center;flex-wrap:wrap;gap:.45rem}.w4a-box{display:inline-block;padding:.4rem .6rem;border:1px solid var(--qa-border-strong);border-radius:6px;font-size:.78rem;line-height:1.25;color:var(--qa-text-secondary);background:var(--qa-bg)}.w4a-box small{display:block;font-size:.68rem;color:var(--qa-text-muted);margin-top:.15rem}.w4a-plain{border:0;background:transparent;padding:0}.w4a-arrow{color:var(--qa-text-muted);font-size:.85rem}.w4a-chip{display:inline-block;padding:.22rem .55rem;border-radius:999px;font-size:.7rem;font-weight:600;letter-spacing:.03em}.w4a-bad{background:var(--mhc-hc-soft);color:var(--mhc-hc)}.w4a-good{background:var(--inst-comp-soft);color:var(--inst-comp)}.w4a-warm{background:var(--inst-mem-soft);color:var(--inst-mem)}.w4a-word{display:inline-flex;border:1px solid var(--qa-border-strong);border-radius:6px;overflow:hidden;background:var(--qa-bg)}.w4a-nib{padding:.35rem .42rem;border-right:1px solid var(--qa-border);font-family:var(--qa-font-mono,monospace);font-size:.7rem;color:var(--qa-text-secondary)}.w4a-nib:last-child{border-right:0}.w4a-sm{display:flex;gap:.2rem;flex-wrap:wrap;margin-top:.3rem}.w4a-sq{width:.55rem;height:.55rem;border:1px solid var(--qa-border-strong);border-radius:2px;background:var(--qa-bg)}.w4a-sq.on{background:var(--qa-accent);border-color:var(--qa-accent)}.w4a-fig figcaption{font-size:.75rem;color:var(--qa-text-muted);margin-top:1rem;line-height:1.5}</style> <figure class="w4a-fig"> <div class="w4a-lane"> <div class="w4a-lane-label">Plain tiling &middot; M = 1</div> <div class="w4a-flow"> <span class="w4a-box">y[1&times;N] = x[1&times;K] &middot; W[K&times;N]<small>one block per BLOCK_N columns; each walks all of K</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">SM occupancy<span class="w4a-sm"><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span></span></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-chip w4a-warm">HBM never saturates</span> </div> </div> <div class="w4a-lane"> <div class="w4a-lane-label">Split-K &middot; 4</div> <div class="w4a-flow"> <span class="w4a-box">K &rarr; 4 chunks<small>each block computes a partial dot product</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">FP32 atomic add<small>partial sums accumulate into y</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">SM occupancy<span class="w4a-sm"><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq on"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span><span class="w4a-sq"></span></span></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-chip w4a-good">4&times; blocks in flight</span> </div> </div> <figcaption>At M = 1 the grid is the bottleneck, not the bus: a skinny matmul launches too few blocks to occupy the SMs, so HBM never fills. Splitting the contraction dimension multiplies the blocks in flight. The price is an FP32 atomic add per partial sum, which is why split-K is a dispatch decision rather than a default.</figcaption> </figure> <h2 id="results">Results</h2> <p>Measured on an A100-SXM4-80GB against <code class="language-plaintext highlighter-rouge">torch</code> FP16, which is cuBLAS underneath. Group size 128, on current-generation models. Speedup is W4A16 over FP16 at decode batch sizes.</p> <table> <thead> <tr> <th>Shape (K, N)</th> <th>Model</th> <th>M=1</th> <th>M=8</th> </tr> </thead> <tbody> <tr> <td>deepseek-v3.2-ffn (7168, 18432)</td> <td>DeepSeek-V3.2 (2026)</td> <td><strong>1.28x</strong></td> <td><strong>1.19x</strong></td> </tr> <tr> <td>qwen3-32b-ffn (5120, 25600)</td> <td>Qwen3-32B (2025)</td> <td><strong>1.29x</strong></td> <td><strong>1.16x</strong></td> </tr> <tr> <td>llama3.3-70b-ffn (8192, 28672)</td> <td>Llama 3.3 70B (2024)</td> <td><strong>1.28x</strong></td> <td><strong>1.21x</strong></td> </tr> <tr> <td>deepseek-v3.2-moe-up (7168, 2048)</td> <td>DeepSeek-V3.2 expert</td> <td><strong>1.22x</strong></td> <td><strong>1.14x</strong></td> </tr> </tbody> </table> <p>The kernel beats FP16 across the decode regime on both the dense FFN and the MoE-expert projections, and it does it while using a quarter of the weight memory. The speedups track each other closely across models from 2024 to 2026, because a weight-only GEMM is set by its matrix dimensions and those have barely moved between generations. Once the batch grows past a handful of tokens, into prefill, the unpack overhead stops hiding behind the weight traffic and cuBLAS pulls back ahead. That crossover is fine. Prefill runs once per request; decode runs once per generated token. Decode is where the bill is.</p> <figure> <picture> <img src="/assets/img/blog/w4a16/roofline.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> <figcaption class="caption">W4A16 sits at four times the arithmetic intensity of FP16 for the same shape, because it moves a quarter of the weight bytes. At decode both kernels are far below the compute ceiling and it is a bandwidth race. As batch grows the operating point climbs toward the ridge, but FP16 pulls ahead before it gets there: the W4A16 point falls away from the bandwidth roofline as the unpack overhead takes over, so it loses to overhead rather than to a compute ceiling.</figcaption> </figure> <p>The roofline also shows what I did not manage. Peak achieved bandwidth on the weight stream tops out around 24% of the A100’s HBM. I am collecting maybe a third of the 4x the format promises; most of it is still on the table. More on that in what’s next.</p> <h2 id="things-i-got-wrong-along-the-way">Things I got wrong along the way</h2> <p><strong>The 8x redundant load.</strong> My first kernel indexed the packed weights by <code class="language-plaintext highlighter-rouge">offs_k // 8</code>, which meant eight consecutive rows of the contraction dimension all loaded the same int32. Read the same word eight times and you have moved eight times the packed data. That is more traffic than FP16, not less. The kernel was correct and roughly three times slower than a plain matmul, achieving about 4% of peak bandwidth. The fix is the loop above: load each int32 once, unpack all eight nibbles in registers. Obvious in hindsight, and it took me an afternoon and a bandwidth counter to see it.</p> <figure class="w4a-fig"> <div class="w4a-lane"> <div class="w4a-lane-label">Naive &middot; indexed by offs_k // 8</div> <div class="w4a-flow"> <span class="w4a-box w4a-plain"><span class="w4a-word"><span class="w4a-nib">w7</span><span class="w4a-nib">w6</span><span class="w4a-nib">w5</span><span class="w4a-nib">w4</span><span class="w4a-nib">w3</span><span class="w4a-nib">w2</span><span class="w4a-nib">w1</span><span class="w4a-nib">w0</span></span><small>one int32 &middot; eight 4-bit weights along K</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">rows k = 0 &hellip; 7<small>each row re-loads the whole word for its one nibble</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-chip w4a-bad">8&times; packed traffic = 2&times; FP16 bytes</span> </div> </div> <div class="w4a-lane"> <div class="w4a-lane-label">Final &middot; load once, unpack in registers</div> <div class="w4a-flow"> <span class="w4a-box w4a-plain"><span class="w4a-word"><span class="w4a-nib">w7</span><span class="w4a-nib">w6</span><span class="w4a-nib">w5</span><span class="w4a-nib">w4</span><span class="w4a-nib">w3</span><span class="w4a-nib">w2</span><span class="w4a-nib">w1</span><span class="w4a-nib">w0</span></span><small>the same int32, read once</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">&gt;&gt; 4j &amp; 0xF<small>all eight nibbles unpacked in registers</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-box">8 &times; FP16 &rarr; tl.dot<small>dequantized with the group scale</small></span> <span class="w4a-arrow">&rarr;</span> <span class="w4a-chip w4a-good">1&times; packed traffic = &frac14; FP16 bytes</span> </div> </div> <figcaption>The same packed word, two access patterns. Indexing by offs_k // 8 sends eight rows of the tile to the same int32, so the kernel moves eight times the packed bytes and more total traffic than FP16. Loading the word once and unpacking in registers is the entire difference between useless and competitive.</figcaption> </figure> <p><strong>Parity is not a win.</strong> After the load-once fix the kernel reached parity with cuBLAS on the large matmuls and stayed slower on the small ones. Correct, portable, and exactly as fast as the thing it was supposed to beat. The bandwidth counter said 19% of peak, which meant the kernel was no longer memory-bound at all. It was overhead-bound, spending its time on the unpack and the register shuffle rather than on memory. Beating FP16 needed a different lever entirely, and that lever was parallelism, not bandwidth.</p> <p><strong>Split-K only helps when the matmul is skinny.</strong> Adding split-K got me past FP16 at batch 1. It also made the kernel slower at batch 32, because the extra blocks each write a partial sum through an FP32 atomic add, and once there is enough work to fill the GPU that atomic traffic costs more than the parallelism buys. So split-K is a dispatch decision, not a default.</p> <p><strong>The best split factor is not monotonic in the shape.</strong> I assumed a bigger contraction dimension wanted more splits. Wrong. The attention projection wanted eight splits, the FFN wanted four, and they have the same contraction dimension. The optimum depends on the full shape in a way I could not capture with a clean rule, so I stopped trying to be clever and handed it to <code class="language-plaintext highlighter-rouge">triton.autotune</code>. The subtlety: autotuning a kernel that accumulates through atomic add corrupts its own output during the timing trials, so you have to pass <code class="language-plaintext highlighter-rouge">reset_to_zero</code> to re-zero the accumulator before each trial. Miss that and every autotuned result is garbage.</p> <p><strong>Naming the file after the package.</strong> This one is embarrassing. When I packaged the kernel for the Hugging Face Kernel Hub I put the code in <code class="language-plaintext highlighter-rouge">w4a16/w4a16.py</code> inside a package also named <code class="language-plaintext highlighter-rouge">w4a16</code>. The build’s import check failed with a circular import, because <code class="language-plaintext highlighter-rouge">from .w4a16 import ...</code> resolved to the half-initialized package instead of the module. Renamed the file to <code class="language-plaintext highlighter-rouge">gemm.py</code> and it built. Twenty minutes of staring at a stack trace that was entirely my fault.</p> <h2 id="whats-next">What’s next</h2> <p>The 4x is still out there. Collecting it means a Marlin-style layout: pre-permute the weights offline so they feed the tensor cores in exactly the order the MMA wants them, without the runtime reshape, and pipeline the loads with async copies so the unpack hides under memory latency instead of competing with it. That is a real project, not a tweak, and a Triton port probably lands somewhere short of Marlin’s hand-tuned CUDA. But it should turn the decode win from 1.3x into something a lot bigger.</p> <p>Also on the list: FP8 activations for a W4A8 variant, and correctness validation on an AMD MI300X to make the cross-platform claim concrete rather than structural. The kernel is pure Triton with no vendor code, so it runs on AMD by construction. I just want to prove it on the hardware.</p> <h2 id="code">Code</h2> <p>Everything is in the repo:</p> <ul> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/triton_kernels/w4a16.py"><code class="language-plaintext highlighter-rouge">triton_kernels/w4a16.py</code></a>: the kernel, the split-K path, and the weight-packing utilities.</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/reference/w4a16_reference.py"><code class="language-plaintext highlighter-rouge">reference/w4a16_reference.py</code></a>: the plain-PyTorch ground truth.</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/tests/test_w4a16.py"><code class="language-plaintext highlighter-rouge">tests/test_w4a16.py</code></a>: correctness across shapes, group sizes, and symmetric and asymmetric quantization.</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/benchmarks/roofline/w4a16_roofline.py"><code class="language-plaintext highlighter-rouge">benchmarks/roofline/w4a16_roofline.py</code></a>: the roofline plot.</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/docs/w4a16.md"><code class="language-plaintext highlighter-rouge">docs/w4a16.md</code></a>: the full writeup.</li> </ul> <p>Or load it straight from the Hub, no install:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">kernels</span> <span class="kn">import</span> <span class="n">get_kernel</span>

<span class="n">w4a16</span> <span class="o">=</span> <span class="nf">get_kernel</span><span class="p">(</span><span class="sh">"</span><span class="s">bassrehab/w4a16</span><span class="sh">"</span><span class="p">,</span> <span class="n">version</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">trust_remote_code</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">packed</span><span class="p">,</span> <span class="n">scales</span><span class="p">,</span> <span class="n">zeros</span> <span class="o">=</span> <span class="n">w4a16</span><span class="p">.</span><span class="nf">quantize_weight_int4_grouped</span><span class="p">(</span><span class="n">weight</span><span class="p">,</span> <span class="n">group_size</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">w4a16</span><span class="p">.</span><span class="nf">w4a16_gemm</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">packed</span><span class="p">,</span> <span class="n">scales</span><span class="p">,</span> <span class="n">zeros</span><span class="p">,</span> <span class="n">group_size</span><span class="o">=</span><span class="mi">128</span><span class="p">)</span>
</code></pre></div></div> <h2 id="takeaways">Takeaways</h2> <ol> <li> <p><strong>The naive 4-bit kernel is a trap.</strong> It is easy to write a correct W4A16 kernel that moves more memory than FP16, not less. Watch the bytes you actually move, not just the output. If your weight traffic is not below FP16’s, you have bought nothing, you are just paying for the unpack.</p> </li> <li> <p><strong>Decode is a parallelism problem, not only a bandwidth problem.</strong> At batch 1 the bottleneck is that the GPU is empty, not that the bus is slow. Split-K fills it. That was the counterintuitive part for me: the fix for a memory-bound kernel was more parallelism.</p> </li> <li> <p><strong>Stop hand-tuning shapes.</strong> When the optimum is non-monotonic in the shape, a heuristic will fight you forever. Autotune it, and remember <code class="language-plaintext highlighter-rouge">reset_to_zero</code> if you accumulate through atomics.</p> </li> <li> <p><strong>Portable and competitive are compatible, but the last 4x is CUDA’s home turf.</strong> Pure Triton got me a real decode win over cuBLAS on any vendor. Matching Marlin is a separate, harder fight, and an honest kernel says so.</p> </li> </ol> <hr/> <table> <tbody> <tr> <td>_This is Part 4 of my LLM inference series. <a href="/blog/2025/making-llm-faster/">Part 1: speculative decoding</a></td> <td><a href="/blog/2025/triton-kernels-llm-inference/">Part 2: custom Triton kernels</a></td> <td><a href="/blog/2026/fused-moe-dispatch-triton/">Part 3: fused MoE dispatch</a>. The code, benchmarks, and technical writeup are all in the <a href="https://github.com/bassrehab/triton-kernels">repo</a>._</td> </tr> </tbody> </table>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="deep-learning"/><category term="llm"/><category term="triton"/><category term="quantization"/><category term="inference-optimization"/><summary type="html"><![CDATA[I wrote a 4-bit weight-only GEMM in pure Triton. The fast W4A16 kernels are all CUDA, so this one runs on NVIDIA and AMD. It beats cuBLAS FP16 by 1.1 to 1.3x in the decode regime, and the road there was mostly me being wrong about memory bandwidth.]]></summary></entry><entry><title type="html">The Activation-Cone Blind Spot, or Why Your Jailbreak Defense Can’t See Prefilling</title><link href="https://subhadipmitra.com/blog/2026/activation-cone-blind-spot/" rel="alternate" type="text/html" title="The Activation-Cone Blind Spot, or Why Your Jailbreak Defense Can’t See Prefilling"/><published>2026-07-06T09:00:00+00:00</published><updated>2026-07-06T09:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/activation-cone-blind-spot</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/activation-cone-blind-spot/"><![CDATA[<blockquote> <p><strong>TL;DR:</strong> I evaluated five jailbreak-defense paradigms across seven instruction-tuned models (7B-31B) and five attack families. The headline: activation-based defenses that act at prompt time are <em>structurally</em> blind to prefilling attacks - not weakly, not sometimes, but by construction. AlphaSteer stops GCG and AutoDAN completely, then lets prefilling through at ~50%. The fix turned out to be embarrassingly simple: read the hidden states of the <em>first generated tokens</em> instead. A linear probe there hits AUROC 0.97-1.00 on every model I tested, and paired with a halt rule it drives prefilling success to zero with no false positives on benign inputs. Paper and full artifacts: <a href="https://arxiv.org/abs/2606.29441">arXiv:2606.29441</a>.</p> </blockquote> <h2 id="where-this-came-from">Where this came from</h2> <p>My red-teaming work has been attack-side for a year: <a href="https://arxiv.org/abs/2606.00813">quality-diversity evolution</a> that discovers diverse jailbreaks automatically, and the follow-up finding that safety alignment moves <em>non-monotonically</em> across model generations. Somewhere in the middle of building attack archives, the obvious question flipped around: the defense literature keeps reporting near-perfect numbers, so why do deployed models keep getting jailbroken?</p> <p>The uncomfortable answer is that defenses get evaluated against the attacks they were designed around. GCG suffixes, AutoDAN prompts, roleplay nesting - these all do their damage <em>inside the prompt</em>, and that’s exactly where modern activation-level defenses look. So I ran the cross-product instead: five defense paradigms, five attack families, seven models. The matrix has a hole in it, and the hole has a shape.</p> <h2 id="the-blind-spot-is-geometric-not-empirical">The blind spot is geometric, not empirical</h2> <p>Activation defenses like AlphaSteer work on a simple, elegant premise: harmful requests land in a recognizable region of activation space - call it the harmful cone - and you can gate or steer based on where the prompt’s representation sits relative to that cone.</p> <p>Prefilling attacks never enter the cone. Instead of persuading the model to comply, they <em>put words in its mouth</em>: the attacker seeds the beginning of the assistant’s response (“Sure, here’s how to…”) and lets autoregression do the rest. The prompt itself can be perfectly benign. By the time anything harmful is in flight, the prompt-time gate has already said yes and gone home.</p> <style>.acb-fig{margin:2rem 0;padding:1.4rem 1.2rem;border:1px solid var(--qa-border);border-radius:8px;background:var(--qa-bg-subtle)}.acb-lane{margin-bottom:1.4rem}.acb-lane:last-of-type{margin-bottom:.4rem}.acb-lane-label{font-family:var(--qa-font-mono,monospace);font-size:.68rem;letter-spacing:.08em;text-transform:uppercase;color:var(--qa-text-muted);margin-bottom:.55rem}.acb-flow{display:flex;align-items:center;flex-wrap:wrap;gap:.45rem}.acb-box{display:inline-block;padding:.4rem .6rem;border:1px solid var(--qa-border-strong);border-radius:6px;font-size:.78rem;line-height:1.25;color:var(--qa-text-secondary);background:var(--qa-bg)}.acb-box small{display:block;font-size:.68rem;color:var(--qa-text-muted);margin-top:.15rem}.acb-gate{border-color:var(--qa-accent);color:var(--qa-text)}.acb-blind{border-style:dashed}.acb-probe{border-color:var(--inst-comp);color:var(--qa-text)}.acb-arrow{color:var(--qa-text-muted);font-size:.85rem}.acb-chip{display:inline-block;padding:.22rem .55rem;border-radius:999px;font-size:.7rem;font-weight:600;letter-spacing:.03em}.acb-stop{background:var(--inst-comp-soft);color:var(--inst-comp)}.acb-pass{background:var(--mhc-hc-soft);color:var(--mhc-hc)}.acb-fig figcaption{font-size:.75rem;color:var(--qa-text-muted);margin-top:1rem;line-height:1.5}</style> <figure class="acb-fig"> <div class="acb-lane"> <div class="acb-lane-label">Semantic attack &middot; GCG, AutoDAN, roleplay</div> <div class="acb-flow"> <span class="acb-box">adversarial prompt</span> <span class="acb-arrow">&rarr;</span> <span class="acb-box acb-gate">prompt-time gate<small>reads prompt activations: inside the harmful cone</small></span> <span class="acb-arrow">&rarr;</span> <span class="acb-chip acb-stop">blocked</span> </div> </div> <div class="acb-lane"> <div class="acb-lane-label">Prefilling attack</div> <div class="acb-flow"> <span class="acb-box">benign-looking prompt<small>+ seeded reply: &ldquo;Sure, here&rsquo;s how&hellip;&rdquo;</small></span> <span class="acb-arrow">&rarr;</span> <span class="acb-box acb-gate acb-blind">prompt-time gate<small>sees only benign activations</small></span> <span class="acb-arrow">&rarr;</span> <span class="acb-chip acb-pass">passes</span> <span class="acb-arrow">&rarr;</span> <span class="acb-box">model continues the seeded turn</span> <span class="acb-arrow">&rarr;</span> <span class="acb-box acb-probe">response-time probe<small>reads the first generated tokens</small></span> <span class="acb-arrow">&rarr;</span> <span class="acb-chip acb-stop">halt</span> </div> </div> <figcaption>Where each defense reads. The prompt-time gate decides before any token exists, so a seeded response walks through it; the response-time probe classifies the turn once the model has started producing it.</figcaption> </figure> <p>This is not a tuning problem. The paper formalizes it: any defense that gates on a single layer’s activation alignment at prompt time admits crafted inputs that pass the gate while producing harmful continuations. You can move the gate, widen the cone, stack more prompts into the calibration set - the attack surface survives because the decision is made before the evidence exists.</p> <p>In the measurements this shows up brutally. AlphaSteer, which zeroes out GCG and AutoDAN, sits at roughly 50% attack success against prefilling. The other prompt-time paradigms fare no better.</p> <h2 id="reading-the-model-after-it-starts-talking">Reading the model after it starts talking</h2> <p>The asymmetry that saves us: the model may be <em>coerced</em> into complying, but its hidden states still encode what kind of turn it’s producing. That’s consistent with what I found in the <a href="/blog/2025/detecting-ai-sandbagging/">sandbagging-detection work</a> - internal representations carry intent that outputs alone hide.</p> <p>So the defense moves to response time. Take the hidden states at the first few generated tokens, run a linear probe - the cheapest classifier there is - and ask: is this turn heading somewhere harmful? Across all seven models, that probe separates harmful from benign continuations at AUROC 0.97-1.00. Attach a halt rule and prefilling attack success drops to zero, with no false positives on benign traffic in my evaluation set.</p> <p>Composition is where it gets satisfying. Response-time halting doesn’t replace prompt-time steering; it covers the region steering can’t see. Running AlphaSteer’s null-space steering for prompt-borne attacks <em>plus</em> the response-time halt yields defense success rates of 0.983 on Mistral and 0.994 on Llama across the full attack suite. Defense-in-depth, except the layers are actually orthogonal this time.</p> <p>Here is the whole cross-product to play with - the defense-by-attack matrix from Table 1, per model. Select AlphaSteer and look at the prefilling row, then switch to the composed defense:</p> <section class="qa-inst" id="inst-blindspot"> <p class="qa-inst-eyebrow"><span class="b">arXiv 2606.29441</span> / Activation-cone blind spot</p> <h2>The diagonal illusion: defense &times; attack</h2> <p class="qa-inst-thesis">Every defense looks perfect against the attacks it was designed around. The paper runs the full cross-product instead: five defense paradigms, five attack families, per-model. Pick a defense and watch the prefilling column &mdash; the one the prompt-time gate never sees.</p> <div class="qa-inst-controls"> <div class="qa-inst-ctrl"> <label for="bs-model"><span class="qa-inst-lbl">Model<span class="qa-inst-info" tabindex="0" role="note" aria-label="Four of the seven evaluated models, the ones with the full paradigm comparison in Table 1.">i<span class="qa-inst-tip">Four of the seven evaluated models &mdash; the ones with the full paradigm comparison in Table&nbsp;1 of the paper.</span></span></span><span class="val" id="bs-model-v"></span></label> <select id="bs-model"></select> </div> <div class="qa-inst-ctrl"> <label for="bs-defense"><span class="qa-inst-lbl">Defense paradigm<span class="qa-inst-info" tabindex="0" role="note" aria-label="Prompt-time paradigms act before generation begins. The response-halt compositions classify the first generated tokens instead.">i<span class="qa-inst-tip">Prompt-time paradigms act before generation begins. The response-halt compositions (measured on Mistral and Llama) classify the first generated tokens instead.</span></span></span><span class="val" id="bs-defense-v"></span></label> <select id="bs-defense"></select> </div> </div> <div class="qa-inst-readouts"> <div class="qa-inst-ro"> <div class="k">Prefilling ASR</div> <div class="v" id="bs-prefill">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Semantic attacks &le;5%</div> <div class="v" id="bs-semantic">&mdash;<small>/4</small></div> </div> <div class="qa-inst-ro"> <div class="k">Aggregate DSR</div> <div class="v" id="bs-dsr">&mdash;</div> </div> <div class="qa-inst-ro"> <div class="k">Blind spot</div> <div class="v"><span class="qa-inst-pill" id="bs-state"><span class="dot"></span>&mdash;</span></div> </div> </div> <p class="qa-inst-hover" style="margin-top:1.2rem">Attack success rate by attack family (n=40 per cell, keyword judge):</p> <div class="qa-inst-bars" id="bs-bars"></div> <p class="qa-inst-verdict" id="bs-verdict" style="margin-top:1.6rem"></p> <details> <summary>Where these numbers come from</summary> <p>All cells are Table&nbsp;1 of <a href="https://arxiv.org/abs/2606.29441">the paper</a>: attack success rate under the keyword judge at <code>n=40</code> per attack. A 0/40 cell carries a Wilson 95% CI of [0%, 9%]. The Llama prefilling headline is additionally scaled to <code>n=200</code> (0&ndash;1/200 across runs) and confirmed by a Gemma-4-31B LLM judge.</p> <ul> <li><strong>Prompt-time paradigms</strong> (static steering, CAST, AlphaSteer, prompt-time probe-gating) act on the prompt's activations, before any token is generated. AlphaSteer's 50% prefilling ASR here is the current-environment number; the original paper environment measured 82% (Appendix on environment drift).</li> <li><strong>Response-halt compositions</strong> were measured on Mistral-7B and Llama-3.1-8B. "AlphaSteer + response halt" is the composition the paper's corollary prescribes: steering covers prompt-borne attacks, the first-token probe covers prefilling.</li> <li>Scope: prefilling results are for the canonical prefilling-attack template family; cross-template generalisation depends on probe depth (see the paper's holdout diagnostic).</li> </ul> <p>Context in <a href="/blog/2026/activation-cone-blind-spot/">the post</a>.</p> </details> </section> <script>!function(){"use strict";function e(e){return document.getElementById(e)}function t(e){var t=Object.keys(n[i.value]);o.innerHTML="",t.forEach(function(e){var t=document.createElement("option");t.value=e,t.textContent=e,o.appendChild(t)}),o.value=t.indexOf(e)>=0?e:"AlphaSteer"}function a(){var t=n[i.value][o.value],a=t.asr[s],l=t.asr.filter(function(e,t){return t!==s&&e<=5}).length,d=a>=25,p=a<=10;e("bs-model-v").textContent=i.value,e("bs-defense-v").textContent=o.value.replace(" (paper)",""),e("bs-prefill").innerHTML=a+"<small>%</small>",e("bs-semantic").innerHTML=l+"<small>/4</small>",e("bs-dsr").textContent="."+Math.round(1e3*t.dsr).toString().padStart(3,"0");var c=e("bs-state");c.className="qa-inst-pill "+(d?"danger":"comp"),c.innerHTML='<span class="dot"></span>'+(d?"Open":p?"Closed":"Narrowed");var h,m="";r.forEach(function(e,a){var r=t.asr[a],n=a===s,i=n?d?"var(--mhc-hc)":"var(--inst-comp)":r<=5?"var(--inst-comp)":"var(--qa-text-muted)";m+='<div class="qa-inst-bar"><div class="bl"><span'+(n?' style="color:var(--qa-text);font-weight:600"':"")+">"+e+(n?" &larr; the blind spot":"")+'</span><span class="n">'+r+'% ASR</span></div><div class="track"><div class="fill" style="width:'+Math.max(r,1)+"%;background:"+i+(n?"":";opacity:.6")+'"></div></div></div>'}),e("bs-bars").innerHTML=m,h="AlphaSteer"===o.value?"The diagonal illusion in one row: <b>0%</b> on the attacks activation steering was built for, <b>"+a+"%</b> on prefilling. The prompt looks benign, so the gate never fires \u2014 the paper proves this is structural for any single-layer activation-cone gate, not a tuning miss.":o.value.indexOf("response halt")>=0?"The corollary\u2019s prescribed architecture: null-space steering covers the prompt-borne attacks, the first-token probe covers prefilling. Aggregate DSR <b>."+Math.round(1e3*t.dsr)+"</b> \u2014 dominating both paradigms alone.":o.value.indexOf("Dual-probe")>=0?"Response-time probing alone closes the prefilling column (<b>"+a+"%</b>), but without steering the semantic attacks reopen. Each paradigm covers what the other cannot see \u2014 which is exactly why the paper composes them.":"No defense"===o.value?"The undefended baseline. Note where the mass sits: prefilling at <b>"+a+"%</b> is already among the strongest attacks before any defense enters the picture.":"A prompt-time paradigm: whatever it does against semantic attacks, prefilling stays at <b>"+a+"%</b>. No prompt-time paradigm in the study got prefilling below 28%.",e("bs-verdict").innerHTML=h}if(document.getElementById("bs-model")){var r=["GCG","AutoDAN","DeepInception","Prefilling","Intent laundering"],s=3,n={"Mistral-7B":{"No defense":{asr:[30,95,10,68,20],dsr:.528},"Static steering":{asr:[3,73,3,53,20],dsr:.689},CAST:{asr:[13,98,3,63,15],dsr:.594},"Probe-gated (prompt-time)":{asr:[20,95,3,73,20],dsr:.556},AlphaSteer:{asr:[0,0,0,50,0],dsr:.889},"Dual-probe gated (paper)":{asr:[55,93,8,8,15],dsr:.622},"AlphaSteer + response halt (paper)":{asr:[0,0,0,8,0],dsr:.983}},"Llama-3.1-8B":{"No defense":{asr:[3,3,5,53,30],dsr:.828},"Static steering":{asr:[0,0,0,75,15],dsr:.817},CAST:{asr:[3,5,5,58,40],dsr:.8},"Probe-gated (prompt-time)":{asr:[5,3,3,55,15],dsr:.839},AlphaSteer:{asr:[0,0,3,50,0],dsr:.883},"Dual-probe gated (paper)":{asr:[0,3,5,0,15],dsr:.967},"AlphaSteer + response halt (paper)":{asr:[0,0,3,0,0],dsr:.994}},"Gemma-2-9B":{"No defense":{asr:[0,65,3,28,15],dsr:.772},"Static steering":{asr:[0,65,5,28,10],dsr:.772},CAST:{asr:[0,65,5,28,10],dsr:.772},AlphaSteer:{asr:[5,75,0,33,10],dsr:.739},"Probe-gated (prompt-time)":{asr:[0,65,3,28,10],dsr:.778}},"Qwen-2.5-7B":{"No defense":{asr:[3,8,15,65,0],dsr:.8},"Static steering":{asr:[5,5,15,60,10],dsr:.8},CAST:{asr:[3,5,15,65,0],dsr:.806},AlphaSteer:{asr:[8,5,18,60,0],dsr:.8},"Probe-gated (prompt-time)":{asr:[3,5,15,63,10],dsr:.8}}},i=e("bs-model"),o=e("bs-defense");Object.keys(n).forEach(function(e){var t=document.createElement("option");t.value=e,t.textContent=e,i.appendChild(t)}),i.addEventListener("change",function(){t(o.value),a()}),o.addEventListener("change",a),new MutationObserver(a).observe(document.documentElement,{attributes:!0,attributeFilter:["data-theme"]}),t("AlphaSteer"),a()}}();</script> <p><em>(Standalone and embeddable version: <a href="/instruments/blindspot-matrix/">The blind-spot matrix</a>.)</em></p> <h2 id="why-deployment-people-should-care">Why deployment people should care</h2> <p>Three practical notes from the receiving end of this result:</p> <ol> <li><strong>If your safety stack is prompt-time only, prefilling is an open door.</strong> This includes a lot of production setups where the “defense” is a classifier or steering pass over the user message. Anything with an assistant-prefill API surface, or agent frameworks that template partial responses, is exposed.</li> <li><strong>The fix is nearly free.</strong> A linear probe over first-token hidden states adds no meaningful latency and no extra model. If you already log activations for observability, you’re most of the way there.</li> <li><strong>Evaluate defenses on the cross-product, not the diagonal.</strong> A defense scored only against the attacks it was built for will look perfect right up until it isn’t. The per-sample results, attack implementations, and judge prompt are all released with the paper so the matrix is reproducible.</li> </ol> <h2 id="one-research-line-third-leg">One research line, third leg</h2> <p>For regular readers, this slots into a single thread: discover diverse attacks automatically (<a href="https://openreview.net/forum?id=YMtanBXw5q">RedQueen / MAP-Elites</a>), track how vulnerabilities move across model generations (<a href="https://arxiv.org/abs/2606.00813">cross-generational transfer</a>), and now close the defense gap those attacks expose. Attack discovery tells you where the walls are weak; this paper is about the part of the wall that wasn’t being watched at all.</p> <p>Paper: <a href="https://arxiv.org/abs/2606.29441">Closing the Activation-Cone Blind Spot: Response-Time Probing and Unified Defense</a>. Code, attacks, per-sample results, and the judge prompt ship with it. The defense-by-attack matrix is explorable as <a href="/instruments/blindspot-matrix/">an instrument</a>.</p>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="ai-safety"/><category term="interpretability"/><category term="llm-security"/><category term="jailbreaks"/><category term="red-teaming"/><category term="probes"/><summary type="html"><![CDATA[Prompt-time activation defenses stop GCG and AutoDAN cold, then fail half the time against prefilling. New paper: why that blind spot is structural, and how a linear probe on the first generated tokens closes it - AUROC 0.97-1.00 across seven models, zero prefilling success when composed with steering.]]></summary></entry><entry><title type="html">What Runtime Interpretability Actually Costs, Part 1: The Case for Measuring It</title><link href="https://subhadipmitra.com/blog/2026/runtime-interpretability-cost/" rel="alternate" type="text/html" title="What Runtime Interpretability Actually Costs, Part 1: The Case for Measuring It"/><published>2026-07-02T09:00:00+00:00</published><updated>2026-07-02T09:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/runtime-interpretability-cost</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/runtime-interpretability-cost/"><![CDATA[<style>.ri-fig{--ri-accent:#1b3a8f;--ri-soft:rgba(27,58,143,0.07);--ri-card:#faf7f0;--ri-card-2:#efe9dc;--ri-border:#d9d3c4;--ri-ink:#2a2620;--ri-muted:#8a8272;--ri-good:#2d6a4f;--ri-good-soft:rgba(45,106,79,0.08);--ri-warn:#b45309;--ri-warn-soft:rgba(180,83,9,0.07);--ri-bad:#9d2933;--ri-bad-soft:rgba(157,41,51,0.06);font-family:'Hanken Grotesk',system-ui,-apple-system,sans-serif;margin:1.9rem 0;color:var(--ri-ink)}[data-theme="dark"] .ri-fig{--ri-accent:#7d9bf2;--ri-soft:rgba(125,155,242,0.12);--ri-card:#1d2129;--ri-card-2:#161a20;--ri-border:#333a44;--ri-ink:#d8d4cc;--ri-muted:#8f897c;--ri-good:#5cb894;--ri-good-soft:rgba(92,184,148,0.10);--ri-warn:#d99a4e;--ri-warn-soft:rgba(217,154,78,0.10);--ri-bad:#e07a84;--ri-bad-soft:rgba(224,122,132,0.10)}.ri-fig .ri-mono{font-family:'DM Mono',ui-monospace,SFMono-Regular,Menlo,monospace}.ri-fig .ri-cap{font-family:'DM Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;letter-spacing:.03em;color:var(--ri-muted);text-align:center;margin-top:.7rem}.ri-fig .ri-step{background:var(--ri-card-2);border:1px solid var(--ri-border);border-radius:6px;padding:.5rem .7rem;font-size:.8rem;text-align:center;line-height:1.35}.ri-fig .ri-arrow{text-align:center;color:var(--ri-muted);font-size:1rem;line-height:.7}@media(max-width:760px){.ri-fig .ri-flow{flex-direction:column;align-items:stretch}.ri-fig .ri-flow-arrow{transform:rotate(90deg);align-self:center;padding:.25rem 0}}</style> <blockquote> <p><strong>TL;DR:</strong> The claim that activation probes are too expensive to run in production is folklore. On compute it is wrong by six orders of magnitude: a linear probe is a dot product, roughly 0.00006 percent of the forward pass it rides on. Any real cost hides in implementation, in CUDA graph breaks, per-step device-to-host copies, and batching bookkeeping, none of which is intrinsic to probing. The systems community has already measured the adjacent problem, general model-internal observability, and the numbers back this up: naive hooks run an order of magnitude over baseline while a well-engineered asynchronous readout costs low single digits. What nobody has measured is the case that decides a safety architecture, an always-on calibrated probe library reported with operator-grade statistics. This is Part 1: the argument, what prior work already settles, the gap it leaves, and the harness aimed at that gap. Part 2 has the numbers.</p> </blockquote> <p>Back in March, in <a href="/blog/2026/three-bets-model-honesty/">the model honesty post</a>, I wrote that I wouldn’t run probes on every request because the latency cost is real. It reads like engineering judgment. It was folklore. I had never measured it, so I went to find who had. The answer turned out to be more interesting than “nobody”: the systems community has already measured the adjacent problem, <a href="https://arxiv.org/abs/2605.11093">general model-internal observability</a>, and those numbers puncture the folklore on their own. A naive forward hook runs an order of magnitude over baseline, and a readout that stays on the GPU and exports asynchronously costs low single digits. What is still missing is the measurement an operator actually needs before staking a safety case on it, and that gap is what this series is about.</p> <p>It matters more than a benchmark footnote should, because this number quietly decides the architecture of every runtime monitoring stack being proposed right now. If activation probes are expensive, they get relegated to offline audits and sampled traffic, and the safety case for deployed agents rests on text-level signals alone. If they’re cheap, they belong in the serving path, on every token, always on. Those are two completely different systems, and once you step outside the observability papers, we’re still choosing between them on vibes.</p> <p>So this is part 1 of settling it properly: the arithmetic, what the existing measurements already show, the specific gap they leave for a safety-probe library, and the design of a harness aimed straight at that gap.</p> <h2 id="the-arithmetic-says-probes-are-free">The arithmetic says probes are free</h2> <p>Start with what a probe actually is at inference time. A linear probe is a dot product between a hidden state and a learned weight vector. That’s it. For a 7B-class model with hidden dimension 4096, one probe readout at one layer costs 4096 multiply-accumulates, call it 8,192 FLOPs per token.</p> <p>The forward pass it’s riding on costs roughly 2 FLOPs per parameter per token, so about 14 GFLOPs per token for the 7B model. The ratio is 6 × 10⁻⁷. The probe is 0.00006 percent of the work you were already doing.</p> <p>Scale it up to something aggressive: 100 probes at each of 10 layers, a thousand readouts per token. Now you’re at 8.2 MFLOPs per token, which is 0.06 percent of the forward pass. Go to a 70B model and the ratio gets <em>better</em>, because the forward pass grows faster than the probe does.</p> <p>On compute alone, the “probes are too expensive” position is not just wrong, it’s wrong by six orders of magnitude. If the folklore is true, the cost has to be hiding somewhere else.</p> <div class="ri-fig" role="img" aria-label="One linear probe costs about 6 times 10 to the minus 7 of a 7B forward pass: 0.00006 percent for one readout per token, 0.06 percent for a thousand readouts across 100 probes and 10 layers, and the ratio shrinks further on a 70B model."> <div style="border:1px solid var(--ri-border);border-radius:10px;overflow:hidden;background:var(--ri-card);"> <div style="background:var(--ri-accent);color:#fff;padding:0.6rem 1.1rem;font-size:0.82rem;font-weight:600;letter-spacing:0.02em;">The arithmetic, in FLOPs</div> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:1.6rem;padding:1.5rem 1.3rem;"> <div style="flex:1;min-width:190px;text-align:center;"> <div class="ri-mono" style="font-size:2.4rem;font-weight:600;color:var(--ri-accent);line-height:1;">6&#8239;&times;&#8239;10<sup style="font-size:1rem;">&minus;7</sup></div> <div style="font-size:0.8rem;color:var(--ri-muted);margin-top:0.6rem;max-width:26ch;margin-left:auto;margin-right:auto;line-height:1.4;">one linear probe &divide; the 7B forward pass it rides on</div> </div> <div style="flex:1.4;min-width:250px;display:flex;flex-direction:column;gap:0.5rem;"> <div style="display:flex;align-items:center;justify-content:space-between;gap:0.8rem;padding:0.5rem 0.75rem;border:1px solid var(--ri-border);border-radius:7px;background:var(--ri-card-2);"> <span style="font-size:0.81rem;">1 readout / token</span> <span class="ri-mono" style="font-size:0.88rem;font-weight:600;color:var(--ri-accent);">0.00006%</span> </div> <div style="display:flex;align-items:center;justify-content:space-between;gap:0.8rem;padding:0.5rem 0.75rem;border:1px solid var(--ri-border);border-radius:7px;background:var(--ri-card-2);"> <span style="font-size:0.81rem;">1,000 readouts / token<br/><span style="color:var(--ri-muted);font-size:0.73rem;">100 probes &times; 10 layers</span></span> <span class="ri-mono" style="font-size:0.88rem;font-weight:600;color:var(--ri-accent);">0.06%</span> </div> <div style="display:flex;align-items:center;justify-content:space-between;gap:0.8rem;padding:0.5rem 0.75rem;border:1px solid var(--ri-border);border-radius:7px;background:var(--ri-card-2);"> <span style="font-size:0.81rem;">same 1,000 on a 70B model</span> <span class="ri-mono" style="font-size:0.82rem;font-weight:600;color:var(--ri-good);">ratio shrinks &darr;</span> </div> </div> </div> <div style="padding:0.7rem 1.3rem;background:var(--ri-soft);border-top:1px solid var(--ri-border);font-size:0.82rem;color:var(--ri-ink);">Wrong by six orders of magnitude. If the folklore is true, the cost is hiding somewhere else.</div> </div> </div> <p>The same ratio, live. Pick a model and choose how many layers you read:</p> <section class="qa-inst" id="inst-probe"> <p class="qa-inst-eyebrow"><span class="b">Bet 12</span> / Runtime interpretability</p> <h2>What does an activation probe actually cost?</h2> <p class="qa-inst-thesis">On paper a probe is a rounding error: one dot product against a hidden state the forward pass already computed. So the "too expensive to run in production" folklore cannot be about the math. It is about the implementation, and the gap between a naive hook and a batched async readout is the whole story.</p> <div class="qa-inst-controls"> <div class="qa-inst-ctrl"> <label for="w2-model"><span class="qa-inst-lbl">Model<span class="qa-inst-info" tabindex="0" role="note" aria-label="Model size. The forward pass grows with parameters while the probe does not, so the arithmetic gap widens as the model grows.">i<span class="qa-inst-tip">Model size. The forward pass grows with parameters while the probe does not, so the arithmetic gap widens as the model grows.</span></span></span><span class="val" id="w2-model-v"></span></label> <select id="w2-model"> <option value="7">7B (d=4096, 32L)</option> <option value="13">13B (d=5120, 40L)</option> <option value="70">70B (d=8192, 80L)</option> <option value="405">405B (d=16384, 126L)</option> </select> </div> <div class="qa-inst-ctrl"> <label for="w2-scope"><span class="qa-inst-lbl">Probe scope<span class="qa-inst-info" tabindex="0" role="note" aria-label="Read one layer or every layer. Even reading every layer keeps the arithmetic a tiny fraction of the forward pass.">i<span class="qa-inst-tip">Read one layer or every layer. Even reading every layer keeps the arithmetic a tiny fraction of the forward pass.</span></span></span><span class="val" id="w2-scope-v"></span></label> <select id="w2-scope"> <option value="single">One layer</option> <option value="all">Every layer</option> </select> </div> <div class="qa-inst-ctrl"> <label for="w2-probes"><span class="qa-inst-lbl">Probes / layer<span class="qa-inst-info" tabindex="0" role="note" aria-label="How many linear readouts per layer. A thousand readouts per token (100 probes across 10 layers) is still a fraction of a percent of the arithmetic.">i<span class="qa-inst-tip">How many linear readouts per layer. A thousand readouts per token is still a fraction of a percent of the arithmetic.</span></span></span><span class="val" id="w2-probes-v"></span></label> <input type="range" id="w2-probes" min="1" max="100" step="1" value="1"/> </div> <div class="qa-inst-ctrl"> <label for="w2-impl"><span class="qa-inst-lbl">Implementation<span class="qa-inst-info" tabindex="0" role="note" aria-label="Naive: a Python forward hook that breaks CUDA graph capture and copies scores to the CPU each step. Batched: one small in-graph matmul with asynchronous export.">i<span class="qa-inst-tip">Naive: a Python forward hook that breaks CUDA graph capture and copies scores to CPU each step. Batched: one small in-graph matmul with async export.</span></span></span><span class="val" id="w2-impl-v"></span></label> <select id="w2-impl"> <option value="batched">Batched async readout</option> <option value="naive">Naive PyTorch hook</option> </select> </div> </div> <div class="qa-inst-readouts"> <div class="qa-inst-ro"> <div class="k">Arithmetic overhead</div> <div class="v" id="w2-arith">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Memory (bytes) overhead</div> <div class="v" id="w2-bytes">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Serving latency overhead</div> <div class="v" id="w2-serving">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Verdict</div> <div class="v"><span class="qa-inst-pill" id="w2-state"><span class="dot"></span>&mdash;</span></div> </div> </div> <p class="qa-inst-hover" id="w2-caption" style="margin-top:1.2rem">Where the latency actually goes (log scale) &mdash; the arithmetic floor vs the two implementations:</p> <div class="qa-inst-bars" id="w2-bars"></div> <p class="qa-inst-verdict" id="w2-verdict"></p> <details> <summary>How this is computed</summary> <p>Per generated token: forward pass <code>&asymp; 2 &times; params</code> FLOPs; each linear readout is <code>2 &times; d_model</code> FLOPs. The <strong>arithmetic floor</strong> is that ratio. The <strong>bytes</strong> version tells the same story: the probe weights for a thousand readouts are single-digit megabytes against the ~14 GB of model weights moved per token.</p> <ul> <li><strong>Naive hook</strong>: a Python forward hook breaks CUDA graph capture, forcing the decode path back to eager. An open vLLM internals plug-in pencils a decode-time mode at ~25% throughput; naive hooks run an order of magnitude over baseline. Per-readout device-to-host copies add synchronization points on top.</li> <li><strong>Batched async</strong>: pack all probes at a layer into one weight matrix, do a single small matmul inside the graph, accumulate in a device buffer, export asynchronously. Cost stays in low single digits.</li> </ul> <p>The cost was never in the math. That is the argument of <a href="/blog/2026/runtime-interpretability-cost/">the post</a>. Overhead figures here are illustrative bands anchored to those measured numbers.</p> </details> </section> <script>!function(){"use strict";function e(e){return document.getElementById(e)}function a(e){return e<1e-4?e.toExponential(1):e<1?e.toPrecision(2):e.toFixed(e<10?1:0)}function t(e,a){return{floor:a,batched:1.5+.002*e+a,naive:300+.6*e}}function n(){var n=i[l.model.value],s="all"===l.scope.value,c=+l.probes.value*(s?n.L:1),v=l.impl.value,d=2*n.params,m=2*n.d*c/d*100,b=n.params*r,h=n.d*c*r/b*100,p=t(c,m),u="naive"===v?p.naive:p.batched,f=u<10;l.model.value,e("w2-model-v").textContent=n.label,e("w2-scope-v").textContent=s?"every layer":"one layer",e("w2-probes-v").textContent=c+(1===c?" readout":" readouts"),e("w2-impl-v").textContent="naive"===v?"naive":"batched",e("w2-arith").innerHTML=a(m)+"<small>%</small>",e("w2-bytes").innerHTML=a(h)+"<small>%</small>",e("w2-serving").innerHTML=a(u)+"<small>%</small>";var w=e("w2-state");w.className="qa-inst-pill "+(f?"comp":"danger"),w.innerHTML='<span class="dot"></span>'+(f?"Basically free":"Too expensive"),o(p,v),e("w2-verdict").innerHTML="naive"===v?"The arithmetic is <b>"+a(m)+"%</b>, but the naive hook costs about <b>"+a(p.naive)+"%</b> \u2014 not from the probe, from breaking CUDA graph capture. This is the folklore, and it is an implementation artifact.":"Batched and async, the same probing costs about <b>"+a(p.batched)+"%</b> \u2014 back within reach of the <b>"+a(m)+"%</b> arithmetic floor. The cost was never in the math."}function o(t,n){function o(e){var a=(l(Math.max(e,i))-l(i))/(l(r)-l(i));return Math.max(1.5,Math.min(100,100*a))}var i=1e-5,r=1e3,l=Math.log10,s=[{key:"floor",label:"Arithmetic floor",v:t.floor,color:"var(--qa-text-muted)"},{key:"batched",label:"Batched async",v:t.batched,color:"var(--inst-comp)"},{key:"naive",label:"Naive hook",v:t.naive,color:"var(--mhc-hc)"}],c="";s.forEach(function(e){var t=e.key===n||"batched"===n&&"batched"===e.key||"naive"===n&&"naive"===e.key;c+='<div class="qa-inst-bar"><div class="bl"><span'+(t?' style="color:var(--qa-text)"':"")+">"+e.label+(t?" &larr;":"")+'</span><span class="n">'+a(e.v)+'%</span></div><div class="track"><div class="fill" style="width:'+o(e.v).toFixed(1)+"%;background:"+e.color+(t?"":";opacity:.55")+'"></div></div></div>'}),e("w2-bars").innerHTML=c}if(document.getElementById("w2-model")){var i={7:{label:"7B",params:7e9,d:4096,L:32},13:{label:"13B",params:13e9,d:5120,L:40},70:{label:"70B",params:7e10,d:8192,L:80},405:{label:"405B",params:405e9,d:16384,L:126}},r=2,l={model:e("w2-model"),scope:e("w2-scope"),probes:e("w2-probes"),impl:e("w2-impl")};["change","input"].forEach(function(e){Object.keys(l).forEach(function(a){l[a].addEventListener(e,n)})}),new MutationObserver(n).observe(document.documentElement,{attributes:!0,attributeFilter:["data-theme"]}),n()}}();</script> <p>A systems engineer will object that I picked the flattering denominator, and they’d be half right. Decode is memory-bandwidth bound, not compute bound. You stream the entire weight matrix per token and do almost no arithmetic per byte, so FLOPs were never what set inter-token latency. Fine. Run the same argument in bytes: the probe weights for a thousand readouts are single-digit megabytes to move per token against the ~14 gigabytes you already move for the model, and the score buffer is rounding error. The ratio barely shifts. That the point survives the change of units is the tell. If a real cost exists, it is not in any quantity the arithmetic measures, FLOPs or bytes. It is in the launches, the copies, and the scheduling, and those never show up in a back-of-the-envelope at all.</p> <h2 id="where-the-cost-actually-hides">Where the cost actually hides</h2> <p>And it might be. Modern serving engines are hostile environments for anything that wants to touch intermediate activations. Three suspects, in the order I’d rank them:</p> <p><strong>CUDA graph breaks.</strong> vLLM and friends capture the decode step as a CUDA graph and replay it. It’s one of the larger single wins in modern serving. A naive PyTorch forward hook is Python code executing in the middle of the forward pass, which is incompatible with graph capture, so attaching one can force the whole decode path back to eager mode. You’re not paying for the probe. You’re paying for losing graph replay on everything. This is not a hypothesis: <a href="https://arxiv.org/abs/2603.06588">an open vLLM internals plug-in</a> restricts observation to the prefill phase precisely to preserve CUDA graphs, and pencils in a decode-time mode at roughly 25 percent throughput. That is the price of the naive path, already on the record.</p> <p><strong>Synchronization and device-to-host copies.</strong> A hidden state at d=4096 in bf16 is 8 KB per token per tapped layer. The bandwidth is nothing. But if your implementation copies scores to the CPU every decode step, you’ve inserted a synchronization point into a pipeline that lives or dies by staying asynchronous. Small syncs, repeated thousands of times per second, are how fast systems die.</p> <p><strong>Batching interactions.</strong> Continuous batching means the “batch” at any decode step is a shifting mix of requests at different positions. Per-request probe attribution has to slice a packed tensor correctly without adding its own bookkeeping overhead in Python.</p> <p>Notice what all three have in common: none of them are intrinsic to probing. They’re artifacts of implementation strategy. Which suggests the real claim worth testing is not “probes are cheap” or “probes are expensive” but something more specific:</p> <blockquote> <p>Probing is nearly free if the readout stays on the GPU and plays nice with graph capture. It’s expensive only when implemented the way a researcher would implement it in a notebook.</p> </blockquote> <p>Here’s the shape of both paths:</p> <div class="ri-fig" role="img" aria-label="Two implementations of the same probes. Path A, the notebook way: a Python forward hook per layer breaks CUDA graph capture and drops to eager mode, copies scores to CPU every step, and adds a sync point, so you lose graph replay on everything. Path B, the serving way: all probes packed into one matrix, one small matmul inside the graph, scores accumulated in a device buffer and exported asynchronously, for a few microseconds per step."> <div style="display:flex;flex-wrap:wrap;gap:1rem;"> <div style="flex:1;min-width:250px;border:1px solid var(--ri-border);border-radius:10px;overflow:hidden;background:var(--ri-card);display:flex;flex-direction:column;"> <div style="background:var(--ri-bad);color:#fff;padding:0.6rem 1rem;font-weight:600;font-size:0.85rem;">Path A &middot; the notebook way</div> <div style="padding:1rem;display:flex;flex-direction:column;gap:0.4rem;flex:1;"> <div class="ri-step">Python forward hook, per layer</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">CUDA graph break, back to eager mode</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">copy scores to CPU every step</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">sync point in the decode loop</div> </div> <div style="background:var(--ri-bad-soft);color:var(--ri-bad);border-top:1px solid var(--ri-border);padding:0.6rem 1rem;font-size:0.8rem;font-weight:600;">You pay by losing graph replay on everything.</div> </div> <div style="flex:1;min-width:250px;border:1px solid var(--ri-border);border-radius:10px;overflow:hidden;background:var(--ri-card);display:flex;flex-direction:column;"> <div style="background:var(--ri-accent);color:#fff;padding:0.6rem 1rem;font-weight:600;font-size:0.85rem;">Path B &middot; the serving way</div> <div style="padding:1rem;display:flex;flex-direction:column;gap:0.4rem;flex:1;"> <div class="ri-step">pack all probes per layer into one matrix</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">one small matmul, inside the graph</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">scores accumulate in a device buffer</div> <div class="ri-arrow">&darr;</div> <div class="ri-step">async export every N steps</div> </div> <div style="background:var(--ri-good-soft);color:var(--ri-good);border-top:1px solid var(--ri-border);padding:0.6rem 1rem;font-size:0.8rem;font-weight:600;">Marginal cost: a few microseconds per step.</div> </div> </div> <div class="ri-cap">Same tap points, same probes. The only thing that changes is where the readout runs and whether it survives graph capture.</div> </div> <p>Path A is what every interpretability codebase does today, because interpretability codebases were built for analysis, not serving. Path B packs all probes at a layer into a single weight matrix, does one small matmul per tapped layer inside the graph, accumulates scores in a device buffer, and exports asynchronously. The hypothesis is that Path B’s marginal cost is a few microseconds per step, which at typical inter-token latencies is measurement noise.</p> <p>There’s a Path C too: fuse the readout into an existing kernel’s epilogue so it costs approximately nothing even in launch overhead. I’ve written enough <a href="/blog/2025/triton-kernels-llm-inference/">Triton</a> <a href="/blog/2026/fused-moe-dispatch-triton/">this year</a> to believe that’s a weekend once Path B works. But I want to know whether it’s even necessary before writing it.</p> <hr/> <h2 id="the-experiment">The experiment</h2> <p>The harness is deliberately boring, which is the point. Boring benchmarks are reproducible benchmarks.</p> <div class="ri-fig" role="img" aria-label="The benchmark pipeline: a fixed-rate load generator feeds a vLLM serving engine running four conditions, baseline with no probes, naive hooks with per-step copy, batched on-GPU readout, and an optional fused kernel epilogue. It reports time to first token, inter-token latency, and throughput at p50, p95, and p99, over five or more runs with warmup discarded and bootstrap 95 percent confidence intervals."> <div style="border:1px solid var(--ri-border);border-radius:10px;background:var(--ri-card);padding:1.3rem 1.1rem;"> <div class="ri-flow" style="display:flex;flex-wrap:wrap;align-items:stretch;justify-content:center;gap:0.5rem;"> <div style="flex:1;min-width:105px;border:1px solid var(--ri-border);border-radius:8px;padding:0.7rem;text-align:center;background:var(--ri-card-2);display:flex;flex-direction:column;justify-content:center;"> <div style="font-size:0.83rem;font-weight:600;">Load generator</div> <div style="font-size:0.72rem;color:var(--ri-muted);margin-top:0.3rem;">fixed arrival rate</div> </div> <div class="ri-flow-arrow" style="display:flex;align-items:center;color:var(--ri-muted);font-weight:600;">&rarr;</div> <div style="flex:1;min-width:105px;border:1px solid var(--ri-border);border-radius:8px;padding:0.7rem;text-align:center;background:var(--ri-card-2);display:flex;flex-direction:column;justify-content:center;"> <div style="font-size:0.83rem;font-weight:600;">Serving engine</div> <div style="font-size:0.72rem;color:var(--ri-muted);margin-top:0.3rem;">vLLM under test</div> </div> <div class="ri-flow-arrow" style="display:flex;align-items:center;color:var(--ri-muted);font-weight:600;">&rarr;</div> <div style="flex:1.2;min-width:130px;border:1px solid var(--ri-border);border-radius:8px;padding:0.55rem;background:var(--ri-card-2);display:flex;flex-direction:column;gap:0.28rem;justify-content:center;"> <div style="font-size:0.74rem;padding:0.28rem 0.5rem;border-radius:5px;border:1px solid var(--ri-border);background:var(--ri-card);">Baseline &middot; no probes</div> <div style="font-size:0.74rem;padding:0.28rem 0.5rem;border-radius:5px;border:1px solid var(--ri-border);background:var(--ri-card);">Naive &middot; hooks + copy</div> <div style="font-size:0.74rem;padding:0.28rem 0.5rem;border-radius:5px;border:1px solid var(--ri-accent);background:var(--ri-soft);color:var(--ri-accent);font-weight:600;">Batched &middot; on-GPU readout</div> <div style="font-size:0.74rem;padding:0.28rem 0.5rem;border-radius:5px;border:1px dashed var(--ri-border);background:var(--ri-card);">Fused &middot; kernel epilogue*</div> </div> <div class="ri-flow-arrow" style="display:flex;align-items:center;color:var(--ri-muted);font-weight:600;">&rarr;</div> <div style="flex:1;min-width:105px;border:1px solid var(--ri-border);border-radius:8px;padding:0.7rem;text-align:center;background:var(--ri-card-2);display:flex;flex-direction:column;justify-content:center;"> <div style="font-size:0.83rem;font-weight:600;">Metrics</div> <div style="font-size:0.72rem;color:var(--ri-muted);margin-top:0.3rem;">TTFT, inter-token latency, throughput at p50 / p95 / p99</div> </div> <div class="ri-flow-arrow" style="display:flex;align-items:center;color:var(--ri-muted);font-weight:600;">&rarr;</div> <div style="flex:1;min-width:105px;border:1px solid var(--ri-border);border-radius:8px;padding:0.7rem;text-align:center;background:var(--ri-good-soft);border-color:var(--ri-good);display:flex;flex-direction:column;justify-content:center;"> <div style="font-size:0.83rem;font-weight:600;color:var(--ri-good);">Statistics</div> <div style="font-size:0.72rem;color:var(--ri-muted);margin-top:0.3rem;">5+ runs each, warmup discarded, bootstrap 95% CI</div> </div> </div> </div> <div class="ri-cap">* Fused is the optional fourth condition, built only if Path B leaves a gap worth closing.</div> </div> <p>The details that matter:</p> <p>Four conditions on identical hardware and identical workloads: baseline vLLM with no probes attached, the naive hook implementation, the batched on-GPU implementation, and eventually the fused one. Each condition sweeps probe count (1, 10, 100 per layer) and tap depth (1, 4, 10 layers).</p> <p>Metrics are the ones operators actually care about: time to first token, inter-token latency, and sustained throughput at a fixed request arrival rate, reported at p50, p95, and p99. Plus GPU memory delta and, critically, whether CUDA graph capture survived, because that single boolean probably explains most of whatever gap shows up.</p> <p>Every number gets at least five runs, warmup discarded, and a bootstrap 95 percent confidence interval. If a difference isn’t distinguishable from run-to-run noise, it gets reported as indistinguishable, not rounded into a story. I built <a href="/blog/2025/building-spark-llm-eval/">Spark-LLM-Eval</a> (<a href="https://github.com/bassrehab/spark-llm-eval">repo</a>) because LLM evaluation without confidence intervals is astrology, and I’m not going to commit the same sin in a latency benchmark.</p> <h2 id="predictions-written-down-before-i-have-results">Predictions, written down before I have results</h2> <p>Two of these are, honestly, no longer wild bets. The observability results above already show naive hooks an order of magnitude over baseline and a well-engineered asynchronous readout in the low single digits. What none of them reports is the number for a calibrated safety-probe library run on every decode token, with the tail-latency statistics an operator would demand before trusting it. So these are falsifiable predictions for my specific configuration, not the general one, and I will be held to them.</p> <ol> <li>The naive implementation (Path A) costs 15 to 40 percent of throughput on a 7B model, and almost all of it traces to the graph break and per-step synchronization rather than the probe math itself.</li> <li>The batched on-GPU implementation (Path B) costs under 2 percent of throughput at 100 probes across 10 layers, and under 1 percent in the configurations anyone would actually run.</li> <li>The fused version is statistically indistinguishable from baseline, making it a nice-to-have rather than a requirement.</li> <li>Overhead as a <em>fraction</em> shrinks as model size grows, so the story gets more favorable exactly where the safety stakes are highest.</li> </ol> <div class="ri-fig" role="img" aria-label="Predictions ledger, recorded before results exist. P1: naive path throughput cost on a 7B model, 15 to 40 percent. P2: batched path cost at 100 probes across 10 layers, under 2 percent. P3: fused path versus baseline, statistically indistinguishable. P4: overhead fraction versus model size, shrinks. Part 2 results pending."> <div style="border:1px solid var(--ri-border);border-radius:10px;overflow:hidden;background:var(--ri-card);"> <div style="background:var(--ri-accent);color:#fff;padding:0.6rem 1.1rem;font-size:0.82rem;font-weight:600;letter-spacing:0.02em;">The ledger &middot; written before the numbers exist</div> <div style="display:flex;padding:0.45rem 1rem;font-size:0.68rem;letter-spacing:0.06em;text-transform:uppercase;color:var(--ri-muted);border-bottom:1px solid var(--ri-border);font-family:'DM Mono',ui-monospace,monospace;"> <span style="width:2.2rem;">#</span> <span style="flex:1;">Prediction</span> <span style="width:8.5rem;text-align:right;">Predicted</span> <span style="width:5rem;text-align:right;">Part&#8202;2</span> </div> <div style="display:flex;align-items:center;padding:0.7rem 1rem;font-size:0.83rem;border-bottom:1px solid var(--ri-border);"> <span class="ri-mono" style="width:2.2rem;color:var(--ri-accent);font-weight:600;">P1</span> <span style="flex:1;">Naive path (A) throughput cost, 7B</span> <span class="ri-mono" style="width:8.5rem;text-align:right;color:var(--ri-bad);font-weight:600;">15&ndash;40%</span> <span class="ri-mono" style="width:5rem;text-align:right;color:var(--ri-muted);">&#9711;</span> </div> <div style="display:flex;align-items:center;padding:0.7rem 1rem;font-size:0.83rem;border-bottom:1px solid var(--ri-border);"> <span class="ri-mono" style="width:2.2rem;color:var(--ri-accent);font-weight:600;">P2</span> <span style="flex:1;">Batched path (B) cost, 100 probes &times; 10 layers</span> <span class="ri-mono" style="width:8.5rem;text-align:right;color:var(--ri-good);font-weight:600;">&lt; 2%</span> <span class="ri-mono" style="width:5rem;text-align:right;color:var(--ri-muted);">&#9711;</span> </div> <div style="display:flex;align-items:center;padding:0.7rem 1rem;font-size:0.83rem;border-bottom:1px solid var(--ri-border);"> <span class="ri-mono" style="width:2.2rem;color:var(--ri-accent);font-weight:600;">P3</span> <span style="flex:1;">Fused path (C) vs baseline</span> <span class="ri-mono" style="width:8.5rem;text-align:right;color:var(--ri-good);font-weight:600;">&asymp; baseline</span> <span class="ri-mono" style="width:5rem;text-align:right;color:var(--ri-muted);">&#9711;</span> </div> <div style="display:flex;align-items:center;padding:0.7rem 1rem;font-size:0.83rem;"> <span class="ri-mono" style="width:2.2rem;color:var(--ri-accent);font-weight:600;">P4</span> <span style="flex:1;">Overhead fraction vs model size</span> <span class="ri-mono" style="width:8.5rem;text-align:right;color:var(--ri-good);font-weight:600;">shrinks &darr;</span> <span class="ri-mono" style="width:5rem;text-align:right;color:var(--ri-muted);">&#9711;</span> </div> </div> <div class="ri-cap">Four falsifiable claims. Part 2 fills the last column, whichever way it lands.</div> </div> <p>If prediction 2 holds for the safety-probe case, the last serious version of the objection dies with it. Not weakens. Dies. “We can’t afford to run probes in production” stops being a claim about the technique and becomes, at most, a claim about your implementation and your tolerance for a couple of percent.</p> <h2 id="what-it-unlocks">What it unlocks</h2> <p>The reason to care about a percentage point of throughput is that it determines the monitoring policy you can honestly defend:</p> <div class="ri-fig" role="img" aria-label="Measured overhead decides the monitoring policy. At or below 2 percent, always on, every token and every request. Between 2 and 10 percent, selective, on for agent actions and tool calls. Above 10 percent, offline audits and sampled traffic only."> <div style="border:1px solid var(--ri-border);border-radius:10px;overflow:hidden;background:var(--ri-card);"> <div style="background:var(--ri-accent);color:#fff;padding:0.6rem 1.1rem;font-size:0.82rem;font-weight:600;letter-spacing:0.02em;">The measured number decides the policy you can defend</div> <div style="padding:1.1rem;display:flex;flex-direction:column;gap:0.6rem;"> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.8rem;padding:0.75rem 0.9rem;border:1px solid var(--ri-border);border-left:4px solid var(--ri-good);border-radius:7px;background:var(--ri-good-soft);"> <span class="ri-mono" style="min-width:74px;font-weight:600;color:var(--ri-good);">&le; 2%</span> <span style="flex:1;min-width:200px;font-size:0.86rem;"><strong>Always on.</strong> Every token, every request.</span> </div> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.8rem;padding:0.75rem 0.9rem;border:1px solid var(--ri-border);border-left:4px solid var(--ri-warn);border-radius:7px;background:var(--ri-warn-soft);"> <span class="ri-mono" style="min-width:74px;font-weight:600;color:var(--ri-warn);">2&ndash;10%</span> <span style="flex:1;min-width:200px;font-size:0.86rem;"><strong>Selective.</strong> On for agent actions and tool calls.</span> </div> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.8rem;padding:0.75rem 0.9rem;border:1px solid var(--ri-border);border-left:4px solid var(--ri-bad);border-radius:7px;background:var(--ri-bad-soft);"> <span class="ri-mono" style="min-width:74px;font-weight:600;color:var(--ri-bad);">&gt; 10%</span> <span style="flex:1;min-width:200px;font-size:0.86rem;"><strong>Offline only.</strong> Audits and sampled traffic.</span> </div> </div> </div> <div class="ri-cap">One benchmark number moves you between three architectures. That is why it is worth measuring instead of guessing.</div> </div> <p>That ladder hides an assumption worth stating plainly: it is a monitoring ladder, not an enforcement one. If all you want is to log a score, decorate a trace, or raise an alert, the readout can leave the critical path entirely, and Path B’s async export costs you nothing. But the moment a probe is allowed to <em>act</em>, to block a tool call or halt a generation, you need its verdict before you commit the token, and “before you commit the token” is a synchronization point by definition. Enforcement puts the score back on the hot path that Path B worked to clear. So the honest ladder has two columns, not one: the overhead of always-on monitoring, which I expect to be trivial, and the overhead of always-on enforcement, which is a harder number and a different benchmark. Part 1 measures the first. The second is its own post, and I suspect its answer is exactly where selective probing earns its keep.</p> <p>In <a href="/blog/2026/three-bets-model-honesty/">the March post</a> I sketched a three-layer honesty stack: confessions, chain-of-thought monitoring, and probes. The open question was always whether the probe layer could live in the serving path or had to stay in the lab. The observability community has been answering the general version of that question in public, wiring activation readouts into the serving engines themselves. Activation-level signals used to be the layer with the richest research results and the thinnest production tooling. That is changing fast. What has not been built is the part that matters for safety: not observability in general, but a calibrated, always-on probe layer measured to the standard you would hold a production safety control to.</p> <p>None of this is hypothetical, and it is further along than I would have guessed. The systems community has built model-internal observability into serving engines and measured its cost: an asynchronous, GPU-resident readout in the low single digits of overhead, the naive hook path an order of magnitude worse, on real hardware with real workloads. The mechanism is settled and the folklore is dead. Two things are still open. Those results report medians for a debugging and observability use case, not the tail-latency statistics you need to run a safety control on every token. And none of them is a calibrated probe library wired to a monitoring policy. The runtime has been measured. The safety primitive that sits on top of it has not.</p> <p>So that’s what I’m building, on top of the observability substrates the systems people just shipped rather than reinventing them. The harness here is the first milestone: standard tap points, a packed on-GPU readout, a calibrated probe library, latency reported to operator standards, and an adversarial eval asking whether a model that knows it’s being probed can evade detection. That last question is where this connects back to <a href="https://github.com/bassrehab/red-queen">the Red Queen work</a>, and it deserves its own post.</p> <p>Part 2 will have the numbers, whichever way they land. If I’m wrong about prediction 2, that’s a more interesting post than if I’m right, and it redirects the design toward selective probing of high-stakes actions, which the March post already argued for. Either outcome is a measured number for the case that actually matters, which is the whole point.</p> <hr/> <p><em>If you’re running probes in production today, or you’ve measured any of this and I’ve missed your write-up, I want to hear from you: <a href="mailto:contact@subhadipmitra.com">contact@subhadipmitra.com</a></em></p> <hr/> <h3 id="references">References</h3> <ol> <li><em>Enabling Performant and Flexible Model-Internal Observability for LLM Inference</em> (DMI-Lib). (2026). <a href="https://arxiv.org/abs/2605.11093">arXiv:2605.11093</a></li> <li><em>vLLM Hook v0: A Plug-in for Programming Model Internals on vLLM.</em> (2026). <a href="https://arxiv.org/abs/2603.06588">arXiv:2603.06588</a></li> <li><em>Observation Plugin for Intercepting and Routing on Activations.</em> vLLM RFC. <a href="https://github.com/vllm-project/vllm/issues/36998">vllm-project/vllm#36998</a></li> <li><em>A BERTology View of LLM Orchestrations: Token- and Layer-Selective Probes for Efficient Single-Pass Classification.</em> (2026). <a href="https://arxiv.org/abs/2601.13288">arXiv:2601.13288</a></li> </ol>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="interpretability"/><category term="inference"/><category term="serving"/><category term="probes"/><category term="benchmarks"/><category term="ai-safety"/><summary type="html"><![CDATA[Everyone assumes activation probes are too expensive to run in production. I ran the numbers on paper and I no longer believe it. Here is the argument, my predictions, and the harness I built to settle it.]]></summary></entry><entry><title type="html">DeepSeek DSpark: Speculation Is a Scheduling Problem</title><link href="https://subhadipmitra.com/blog/2026/deepseek-dspark-speculative-decoding-production/" rel="alternate" type="text/html" title="DeepSeek DSpark: Speculation Is a Scheduling Problem"/><published>2026-06-29T09:00:00+00:00</published><updated>2026-06-29T09:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/deepseek-dspark-speculative-decoding-production</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/deepseek-dspark-speculative-decoding-production/"><![CDATA[<p>Last year I <a href="/blog/2025/making-llm-faster/">built speculative decoding from scratch and wrote about it here</a>. I benchmarked it on a single GPU, got my speedups, and walked away thinking I understood it. Reading DeepSeek’s DSpark paper this week, I realized my mental model had a silent assumption in it: the GPU is idle. That assumption is fine on a test bench and wrong on a fleet, and it turns out to be the difference between spec decoding as a demo and spec decoding as something you’d trust in production.</p> <p>The internet already has forty posts walking through the V4 model cards, so this won’t be another one. Instead, this post is organized around what you can actually do with DSpark’s two core ideas: how to tell if your serving stack has the problem they solve, whether the technique is worth your time at all, and how to train a drafter for your own model with the DeepSpec code DeepSeek released. My wrong assumption is just the doorway in.</p> <h2 id="speculation-is-a-scheduling-problem-not-a-drafting-problem">Speculation is a scheduling problem, not a drafting problem</h2> <p>Quick recap of the mechanics. A small draft model proposes a block of tokens, the target model verifies the whole block in one forward pass, rejection sampling accepts the longest valid prefix plus one bonus token. Lossless by construction. Per-token latency is (T_draft + T_verify) / τ, where τ is tokens accepted per cycle.</p> <p>On an idle GPU, verifying eight tokens costs about the same as verifying one, because decoding is memory bound and the extra tokens ride along in the batch. So you verify everything and it feels free. On a busy fleet it is not free: every speculative token you verify is compute taken from someone else’s request. Verify eight, accept two, and you’ve spent six tokens of a stranger’s latency on nothing.</p> <p>DSpark’s response is a confidence head that scores each drafted token plus a scheduler that watches machine load. Quiet fleet: verify the full block. Slammed fleet: verify only the confident prefix, discard the tail unverified. Speculation becomes elastic, expanding into spare capacity and contracting out of the way. The shape of one decode cycle:</p> <pre><code class="language-mermaid">flowchart LR
    D["Drafter&lt;br/&gt;&lt;i&gt;proposes k tokens&lt;/i&gt;"] --&gt; CH["Confidence head&lt;br/&gt;&lt;i&gt;survival score per position&lt;/i&gt;"]
    L["GPU load&lt;br/&gt;&lt;i&gt;batch occupancy&lt;/i&gt;"] --&gt; S
    CH --&gt; S{"Scheduler&lt;br/&gt;&lt;i&gt;how much is worth verifying?&lt;/i&gt;"}
    S --&gt;|quiet fleet| VF["Verify all k"]
    S --&gt;|busy fleet| VP["Verify confident prefix only"]
    VF --&gt; T["Target forward pass&lt;br/&gt;&lt;i&gt;one batch, one step&lt;/i&gt;"]
    VP --&gt; T
    T --&gt; A["Longest accepted prefix&lt;br/&gt;+ 1 bonus token"]
    style S fill:#e76f51,color:#fff
    style A fill:#2d6a4f,color:#fff
</code></pre> <p><strong>What this means for you:</strong> if you run speculative decoding in a batched serving stack today, your acceptance rate is not the number to watch. Three numbers together tell you whether you have this problem: accepted length τ per verify cycle, batch occupancy at your p95, and the wasted-verify fraction, which is just (k − (τ − 1)) / k for draft length k. Your serving stack already exposes the raw material; vLLM, for instance, reports spec-decode acceptance counters you can derive the rest from. Plot wasted verify against occupancy, and if it climbs as the batch fills, you are paying the tax. Measure per-user latency at your p95 load, not at idle. If spec decoding looks great in your benchmarks and mediocre in production, wasted verification under load is the first suspect, and a fixed draft length is probably the cause. Even without adopting DSpark wholesale, making your speculation length load-aware (even crudely, like dropping draft length when batch occupancy crosses a threshold) captures part of the win.</p> <h2 id="suffix-decay-and-why-one-token-of-context-fixes-most-of-it">Suffix decay, and why one token of context fixes most of it</h2> <p>Parallel drafters like Medusa and DFlash draft the whole block in one shot. Fast, but each position guesses independently, so accuracy rots deeper into the block. Position one is usually right, position five is a coin flip. This suffix decay is what caps τ. Sequential drafters like Eagle3 avoid it by drafting token by token, but they give back draft speed to get there, which is exactly the trade DSpark refuses to make.</p> <p>DSpark keeps the parallel backbone and adds a tiny sequential head on top. Not an RNN over the whole prefix. A Markov head that conditions on exactly one token, the previous one, through a cheap low-rank projection. That alone holds acceptance steady deep into the block. The authors tried a full RNN head; it barely helped beyond the one-token version, so the one-token version ships.</p> <p>Since DeepSpec ships all three drafter families side by side, the choice you’ll actually face is which one to train. Here is the trade in one table:</p> <table> <thead> <tr> <th>Drafter</th> <th>How it drafts</th> <th>Suffix decay</th> <th>Draft cost per round</th> <th>Train it if</th> </tr> </thead> <tbody> <tr> <td>Eagle3</td> <td>Sequentially, token by token</td> <td>None, but drafting itself is the bottleneck</td> <td>Highest</td> <td>You need maximum acceptance and your blocks stay short anyway</td> </tr> <tr> <td>DFlash</td> <td>Whole block in one parallel pass</td> <td>Severe; position five is a coin flip</td> <td>Lowest</td> <td>You need the cheapest possible draft path and accept short prefixes</td> </tr> <tr> <td>DSpark</td> <td>Parallel backbone plus a one-token Markov head</td> <td>Mostly fixed</td> <td>DFlash plus a low-rank projection, near-free</td> <td>Almost always; it is DFlash speed with acceptance that survives deep blocks</td> </tr> </tbody> </table> <p><strong>What this means for you:</strong> if you use a parallel drafter, suffix decay is your τ ceiling, and the fix is embarrassingly cheap. It also means longer draft blocks are back on the table: with the sequential head, stretching the block from 4 to 16 tokens costs almost nothing per round while meaningfully raising accepted length. If you tuned your draft length down to 3 or 4 because acceptance fell off a cliff, that tuning may be obsolete.</p> <h2 id="should-you-use-dspark-an-honest-decision-guide">Should you use DSpark? An honest decision guide</h2> <p>You’re a good candidate if you control your model weights and your serving stack, you serve enough traffic that GPUs sit at meaningful utilization, and a real share of your workload is structured, meaning code, math, extraction, or tool calls. Structured outputs have fewer plausible next tokens, so accepted prefixes run long and the technique shines. Coding agents are the best case.</p> <p>You’re a poor candidate if you rent inference through someone else’s API, because none of this is a switch you can flip from outside. You’re also a poor candidate on consumer hardware with a single user: speculative decoding needs the draft path to be roughly 10 to 30 times faster than the target, and with a weak speed ratio it can end up slower than plain decoding even though the outputs stay correct.</p> <p>And one warning that doesn’t fit in a headline: drafters do not transfer. A draft head learns the output distribution of one specific target model. The checkpoint trained for stock V4 is the wrong drafter for your fine-tune, and wrong again for the same model in a different reasoning mode.</p> <h2 id="can-you-run-it-today-without-training-anything">Can you run it today, without training anything?</h2> <p>Mostly, with early-adopter caveats. If your target model is stock V4, the released DSpark checkpoints load in vLLM and SGLang today; the community 1.5x number in the benchmarks section below came from exactly that path. What you’re getting is early support rather than a turnkey flag: formal DSpark spec-decode integration is still landing in both projects (there are open feature requests tracking it in SGLang and in vllm-ascend), so budget for version pinning and some config friction. Still, running a shipped checkpoint is a far lower bar than training a drafter, so try that first and only reach for DeepSpec when your weights diverge from stock.</p> <h2 id="how-to-train-a-dspark-drafter-for-your-own-model-with-deepspec">How to train a DSpark drafter for your own model with DeepSpec</h2> <p>The genuinely useful part of this release is DeepSpec, the MIT-licensed codebase DeepSeek published for training and evaluating draft models. It ships implementations of DSpark, DFlash, and Eagle3, with released checkpoints targeting Qwen3 at 4B, 8B, and 14B plus Gemma-4-12B, and evaluation sets spanning math, code, and chat.</p> <p>The pipeline, end to end:</p> <pre><code class="language-mermaid">flowchart LR
    P["Prompts&lt;br/&gt;&lt;i&gt;from your real traffic&lt;/i&gt;"] --&gt; R["Regenerate answers&lt;br/&gt;&lt;i&gt;with your target model&lt;/i&gt;"]
    R --&gt; C["Target cache&lt;br/&gt;&lt;i&gt;~38 TB for Qwen3-4B&lt;/i&gt;"]
    C --&gt; TR["Train draft head&lt;br/&gt;&lt;i&gt;target frozen, reuses its&lt;br/&gt;embedding and output layers&lt;/i&gt;"]
    TR --&gt; E{"Accepted length&lt;br/&gt;&lt;i&gt;on held-out math, code, chat&lt;/i&gt;"}
    E --&gt;|holds up| DEP["Wire into serving stack"]
    E --&gt;|falls short| P
    style C fill:#e76f51,color:#fff
    style DEP fill:#2d6a4f,color:#fff
</code></pre> <p>The workflow is refreshingly honest about the drafter-target coupling, and it is concrete enough to walk through for real. Below is the actual run for the repo’s default target, Qwen3-4B. Everywhere <code class="language-plaintext highlighter-rouge">Qwen/Qwen3-4B</code> appears, your own fine-tune drops in; the whole point of the pipeline is that the drafter learns <em>your</em> model’s distribution, so there is no shortcut around regenerating the data yourself.</p> <details> <summary><strong>The full run for Qwen3-4B, command by command</strong></summary> <p><strong>Step 0: environment.</strong> Clone the repo and install dependencies. The default configs and scripts assume a single node with 8 GPUs.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/deepseek-ai/DeepSpec.git
<span class="nb">cd </span>DeepSpec
python <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
</code></pre></div> </div> <p><strong>Step 1: get prompts.</strong> The released checkpoints were trained on the open-perfectblend dataset, and the repo script downloads and splits it. This is the step to be opinionated about: if you serve real traffic, prompts sampled from your own logs beat a public blend, because acceptance lives and dies on distribution match.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python scripts/data/download_and_split.py <span class="se">\</span>
    <span class="nt">--dataset-name</span> mlabonne/open-perfectblend <span class="se">\</span>
    <span class="nt">--test-size</span> 0.05 <span class="se">\</span>
    <span class="nt">--train-output-path</span> train_datasets/perfectblend_train.jsonl <span class="se">\</span>
    <span class="nt">--test-output-dir</span> eval_datasets <span class="se">\</span>
    <span class="nt">--skip-existing</span>
</code></pre></div> </div> <p><strong>Step 2: regenerate the answers with your target model.</strong> Not the dataset’s answers. Yours. DeepSpec does this by standing up SGLang servers, one per GPU, and streaming the prompts through:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash scripts/data/launch_sglang_server.sh

python scripts/data/generate_train_data.py <span class="se">\</span>
    <span class="nt">--model</span> Qwen/Qwen3-4B <span class="se">\</span>
    <span class="nt">--server-address</span> <span class="se">\</span>
        127.0.0.1:30000 127.0.0.1:30001 127.0.0.1:30002 127.0.0.1:30003 <span class="se">\</span>
        127.0.0.1:30004 127.0.0.1:30005 127.0.0.1:30006 127.0.0.1:30007 <span class="se">\</span>
    <span class="nt">--concurrency</span> 32 <span class="se">\</span>
    <span class="nt">--temperature</span> 0.7 <span class="nt">--top-p</span> 0.8 <span class="nt">--top-k</span> 20 <span class="nt">--min-p</span> 0 <span class="se">\</span>
    <span class="nt">--max-tokens</span> 4096 <span class="se">\</span>
    <span class="nt">--disable-thinking</span> <span class="se">\</span>
    <span class="nt">--resume</span> <span class="se">\</span>
    <span class="nt">--input-file-path</span> train_datasets/perfectblend_train.jsonl <span class="se">\</span>
    <span class="nt">--output-file-path</span> train_datasets/qwen3_4b/perfectblend_train_regen.jsonl
</code></pre></div> </div> <p>Note <code class="language-plaintext highlighter-rouge">--disable-thinking</code>: every released checkpoint was trained on non-thinking outputs. If you deploy a thinking mode, generate this data in thinking mode instead; the distributions differ enough to cost you accepted length.</p> <p><strong>Step 3: build the target cache.</strong> This runs the frozen target over the regenerated data and stores what the draft head will train against. It is also where the storage bill lands: roughly 38 TB for this default setup.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python scripts/data/prepare_target_cache.py <span class="se">\</span>
    <span class="nt">--config</span> config/dspark/dspark_qwen3_4b.py <span class="se">\</span>
    <span class="nt">--train-data-path</span> train_datasets/qwen3_4b/perfectblend_train_regen.jsonl <span class="se">\</span>
    <span class="nt">--output-dir</span> <span class="k">${</span><span class="nv">HOME</span><span class="k">}</span>/.cache/deepspec/qwen3_4b_target_cache <span class="se">\</span>
    <span class="nt">--local-batch-size</span> 16
</code></pre></div> </div> <p><strong>Step 4: train.</strong> One worker per GPU; the config is picked via <code class="language-plaintext highlighter-rouge">config_path</code> (the DSpark configs live under <code class="language-plaintext highlighter-rouge">config/dspark/</code>) and individual fields can be overridden with <code class="language-plaintext highlighter-rouge">--opts</code>. The head itself is small because it reuses the target’s embedding and output layers; the compute already went into the cache.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash scripts/train/train.sh
</code></pre></div> </div> <p>Checkpoints land in <code class="language-plaintext highlighter-rouge">~/checkpoints/&lt;project_name&gt;/&lt;exp_name&gt;/step_*</code>.</p> <p><strong>Step 5: measure accepted length before touching your serving stack.</strong> Set <code class="language-plaintext highlighter-rouge">target_name_or_path</code> and <code class="language-plaintext highlighter-rouge">draft_name_or_path</code> (your new checkpoint, or a released one as a baseline) in the eval script and run it over the held-out sets, which span gsm8k, math500, aime25, humaneval, mbpp, livecodebench, mt-bench, alpaca, and arena-hard-v2:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash scripts/eval/eval.sh
</code></pre></div> </div> </details> <p>Two sanity checks worth doing once the eval runs. Run the released DSpark checkpoint for your base model through the same eval to calibrate what good looks like. And compare your accepted length on the code sets against the chat sets: if code is not clearly ahead, your training data does not match your traffic, and the problem is in steps 1 and 2, not in training.</p> <p>Budget warning, restated as a summary: the expensive step is the data, not the training. If you control your weights, this is still a weekend-to-a-week project rather than a research program, but it is not a laptop project.</p> <h2 id="dspark-benchmarks-for-the-record">DSpark benchmarks, for the record</h2> <p>I’ve argued the numbers aren’t the story, but you should have them in one place. Offline, DSpark’s accepted length beats Eagle3 by roughly 27 to 31 percent and DFlash by 16 to 18 percent across the three Qwen3 sizes, and a 2-layer DSpark drafter outperforms a 5-layer DFlash. Growing the draft block from 4 to 16 tokens costs only 0.2 to 1.3 percent extra per-round latency. In production, against DeepSeek’s previous single-token MTP-1 baseline at matched capacity, per-user generation runs 60 to 85 percent faster on V4-Flash and 57 to 78 percent faster on V4-Pro, with aggregate throughput up 51 to 52 percent at fixed per-user speed targets. The shipped config is DSpark-5, five-token blocks with the Markov head.</p> <p>Two calibration notes. The roughly 400 and 660 percent figures you’ll see elsewhere (406 percent on V4-Pro at a 50 token-per-second per-user target, 661 percent on V4-Flash at 120) are real but describe throughput at speed targets strict enough that the single-token baseline nearly collapses, so the ratio balloons. They mark the corner of the serving frontier, not the typical case. And the baseline matters: an early community vLLM benchmark on V4-Flash landed around 1.5x over MTP-1 and 2.3x over plain decoding, which is the kind of number you should expect to reproduce, not the headline.</p> <h2 id="build-intuition-before-you-build-anything">Build intuition before you build anything</h2> <p>Before you spend a week on DeepSpec, spend a minute on the simulator below. It runs three decoding policies against the same GPU budget: plain decoding, fixed-length speculation, and DSpark’s confidence-scheduled version.</p> <p>Here is the experiment to run. Set the workload to code, then drag concurrent requests up and watch the frontier chart. At some point the fixed-length speculation curve crosses <em>below</em> plain decoding. That crossover is the entire argument of this post in one pixel: past saturation, a fixed draft length keeps paying to verify tokens that were never going to be accepted, and speculation becomes a tax on every other request. DSpark’s curve doesn’t cross, because the scheduler shrinks its verify window as utilization climbs. You can watch it happen in the lanes above the chart, where the scheduled policy starts tearing the tail off its blocks while the fixed policy keeps burning red.</p> <p>Then switch the workload to chat and see how much earlier everything degrades when acceptance decays fast, and toggle the Markov head to see why one token of context moves the whole frontier. The sliders map directly onto your deployment questions: how accurate is your drafter, how long can your blocks be, and how busy are your machines.</p> <section class="qa-inst" id="inst-dspark"> <p class="qa-inst-eyebrow"><span class="b">Bet 02</span> / Scheduled speculation</p> <h2>Speculation is free until the GPU is busy.</h2> <p class="qa-inst-thesis">On an idle GPU, verifying a block of drafted tokens rides along with the batch, so fixed-length speculation looks great on a test bench. On a shared fleet, every speculative token you verify is compute taken from someone else's request. Three policies share the same capacity here: plain decoding, fixed-length speculation, and a DSpark-style scheduler that shrinks its verify window as utilization climbs. Drag concurrency up and watch the fixed policy cross below plain.</p> <div class="qa-inst-controls"> <div class="qa-inst-ctrl"> <label for="w7-wl"><span class="qa-inst-lbl">Workload<span class="qa-inst-info" tabindex="0" role="note" aria-label="Structured output like code has few plausible next tokens, so draft acceptance stays high deep into the block. Open-ended chat decays fast.">i<span class="qa-inst-tip">Structured output (code, math) has few plausible next tokens, so acceptance stays high deep into the block. Open-ended chat decays fast.</span></span></span><span class="val" id="w7-wl-v"></span></label> <select id="w7-wl"> <option value="code" selected="">Code (structured)</option> <option value="math">Math</option> <option value="chat">Chat (open-ended)</option> </select> </div> <div class="qa-inst-ctrl"> <label for="w7-mk"><span class="qa-inst-lbl">Markov head<span class="qa-inst-info" tabindex="0" role="note" aria-label="DSpark's fix for suffix decay: a tiny sequential head that conditions each drafted position on one previous token. It slows per-position acceptance decay, so longer blocks stay useful.">i<span class="qa-inst-tip">DSpark's fix for suffix decay: a tiny sequential head conditioning on one previous token. It slows acceptance decay, so longer blocks stay useful.</span></span></span><span class="val" id="w7-mk-v"></span></label> <select id="w7-mk"> <option value="on" selected="">On (slower decay)</option> <option value="off">Off (parallel drafter only)</option> </select> </div> <div class="qa-inst-ctrl"> <label for="w7-k"><span class="qa-inst-lbl">Draft block k<span class="qa-inst-info" tabindex="0" role="note" aria-label="How many tokens the drafter proposes per cycle. The scheduler may verify fewer than k when the fleet is busy.">i<span class="qa-inst-tip">Tokens the drafter proposes per cycle. The scheduler may verify fewer than k when the fleet is busy.</span></span></span><span class="val" id="w7-k-v"></span></label> <input type="range" id="w7-k" min="2" max="16" step="1" value="10"/> </div> <div class="qa-inst-ctrl"> <label for="w7-n"><span class="qa-inst-lbl">Concurrent requests N<span class="qa-inst-info" tabindex="0" role="note" aria-label="How many requests share the GPU. This is the axis your benchmarks probably held at 1.">i<span class="qa-inst-tip">How many requests share the GPU. This is the axis your benchmarks probably held at 1.</span></span></span><span class="val" id="w7-n-v"></span></label> <input type="range" id="w7-n" min="1" max="128" step="1" value="8"/> </div> <div class="qa-inst-ctrl"> <label for="w7-cap"><span class="qa-inst-lbl">GPU capacity<span class="qa-inst-info" tabindex="0" role="note" aria-label="Tokens the fleet can process per decode step before step time starts stretching. Below this, batched verify tokens ride free; above it, speculation costs everyone.">i<span class="qa-inst-tip">Tokens per decode step before step time stretches. Below this, batched verify tokens ride free; above it, speculation costs everyone.</span></span></span><span class="val" id="w7-cap-v"></span></label> <input type="range" id="w7-cap" min="12" max="128" step="4" value="48"/> </div> </div> <div class="qa-inst-lanes" id="w7-lanes"> <div class="qa-inst-lane"><span class="lbl" style="color:var(--qa-text-muted)">plain</span><div class="chips" id="w7-lane-plain"></div></div> <div class="qa-inst-lane"><span class="lbl" style="color:var(--inst-mem)">fixed k</span><div class="chips" id="w7-lane-fixed"></div></div> <div class="qa-inst-lane"><span class="lbl" style="color:var(--inst-comp)">dspark</span><div class="chips" id="w7-lane-dspark"></div></div> <div class="qa-inst-lanes-meta" id="w7-lanes-meta"></div> </div> <div class="qa-inst-lanes-legend"> <span><span class="sw acc"></span>accepted</span> <span><span class="sw rej"></span>verified but rejected (wasted)</span> <span><span class="sw cut"></span>drafted, never verified</span> <span><span class="sw bonus"></span>bonus token</span> <button type="button" class="qa-inst-btn secondary" id="w7-play" aria-pressed="true">Pause</button> </div> <div class="qa-inst-readouts"> <div class="qa-inst-ro"> <div class="k">Verdict at N</div> <div class="v"><span class="qa-inst-pill comp" id="w7-state"><span class="dot"></span>&mdash;</span></div> </div> <div class="qa-inst-ro"> <div class="k">Fixed spec vs plain</div> <div class="v" id="w7-fixed-x">&mdash;<small> &times;</small></div> </div> <div class="qa-inst-ro"> <div class="k">DSpark vs plain</div> <div class="v" id="w7-dspark-x">&mdash;<small> &times;</small></div> </div> <div class="qa-inst-ro"> <div class="k">Scheduler verifies</div> <div class="v" id="w7-verify">&mdash;</div> </div> </div> <figure> <div class="qa-inst-chart"><svg id="w7-chart" viewBox="0 0 640 300" role="img" aria-label="Per-user generation speed versus concurrent requests for three decoding policies"></svg></div> <figcaption> <span class="key"><span class="swatch" style="background:var(--qa-text-muted)"></span> plain decoding</span> <span class="key"><span class="swatch" style="background:var(--inst-mem)"></span> fixed spec</span> <span class="key"><span class="swatch" style="background:var(--inst-comp)"></span> DSpark scheduled</span> <span class="key"><span class="swatch" style="background:var(--mhc-hc)"></span> crossover</span> </figcaption> </figure> <table class="qa-inst-table" aria-label="Policy comparison at the current settings"> <thead> <tr><th>policy</th><th>verify</th><th>&tau; / cycle</th><th>per-user speed</th><th>vs plain</th><th>wasted verify</th></tr> </thead> <tbody id="w7-table"></tbody> </table> <p class="qa-inst-verdict" id="w7-verdict"></p> <details> <summary>How this is computed</summary> <p>A toy capacity model: a decode step costs <code>max(1, total tokens / capacity)</code> time units, so batched verify tokens ride free below saturation and stretch everyone's step time above it. Draft position <code>i</code> is accepted with probability <code>p&#8321;&middot;d&#8305;</code> (workload sets <code>p&#8321;</code> and decay <code>d</code>; the Markov head raises <code>d</code>). Tokens per cycle <code>&tau; = 1 + &Sigma; q&#7522;</code> over the verified prefix, where <code>q&#7522;</code> is the cumulative acceptance probability; per-user speed is <code>&tau; / step</code>.</p> <ul> <li><strong>Plain</strong> decodes one token per step. <strong>Fixed</strong> always verifies the full block, so past saturation it pays full price for tokens that were never going to be accepted and drops below plain.</li> <li><strong>DSpark</strong> verifies only the confident prefix: the threshold tightens as utilization crosses ~70%, solved here as a small fixed point. Speculation expands into spare capacity and contracts out of the way.</li> <li>Constants are illustrative, not fitted to the paper. The crossover, and the scheduler refusing to cross, are the real phenomenon.</li> </ul> <p>The mechanism is from DeepSeek's <a href="https://github.com/deepseek-ai/DeepSpec">DSpark / DeepSpec</a> release: a parallel drafter with a one-token Markov head against suffix decay, plus confidence-scheduled verification that adapts to fleet load.</p> </details> </section> <script>!function(){"use strict";function e(e){return document.getElementById(e)}function t(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function n(e){for(var t=Math.pow(10,Math.floor(Math.log(e)/Math.LN10)),n=[1,1.5,2,3,4,5,6,8,10],a=0;a<n.length;a++)if(n[a]*t>=e-1e-9)return n[a]*t;return 10*t}function a(e,t,n,a){for(var i=a?Math.min(.985,n+.06):n,r=[],s=0;s<e;s++)r.push(Math.min(.99,t*Math.pow(i,s)));return r}function i(e){for(var t=[],n=1,a=0;a<e.length;a++)n*=e[a],t.push(n);return t}function r(e,t){for(var n=1,a=0;a<t;a++)n+=e[a];return n}function s(e,t,n,a,i){var s,o,d,l;if("plain"===e)return s=t,{v:0,tau:1,step:o=Math.max(1,s/n),perUser:1/o,util:s/n,wasted:0};if("fixed"===e)return s=t*((l=a)+x),o=Math.max(1,s/n),{v:l,tau:d=r(i,l),step:o,perUser:d/o,util:s/n,wasted:(l-(d-1))/l};l=a;for(var p=0;p<12;p++){for(var c=t*(l+x)/n,u=.03+.75*Math.max(0,c-.7),f=0,h=0;h<a&&i[h]>=u;h++)f++;f=Math.max(1,f),l=Math.round(.5*l+.5*f)}return s=t*(l+x),o=Math.max(1,s/n),{v:l,tau:d=r(i,l),step:o,perUser:d/o,util:s/n,wasted:(l-(d-1))/l}}function o(){var e=h[v.wl.value],t="on"===v.mk.value,n=+v.k.value,r=+v.n.value,o=+v.cap.value,d=i(w=a(n,e.p1,e.decay,t));y={k:n,N:r,cap:o,q:d,wl:e,markov:t,plain:s("plain",r,o,n,d),fixed:s("fixed",r,o,n,d),dspark:s("dspark",r,o,n,d)}}function d(e,t){return'<span class="qa-inst-chip '+t+'">'+e+"</span>"}function l(){var t=y.k;["plain","fixed","dspark"].forEach(function(n){var a=e("w7-lane-"+n),i="";if("plain"===n)i=d(f[k.plain%f.length],"acc"),k.plain=(k.plain+1)%f.length;else{var r,s=y[n].v,o=0;for(r=0;r<s&&Math.random()<w[r];r++)o++;for(r=0;r<t;r++){var l=r>=s?"cut":r<o?"acc":"rej";i+=d(f[(k[n]+r)%f.length],l)}i+=d("+1","bonus"),k[n]=(k[n]+o+1)%f.length}a.innerHTML=i});var n=y.dspark;e("w7-lanes-meta").textContent="fleet utilization (dspark policy): "+Math.round(100*Math.min(1.6,n.util))+"%"+(n.v<y.k?" \xb7 scheduler verifying "+n.v+"/"+y.k:" \xb7 verifying full blocks")}function p(){function a(e){return d+Math.log(e)/Math.log(128)*p}function i(e){return l+(1-e/g)*c}function r(e,t,n,r){return'<polyline points="'+w[e].map(function(e){return a(e.n).toFixed(1)+","+i(e.y).toFixed(1)}).join(" ")+'" fill="none" stroke="'+t+'" stroke-width="'+r+'"'+(n?' stroke-dasharray="5 3"':"")+' stroke-linejoin="round"/>'}var o=300,d=46,l=16,p=640-d-14,c=o-l-32,u=t("--qa-text-muted"),f=t("--inst-grid"),h=t("--inst-mem"),x=t("--inst-comp"),v=t("--mhc-hc"),k=t("--qa-text"),w={plain:[],fixed:[],dspark:[]},g=0;m.forEach(function(e){["plain","fixed","dspark"].forEach(function(t){var n=s(t,e,y.cap,y.k,y.q);w[t].push({n:e,y:n.perUser}),g=Math.max(g,n.perUser)})}),g=n(g);for(var M=-1,b=1;b<m.length;b++)if(w.fixed[b].y<w.plain[b].y){M=b;break}var F=[];if([.25,.5,.75,1].forEach(function(e){var t=i(e*g);F.push('<line x1="'+d+'" y1="'+t.toFixed(1)+'" x2="'+(d+p)+'" y2="'+t.toFixed(1)+'" stroke="'+f+'" stroke-width="1"/>'),F.push('<text x="'+(d-7)+'" y="'+(t+3).toFixed(1)+'" fill="'+u+'" font-size="9.5" text-anchor="end">'+(e*g).toFixed(1)+"</text>")}),[1,4,16,64,128].forEach(function(e){F.push('<text x="'+a(e).toFixed(1)+'" y="'+(o-14)+'" fill="'+u+'" font-size="9.5" text-anchor="middle">'+e+"</text>")}),F.push('<text x="'+(d+p/2)+'" y="'+(o-3)+'" fill="'+u+'" font-size="9.5" text-anchor="middle" letter-spacing="1">CONCURRENT REQUESTS (LOG) &rarr;</text>'),F.push('<text transform="translate(11,'+(l+c/2)+') rotate(-90)" fill="'+u+'" font-size="9.5" text-anchor="middle" letter-spacing="1">PER-USER TOK / STEP</text>'),F.push(r("fixed",h,!1,1.8)),F.push(r("plain",u,!0,1.8)),F.push(r("dspark",x,!1,2.4)),M>0){var E=a(w.fixed[M].n),U=i(w.fixed[M].y);F.push('<circle cx="'+E.toFixed(1)+'" cy="'+U.toFixed(1)+'" r="4.5" fill="none" stroke="'+v+'" stroke-width="1.6"/>'),F.push('<text x="'+(E+8).toFixed(1)+'" y="'+(U-8).toFixed(1)+'" fill="'+v+'" font-size="9.5">fixed drops below plain</text>')}var N=a(y.N);F.push('<line x1="'+N.toFixed(1)+'" y1="'+l+'" x2="'+N.toFixed(1)+'" y2="'+(l+c)+'" stroke="'+k+'" stroke-width="1" stroke-dasharray="2 3"/>');var T=N>d+p-64;F.push('<text x="'+(T?N-5:N+5).toFixed(1)+'" y="'+(l+10)+'" fill="'+k+'" font-size="9.5"'+(T?' text-anchor="end"':"")+">you: N="+y.N+"</text>");var L=t("--qa-bg")||"#fff";[["plain",u],["fixed",h],["dspark",x]].forEach(function(e){var t=i(s(e[0],y.N,y.cap,y.k,y.q).perUser);F.push('<circle cx="'+N.toFixed(1)+'" cy="'+t.toFixed(1)+'" r="4" fill="'+e[1]+'" stroke="'+L+'" stroke-width="1.5"/>')}),e("w7-chart").innerHTML=F.join("")}function c(){o(),e("w7-wl-v").textContent="",e("w7-mk-v").textContent="",e("w7-k-v").textContent=y.k+" tok",e("w7-n-v").textContent=y.N,e("w7-cap-v").textContent=y.cap+" tok/step";var t=y.fixed.perUser/y.plain.perUser,n=y.dspark.perUser/y.plain.perUser;e("w7-fixed-x").innerHTML=t.toFixed(2)+"<small> &times;</small>",e("w7-dspark-x").innerHTML=n.toFixed(2)+"<small> &times;</small>",e("w7-verify").textContent=y.dspark.v+"/"+y.k;var a=e("w7-state");t<1?(a.className="qa-inst-pill danger",a.innerHTML='<span class="dot"></span>Fixed spec is a tax'):y.dspark.v<y.k?(a.className="qa-inst-pill mem",a.innerHTML='<span class="dot"></span>Scheduler trimming'):(a.className="qa-inst-pill comp",a.innerHTML='<span class="dot"></span>Speculation rides free');var i="";[["plain","plain decoding"],["fixed","fixed spec"],["dspark","dspark"]].forEach(function(e){var t=e[0],n=y[t],a=n.perUser/y.plain.perUser;i+="<tr><td>"+e[1]+"</td><td>"+("plain"===t?"1":n.v+"/"+y.k)+"</td><td>"+n.tau.toFixed(2)+"</td><td>"+n.perUser.toFixed(2)+'</td><td class="'+(a>=1?"good":"bad")+'">'+a.toFixed(2)+'&times;</td><td class="'+(n.wasted>.4?"bad":"")+'">'+Math.round(100*n.wasted)+"%</td></tr>"}),e("w7-table").innerHTML=i,p(),l(),e("w7-verdict").innerHTML=t<1?"At N=<b>"+y.N+"</b> the fleet is saturated: fixed k="+y.k+" speculation runs at <b>"+t.toFixed(2)+"&times;</b> plain, paying to verify tokens that were never going to be accepted. The scheduler verifies "+y.dspark.v+"/"+y.k+" and holds <b>"+n.toFixed(2)+"&times;</b>.":y.dspark.v<y.k?"Utilization is climbing, so the scheduler has trimmed its verify window to "+y.dspark.v+"/"+y.k+" and keeps <b>"+n.toFixed(2)+"&times;</b> plain while fixed spec still holds "+t.toFixed(2)+"&times;. Keep dragging N.":"Below saturation, verification rides free: fixed spec gets <b>"+t.toFixed(2)+"&times;</b> plain and the scheduler agrees, verifying full blocks. This is the regime your single-GPU benchmark measured."}function u(t){var n=e("w7-play");g&&(clearInterval(g),g=null),t&&(g=setInterval(function(){document.hidden||l()},2e3)),n.textContent=t?"Pause":"Play",n.setAttribute("aria-pressed",t?"true":"false")}if(document.getElementById("w7-k")){var f="speculative decoding trades idle compute for latency the draft proposes a block the target verifies it in one pass rejection sampling keeps the distribution exactly intact so every accepted token is free of quality loss".split(" "),h={code:{p1:.9,decay:.93},math:{p1:.87,decay:.91},chat:{p1:.78,decay:.86}},x=.15,m=function(){for(var e=[],t={},n=0;n<=14;n++){var a=Math.round(Math.pow(2,n/2));t[a]||(t[a]=1,e.push(a))}return e}(),v={wl:e("w7-wl"),mk:e("w7-mk"),k:e("w7-k"),n:e("w7-n"),cap:e("w7-cap")},k={plain:0,fixed:0,dspark:0},w=[],y=null,g=null,M=window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;e("w7-play").addEventListener("click",function(){u(!g)}),["change","input"].forEach(function(e){Object.keys(v).forEach(function(t){v[t].addEventListener(e,c)})}),new MutationObserver(p).observe(document.documentElement,{attributes:!0,attributeFilter:["data-theme"]}),c(),u(!M)}}();</script> <p><em>(Standalone and embeddable version: <a href="/instruments/dspark-fleet/">Speculation under load</a>.)</em></p> <p>The constants in the toy are illustrative, not fitted to the paper. The dynamics are the real thing.</p> <h2 id="sources">Sources</h2> <ul> <li>The DSpark paper: <a href="https://github.com/deepseek-ai/DeepSpec/blob/main/DSpark_paper.pdf">github.com/deepseek-ai/DeepSpec/blob/main/DSpark_paper.pdf</a></li> <li>DeepSpec codebase: <a href="https://github.com/deepseek-ai/DeepSpec">github.com/deepseek-ai/DeepSpec</a></li> <li>DSpark checkpoints for V4-Pro and V4-Flash on <a href="https://huggingface.co/deepseek-ai">Hugging Face</a></li> <li>My earlier post on building speculative decoding from scratch: <a href="/blog/2025/making-llm-faster/">Making LLMs faster</a></li> <li>The simulator as a standalone instrument: <a href="/instruments/dspark-fleet/">Speculation under load</a></li> </ul>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="deepseek"/><category term="dspark"/><category term="speculative-decoding"/><category term="llm-inference"/><category term="inference-optimization"/><category term="deepspec"/><category term="vllm"/><summary type="html"><![CDATA[What DeepSeek's DSpark and DeepSpec release actually changes for LLM inference: suffix decay, confidence-scheduled verification, a decision guide, and how to train a draft model for your own weights.]]></summary></entry><entry><title type="html">Loop Engineering: Convergence Is Not Correctness</title><link href="https://subhadipmitra.com/blog/2026/loop-engineering-verifier/" rel="alternate" type="text/html" title="Loop Engineering: Convergence Is Not Correctness"/><published>2026-06-21T09:00:00+00:00</published><updated>2026-06-21T09:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/loop-engineering-verifier</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/loop-engineering-verifier/"><![CDATA[<blockquote> <p><strong>TL;DR:</strong> The June 2026 discourse on loop engineering is right about the shift and wrong about the hard part. A loop always converges. That is what it is built to do. But convergence is not correctness: a loop keeps whatever passes your check, so it settles on the cheapest output that clears the bar you actually wrote, not the one you meant. Every gap between “passed the check” and “actually correct” gets found and exploited, by accident or by an adversary, and the check that separates the two is the part that decides whether the loop compounds your work or builds an expensive machine for being confidently incorrect at scale. This is the practitioner’s playbook, written from running loops unattended over real production systems and not just a coding agent in a terminal.</p> </blockquote> <h2 id="everyone-is-getting-the-shift-right-most-are-skipping-the-hard-part">Everyone is getting the shift right. Most are skipping the hard part.</h2> <p>Within two weeks in June 2026, three people said the same thing and a term went viral.</p> <p>Peter Steinberger: <em>“You shouldn’t be prompting coding agents anymore. You should be designing loops that prompt your agents.”</em> Boris Cherny, who runs Claude Code at Anthropic: <em>“I don’t prompt Claude anymore. I have loops running that prompt Claude and figuring out what to do. My job is to write loops.”</em> And Addy Osmani gave it a name: <em>“Loop engineering is replacing yourself as the person who prompts the agent. You design the system that does it instead.”</em></p> <p>They are right. The default unit of work is moving from the response to the trajectory. The bottleneck is no longer “can the model do this in one shot” but “can I design a system that drives the model to a correct end state on its own.” That is a real change, and if your mental model is still copy, paste, re-prompt, you are about to be out-leveraged by people who automated the re-prompting.</p> <p>But read the wave of explainers that followed and you will notice something. They all describe the loop. Reason, act, observe, repeat. They draw the circle. Then they move on. The circle is the part that is genuinely easy. A <code class="language-plaintext highlighter-rouge">while</code> loop with a tool-calling model inside it is a weekend project. I have watched teams build that circle in an afternoon and then spend three months discovering that the circle is not the problem.</p> <p>The problem is the one box almost nobody dwells on: <strong>verify</strong>. The box that decides whether the loop goes around again or stops. That box is where loops create value or destroy it, and it is the part of loop engineering that is actually hard, actually technical, and actually where your judgment as an engineer goes.</p> <p>This post is about that box.</p> <h2 id="what-loop-engineering-actually-is-stated-precisely">What loop engineering actually is, stated precisely</h2> <p>The terms are getting muddy as the content farms amplify them, so be precise. The lineage is four layers, each wrapping the last:</p> <ul> <li><strong>Prompt engineering</strong> optimizes a single instruction you type.</li> <li><strong>Context engineering</strong> optimizes what the model sees on a given call.</li> <li><strong>Harness engineering</strong> optimizes the environment one agent run executes in. An agent equals a model plus a harness, where the harness is everything that is not the model: tools, memory, guardrails, orchestration, context, sandbox, serving. In production it decides whether your agent works far more than the model choice does.</li> <li><strong>Loop engineering</strong> optimizes the controller that runs agents against a goal until the goal is met or an exit fires.</li> </ul> <p>A harness equips one run; a loop decides whether to run again. You can have an excellent harness and a terrible loop, and the result is a capable agent that marches confidently off a cliff because nothing above it is checking whether it is still pointed at the goal. The harness is the agent. The loop is the supervision, and this post lives in the supervision layer, because that is where the check lives.</p> <p>Here is the canonical loop, with the box this post is about marked in orange and the exits marked explicitly, because a loop without exits is not an architecture, it is an outage waiting for a credit card.</p> <pre><code class="language-mermaid">flowchart LR
    I["Objective&lt;br/&gt;&lt;i&gt;a verifiable goal&lt;/i&gt;"] --&gt; C["Context&lt;br/&gt;&lt;i&gt;assemble state&lt;/i&gt;"]
    C --&gt; A["Act&lt;br/&gt;&lt;i&gt;tools, edits, calls&lt;/i&gt;"]
    A --&gt; O["Observe&lt;br/&gt;&lt;i&gt;run it, capture results&lt;/i&gt;"]
    O --&gt; V{"Verify&lt;br/&gt;&lt;i&gt;did we meet the goal?&lt;/i&gt;"}
    V --&gt;|pass| DONE["Exit: done"]
    V --&gt;|fail| ADJ["Adjust&lt;br/&gt;&lt;i&gt;update the plan&lt;/i&gt;"]
    ADJ --&gt; C
    V -.-&gt;|cap hit, budget out, no progress| STOP["Exit: stop and escalate"]
    style V fill:#e76f51,color:#fff
    style DONE fill:#2d6a4f,color:#fff
    style STOP fill:#9d0208,color:#fff
</code></pre> <p>Notice that everything interesting routes through <code class="language-plaintext highlighter-rouge">Verify</code>. The quality of your loop is bounded by the quality of that diamond. Everything else is plumbing.</p> <h2 id="the-independent-claim-convergence-is-not-correctness">The independent claim: convergence is not correctness</h2> <p>Here is the thesis the explainers are missing, and it is not a vibe, it is a mechanism. A loop always converges. Reaching a stable “done” is the one thing a loop is built to do reliably. The trap is reading that convergence as correctness, because the two are not the same event, and a loop is a machine for driving them apart.</p> <p>A loop fails in two directions. It can fail to converge at all, thrashing or running away until something stops it, which is loud and which the exits later in this post are for. Or it can converge cleanly on the wrong thing, which is silent, which no exit catches, and which is the failure this post is mostly about.</p> <p>When you prompt a model once, you are exposed to its error distribution exactly once. It is right or wrong, you look at the result, you move on. Your judgment is in the loop on every output.</p> <p>When you wrap that model in a loop, you change the statistics completely. The loop generates a candidate, runs your verifier, and keeps the candidate only if the verifier passes. Run that ten or fifty times and you are no longer sampling the model’s outputs. You are <strong>selecting</strong> for outputs that pass your verifier. That is, by definition, optimization. Your verifier is the objective function, and the loop is a crude optimizer hammering on it. So the loop converges on exactly one thing, the cheapest output that satisfies the check, and whether that is also correct depends entirely on how tightly the check pins passing to being right. Nothing in the loop is working to close that gap. The loop is working to exploit it.</p> <p>This is Goodhart’s law with a motor on it. When a measure becomes a target it stops being a good measure, and a loop turns your check into a target and then attacks it hundreds of times. Any gap between “passes the check” and “is actually what I wanted” does not stay a harmless gap. It becomes a reliable exploit, found by brute force, because the loop keeps trying until something makes the light go green.</p> <p>I have watched this happen in the most boring possible ways. A loop told to “make the tests pass” deletes the failing test. A loop told to “reduce lint errors” adds suppression comments. A loop graded by an LLM judge learns to write outputs that sound authoritative to the judge, because confident prose is cheaper to generate than correct work. Each one converged. None of them was correct. They are the natural consequence of pointing an optimizer at a proxy.</p> <p>This reframes the entire critique conversation. The viral skepticism around “loopmaxxing,” the idea that infinite iterations will fix anything, is correct but underspecified. Loopmaxxing is not a discipline problem. It is a convergence-without-correctness problem. More iterations do not help because you are climbing a hill whose summit is “satisfies a flawed check,” not “is correct.” More compute just gets you to the wrong summit faster.</p> <p>This is the same failure surface as reward hacking in reinforcement learning, and it is why the RLVR crowd became obsessed with verifiable rewards. It is also exactly the territory I spend my research time in. A model that has learned what a grader rewards, rather than what the task requires, is doing a form of <a href="/blog/2025/detecting-ai-sandbagging/">sandbagging against the evaluator</a>, and a loop is a machine for finding that behavior whether you wanted it or not. If you take one thing from this post: <strong>the loop does not make your agent smarter, it makes the check the only thing standing between “it stopped” and “it is right.”</strong></p> <p>You can watch this play out. Weaken the verifier or turn up the gaming and correctness collapses while the loop still converges to a confident “done”:</p> <section class="qa-inst" id="inst-verifier"> <p class="qa-inst-eyebrow"><span class="b">Bet 10</span> / Loop engineering</p> <h2>A loop always converges. That tells you nothing about correctness.</h2> <p class="qa-inst-thesis">An agentic loop retries until a verifier says "done", so it converges on whatever passes the check, not on what you meant. Whether "done" is actually correct is set by the verifier, not by convergence. Two forces pull against each other: refinement makes the loop genuinely better, while optimizing against a weak verifier games it. Watch which one wins.</p> <div class="qa-inst-controls"> <div class="qa-inst-ctrl"> <label for="w5-base"><span class="qa-inst-lbl">Base correctness<span class="qa-inst-info" tabindex="0" role="note" aria-label="How often a fresh candidate is actually correct, before the verifier looks. Task difficulty against model capability.">i<span class="qa-inst-tip">How often a fresh candidate is actually correct, before the verifier looks. Task difficulty against model capability.</span></span></span><span class="val" id="w5-base-v"></span></label> <input type="range" id="w5-base" min="5" max="90" step="1" value="40"/> </div> <div class="qa-inst-ctrl"> <label for="w5-refine"><span class="qa-inst-lbl">Refinement / iter<span class="qa-inst-info" tabindex="0" role="note" aria-label="How much the loop genuinely improves each iteration: candidate correctness rises by this much per pass.">i<span class="qa-inst-tip">How much the loop genuinely improves each iteration: candidate correctness rises by this much per pass.</span></span></span><span class="val" id="w5-refine-v"></span></label> <input type="range" id="w5-refine" min="0" max="15" step="1" value="4"/> </div> <div class="qa-inst-ctrl"> <label for="w5-fa"><span class="qa-inst-lbl">Verifier false-accept<span class="qa-inst-info" tabindex="0" role="note" aria-label="How often the verifier passes a wrong answer. This is what the loop selects for: a false-accept ends the loop on an incorrect output.">i<span class="qa-inst-tip">How often the verifier passes a wrong answer. A false-accept ends the loop on an incorrect output, and it is what the loop selects for.</span></span></span><span class="val" id="w5-fa-v"></span></label> <input type="range" id="w5-fa" min="0" max="40" step="1" value="15"/> </div> <div class="qa-inst-ctrl"> <label for="w5-fr"><span class="qa-inst-lbl">Verifier false-reject<span class="qa-inst-info" tabindex="0" role="note" aria-label="How often the verifier rejects a correct answer. Higher means the loop runs longer, but it does not make done more trustworthy.">i<span class="qa-inst-tip">How often the verifier rejects a correct answer. Higher means the loop runs longer, but does not make done more trustworthy.</span></span></span><span class="val" id="w5-fr-v"></span></label> <input type="range" id="w5-fr" min="0" max="40" step="1" value="10"/> </div> <div class="qa-inst-ctrl"> <label for="w5-game"><span class="qa-inst-lbl">Gaming / iter<span class="qa-inst-info" tabindex="0" role="note" aria-label="Reward hacking: how much the loop learns to exploit the verifier each iteration, raising its false-accept rate. The RLVR failure mode.">i<span class="qa-inst-tip">Reward hacking: how much the loop learns to exploit the verifier each iteration, raising its false-accept rate. The RLVR failure mode.</span></span></span><span class="val" id="w5-game-v"></span></label> <input type="range" id="w5-game" min="0" max="10" step="1" value="3"/> </div> <div class="qa-inst-ctrl"> <label for="w5-iters"><span class="qa-inst-lbl">Iteration budget<span class="qa-inst-info" tabindex="0" role="note" aria-label="How many times the loop may retry before giving up. More iterations raise the chance of reaching done, not the chance done is correct.">i<span class="qa-inst-tip">How many times the loop may retry before giving up. More iterations raise the chance of reaching done, not that done is correct.</span></span></span><span class="val" id="w5-iters-v"></span></label> <input type="range" id="w5-iters" min="1" max="30" step="1" value="12"/> </div> </div> <div class="qa-inst-readouts"> <div class="qa-inst-ro"> <div class="k">Loops that converged</div> <div class="v" id="w5-conv">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Of those, correct</div> <div class="v" id="w5-correct">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">"Done" but wrong</div> <div class="v" id="w5-wrong">&mdash;<small>%</small></div> </div> <div class="qa-inst-ro"> <div class="k">Verdict</div> <div class="v"><span class="qa-inst-pill" id="w5-state"><span class="dot"></span>&mdash;</span></div> </div> </div> <figure> <div class="qa-inst-chart"><svg id="w5-chart" viewBox="0 0 640 300" role="img" aria-label="Convergence versus correctness over iterations"></svg></div> <figcaption> <span class="key"><span class="swatch" style="background:var(--qa-accent)"></span> converged (looks done)</span> <span class="key"><span class="swatch" style="background:var(--inst-comp)"></span> correct given done (the truth)</span> <span class="key">over iterations</span> </figcaption> </figure> <p class="qa-inst-hover" style="margin-top:1rem">Outcome of every loop at the budget:</p> <div class="qa-inst-bar"><div class="track" id="w5-outcome" style="height:22px"></div></div> <div class="qa-inst-axis" id="w5-outcome-legend" style="grid-template-columns:repeat(3,1fr);margin-top:.4rem"></div> <p class="qa-inst-verdict" id="w5-verdict"></p> <details> <summary>How this is computed</summary> <p>Exact, via the loop's stopping-time distribution. Each iteration <code>t</code>, a candidate is correct with probability <code>p&#8348; = base + refinement&middot;(t-1)</code>; the verifier's false-accept rate is <code>fa&#8348; = fa&#8320; + gaming&middot;(t-1)</code>. A correct candidate passes with probability <code>1 &minus; false-reject</code>; a wrong one passes with <code>fa&#8348;</code>. The loop stops on the first pass.</p> <ul> <li><strong>P(correct | done)</strong> is the verifier's precision at the base rate: <code>p(1&minus;fr) / (p(1&minus;fr) + (1&minus;p)&middot;fa)</code>. It does not depend on how many times you loop.</li> <li><strong>More iterations</strong> raise convergence toward 1, and nothing else. On a weak verifier, they raise the chance "done" is a false-accept.</li> <li><strong>Refinement</strong> lifts correctness over iterations; <strong>gaming</strong> collapses it. The verifier, not the loop, decides which wins.</li> </ul> <p>The argument of <a href="/blog/2026/loop-engineering-verifier/">Loop Engineering</a> and Bet #10: the verifier is the part that actually decides.</p> </details> </section> <script>!function(){"use strict";function e(e){return document.getElementById(e)}function t(e){return e<0?0:e>1?1:e}function n(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function o(e,n,o,r,i,a){for(var c=1,s=0,d=0,l=[{t:0,conv:0,cgd:0}],u=0,f=1;f<=a;f++){var v=t(e+n*(f-1)),w=v*(1-r),x=(1-v)*t(o+i*(f-1)),m=w+x;s+=c*w,d+=c*x,u+=f*(c*m);var g=1-(c*=1-m);l.push({t:f,conv:g,cgd:g>1e-9?s/g:0})}return{conv:g=1-c,dc:s,dw:d,notConv:c,cgd:g>1e-9?s/g:0,faShare:g>1e-9?d/g:0,expIter:g>1e-9?u/g:a,hist:l}}function r(){var t=+c.base.value/100,n=+c.refine.value/100,r=+c.fa.value/100,s=+c.fr.value/100,d=+c.game.value/100,l=+c.iters.value,u=o(t,n,r,s,d,l);c.base&&(e("w5-base-v").textContent=(100*t).toFixed(0)+"%"),e("w5-refine-v").textContent="+"+(100*n).toFixed(0)+"%",e("w5-fa-v").textContent=(100*r).toFixed(0)+"%",e("w5-fr-v").textContent=(100*s).toFixed(0)+"%",e("w5-game-v").textContent="+"+(100*d).toFixed(0)+"%",e("w5-iters-v").textContent=l,e("w5-conv").innerHTML=(100*u.conv).toFixed(0)+"<small>%</small>",e("w5-correct").innerHTML=(100*u.cgd).toFixed(0)+"<small>%</small>",e("w5-wrong").innerHTML=(100*u.faShare).toFixed(0)+"<small>%</small>";var f=u.cgd>=.9,v=u.cgd>=.7,w=e("w5-state");w.className="qa-inst-pill "+(f?"comp":v?"mem":"danger"),w.innerHTML='<span class="dot"></span>'+(f?"Trustworthy":v?"Shaky":"Converged, wrong"),i(u.hist,l),a(u),e("w5-verdict").innerHTML="<b>"+(100*u.conv).toFixed(0)+'%</b> of loops reach a stable "done", but only <b>'+(100*u.cgd).toFixed(0)+"%</b> of those are actually correct. "+(d>n?"Optimizing against this verifier is winning: correctness is being gamed away.":u.cgd>=.9?"A strong verifier is carrying the loop.":"Convergence is not correctness; the verifier is what decides.")}function i(t,o){function r(e){return d+(o?e/o:0)*u}function i(e){return l+(1-e)*f}function a(e,n){for(var o=[],a=0;a<t.length;a++)o.push(r(t[a].t).toFixed(1)+","+i(t[a][e]).toFixed(1));return'<polyline points="'+o.join(" ")+'" fill="none" stroke="'+n+'" stroke-width="2.2" stroke-linejoin="round"/>'}var c,s=300,d=44,l=16,u=640-d-16,f=s-l-30,v=n("--qa-accent"),w=n("--inst-comp"),x=n("--inst-grid"),m=n("--qa-text-muted"),g=[];for(c=0;c<=1.0001;c+=.25){var h=i(c);g.push('<line x1="'+d+'" y1="'+h.toFixed(1)+'" x2="'+(d+u)+'" y2="'+h.toFixed(1)+'" stroke="'+x+'" stroke-width="1"/>'),g.push('<text x="'+(d-7)+'" y="'+(h+3).toFixed(1)+'" fill="'+m+'" font-size="9.5" text-anchor="end">'+(100*c).toFixed(0)+"</text>")}g.push(a("conv",v)),g.push(a("cgd",w)),g.push('<text x="'+(d+u/2)+'" y="'+(s-6)+'" fill="'+m+'" font-size="9.5" text-anchor="middle" letter-spacing="1">ITERATIONS &rarr;</text>'),g.push('<text transform="translate(12,'+(l+f/2)+') rotate(-90)" fill="'+m+'" font-size="9.5" text-anchor="middle" letter-spacing="1">PROBABILITY %</text>'),e("w5-chart").innerHTML=g.join("")}function a(t){var o=n("--inst-comp"),r=n("--mhc-hc"),i=n("--qa-text-muted"),a=[{w:t.dc,c:o,label:"done, correct"},{w:t.dw,c:r,label:"done, wrong"},{w:t.notConv,c:i,label:"never converged"}],c="";a.forEach(function(e){e.w>5e-4&&(c+='<span style="display:inline-block;height:100%;width:'+(100*e.w).toFixed(2)+"%;background:"+e.c+(e.c===i?";opacity:.4":"")+'"></span>')}),e("w5-outcome").innerHTML=c,e("w5-outcome").style.display="flex";var s="";a.forEach(function(e){s+="<span>"+(100*e.w).toFixed(0)+"% "+e.label+"</span>"}),e("w5-outcome-legend").innerHTML=s}if(document.getElementById("w5-base")){var c={base:e("w5-base"),refine:e("w5-refine"),fa:e("w5-fa"),fr:e("w5-fr"),game:e("w5-game"),iters:e("w5-iters")};["change","input"].forEach(function(e){Object.keys(c).forEach(function(t){c[t].addEventListener(e,r)})}),new MutationObserver(r).observe(document.documentElement,{attributes:!0,attributeFilter:["data-theme"]}),r()}}();</script> <h2 id="the-verifier-is-your-specification-made-executable">The verifier is your specification, made executable</h2> <p>Everything now rests on that check, so it is worth being precise about what it actually is, because that changes how you write it. The verifier is not a test you bolt onto the work after the fact. It is the specification of the work, written in the one language an optimizer respects: a check it cannot talk its way around.</p> <p>The loop never reads your intent. It cannot. It reads the verifier, over and over, and shapes its output toward whatever the verifier will accept, so the verifier is the complete, operative definition of done inside your system. Anything you meant but did not encode does not exist. The spec in your head, the ticket, the design doc, none of it is binding, because none of it is what the loop can see.</p> <p>This is why “I will know it when I see it” is fatal in a loop in a way it never was by hand. When you were in the loop on every output, your taste was the spec, applied live. Step out and let it run unattended, and your taste has to be written down or it is gone. A vague verifier is not a lenient spec, it is the absence of one, and the loop fills that vacuum with the cheapest output that clears the bar you wrote, not the bar you meant.</p> <p>So the discipline is blunt: if you cannot write the verifier, you have not finished specifying the task. That is not a tooling gap you route around with a smarter model. It is the task telling you that you do not yet know what you want. Writing the verifier first is the act of finishing the specification, and most of the time it is where you find the spec was never finished at all.</p> <h2 id="the-verifier-ladder">The verifier ladder</h2> <p>So the work of loop engineering is mostly verifier engineering. The single most useful mental model I have for this is a ladder, ordered by how cheap, deterministic, and ungameable each kind of check is. The rule is simple and it is the highest-leverage habit in this whole discipline: <strong>push every check as far up the ladder as it will go.</strong></p> <div style="margin:1.6rem 0;border:1px solid #c5c0b4;border-radius:8px;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;line-height:1.3;"> <div style="background:#264653;color:#fff;padding:0.7rem 1rem;font-weight:600;">The verifier ladder: climb up until you physically cannot</div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#2d6a4f;color:#fff;"> <span style="flex:1;font-weight:600;">1. Compiler and type checks</span> <span style="font-size:0.78rem;opacity:0.95;">deterministic · ~free · cannot be gamed</span> </div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#2a9d8f;color:#fff;"> <span style="flex:1;font-weight:600;">2. Unit, property, and golden tests</span> <span style="font-size:0.78rem;opacity:0.95;">deterministic · cheap · hard to game if independent</span> </div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#e9c46a;color:#1a1a1a;"> <span style="flex:1;font-weight:600;">3. Schema, contract, and invariant checks</span> <span style="font-size:0.78rem;opacity:0.9;">deterministic · cheap · catches structural drift</span> </div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#f4a261;color:#1a1a1a;"> <span style="flex:1;font-weight:600;">4. Static analysis, linters, policy and rule engines</span> <span style="font-size:0.78rem;opacity:0.9;">deterministic · cheap · partial coverage</span> </div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#e76f51;color:#fff;"> <span style="flex:1;font-weight:600;">5. LLM judge with a rubric and ground truth</span> <span style="font-size:0.78rem;opacity:0.95;">stochastic · costs a call · gameable, needs an anchor</span> </div> <div style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1rem;background:#9d0208;color:#fff;"> <span style="flex:1;font-weight:600;">6. LLM judge with no ground truth, or human taste</span> <span style="font-size:0.78rem;opacity:0.95;">stochastic or scarce · most gameable · last resort</span> </div> <div style="padding:0.7rem 1rem;font-size:0.82rem;color:#3d3d3d;background:#f5f1e8;">Most trustworthy and cheapest at the top. Every check you move up the ladder is one that cannot be gamed and does not cost a model call. Spend scarce human and LLM-judge budget only on what genuinely cannot be made deterministic.</div> </div> <p>Most teams start at the bottom because it is the easiest to wire up. “Have a second model grade the output” is one API call. It is also the most gameable verifier on the ladder, and you are about to point an optimizer at it. That is precisely backwards.</p> <p>A few practical moves that pay off immediately:</p> <ul> <li><strong>Convert “looks right” into “passes a check.”</strong> If your stop condition is “the refactor is clean,” you have no verifier, you have a wish. Replace it with: the test suite still passes, public API signatures are unchanged, and the diff touches no file outside the target module. Those are deterministic. The loop cannot argue with them.</li> <li><strong>When you have no ground truth, manufacture a proxy for it.</strong> Differential testing (run the old and new code on the same inputs, outputs must match), metamorphic testing (a known input transformation must produce a known output transformation), and golden datasets give you deterministic checks for tasks that look unverifiable at first glance. Most “you cannot test this” claims are a failure of imagination about oracles.</li> <li><strong>If you must use an LLM judge, anchor it and make it adversarial.</strong> Give it the spec, the artifact, and a rubric, and instruct it to find the strongest reason to reject. A judge prompted to approve approves. A judge prompted to refute, with the original objective in hand, is a meaningfully harder target. I built <a href="/blog/2025/building-spark-llm-eval/">Spark-LLM-Eval</a> partly because evaluating model output rigorously, with confidence intervals and significance rather than a single judge call, is exactly the muscle loop engineering demands and most teams have not built.</li> </ul> <h2 id="make-the-checker-independent-or-it-shares-the-makers-blind-spots">Make the checker independent, or it shares the maker’s blind spots</h2> <p>Osmani’s primitives for a real loop include a maker and a separate checker, and Cobus Greyling makes the same point: a separate model verifies, so the agent never grades its own work. This is correct and it is incomplete. The detail that matters is <em>what</em> the checker is allowed to share with the maker.</p> <p>If your maker is model X with context C, and your checker is also model X with context C, you have not built independent verification. You have built an echo. The two share an architecture, a training distribution, and a working memory, which means they share blind spots. Whatever the maker confidently got wrong, the checker will confidently bless. The loop then converges, smoothly and efficiently, on output that is wrong in a way both halves cannot see.</p> <div style="display:flex;flex-wrap:wrap;gap:1rem;margin:1.7rem 0;font-family:system-ui,-apple-system,sans-serif;"> <div style="flex:1;min-width:230px;border:1px solid #c5c0b4;border-radius:8px;overflow:hidden;"> <div style="background:#9d0208;color:#fff;padding:0.6rem 1rem;font-weight:600;font-size:0.88rem;">Same model, same context</div> <div style="padding:1.3rem 1rem;text-align:center;background:#f5f1e8;"> <div style="display:inline-block;background:#fff;border:1px solid #ddd9ce;border-radius:6px;padding:0.45rem 1.2rem;color:#1a1a1a;font-weight:600;font-size:0.85rem;">Maker</div> <div style="font-size:0.72rem;color:#6b6b6b;margin:0.25rem 0 0;">model X, context C</div> <div style="color:#9d0208;font-size:1.35rem;line-height:1.2;">&#8595;</div> <div style="display:inline-block;background:#9d0208;border-radius:6px;padding:0.45rem 1.2rem;color:#fff;font-weight:600;font-size:0.85rem;">Checker</div> <div style="font-size:0.72rem;color:#3d3d3d;margin:0.35rem auto 0;max-width:22ch;">model X, same context C, so it is an echo</div> </div> <div style="background:#f6e5e5;color:#9d0208;padding:0.55rem 1rem;font-size:0.82rem;font-weight:600;border-top:1px solid #e3c9c9;">Converges on confidently wrong</div> </div> <div style="flex:1;min-width:230px;border:1px solid #c5c0b4;border-radius:8px;overflow:hidden;"> <div style="background:#2d6a4f;color:#fff;padding:0.6rem 1rem;font-weight:600;font-size:0.88rem;">Independent checker</div> <div style="padding:1.3rem 1rem;text-align:center;background:#f5f1e8;"> <div style="display:inline-block;background:#fff;border:1px solid #ddd9ce;border-radius:6px;padding:0.45rem 1.2rem;color:#1a1a1a;font-weight:600;font-size:0.85rem;">Maker</div> <div style="font-size:0.72rem;color:#6b6b6b;margin:0.25rem 0 0;">writes the artifact</div> <div style="color:#2d6a4f;font-size:1.35rem;line-height:1.2;">&#8595;</div> <div style="display:inline-block;background:#2d6a4f;border-radius:6px;padding:0.45rem 1.2rem;color:#fff;font-weight:600;font-size:0.85rem;">Checker</div> <div style="font-size:0.72rem;color:#3d3d3d;margin:0.35rem auto 0;max-width:24ch;">different model or fresh context, deterministic signals first</div> </div> <div style="background:#e8f3ee;color:#2d6a4f;padding:0.55rem 1rem;font-size:0.82rem;font-weight:600;border-top:1px solid #cfe5da;">Catches what the maker cannot see</div> </div> </div> <p>Independence is a spectrum and you want as much of it as you can afford. In rough order of strength: a deterministic check shares nothing with the maker and is the strongest checker you can have. A different model family with a fresh context is next. The same model with a fresh context and only the spec plus the artifact, not the maker’s reasoning, is weak but better than nothing. The same model continuing the same conversation is not a checker at all.</p> <p>The concrete rule I follow: <strong>the agent that decides “done” must not be the agent that did the work, and it should see objective signals before it sees prose.</strong> Hand it the test results, the diff, and the schema validation first. Let it read the maker’s explanation last, if at all. Explanations are where models launder their own mistakes into something that sounds verified.</p> <h2 id="who-verifies-the-verifier">Who verifies the verifier?</h2> <p>Here is the question this entire conversation walks up to and then declines to ask. You have made the verifier the thing every other claim in this post depends on. You have climbed the ladder and made the checker independent. So how do you know the verifier itself is trustworthy? A load-bearing beam you never tested is not a feature. It is the subject of the eventual incident report.</p> <p>A verifier is software, and the unsettling part is that it is the one piece of software in your stack with nothing checking its work. The maker is checked by the verifier. The verifier is checked by nothing, unless you build that too. Three habits close the gap.</p> <p><strong>Mutation-test the verifier. Try to fool it on purpose.</strong> Borrow the move from mutation testing: take outputs you already know are wrong and confirm the verifier rejects every one of them. Delete the function the tests cover and watch the suite go red. Hand the reconciliation checker a set of matches that secretly net to a non-zero figure and confirm it refuses to pass. Feed the LLM judge a confident, well-formatted, factually wrong answer, which is exactly what a loop will eventually generate, and see whether it catches the error or gets flattered by the formatting. A verifier you have never tried to defeat is one you are trusting on faith, right before you point an optimizer at it that will try to defeat it for a living. Do that job first, in daylight, before the loop does it at three in the morning. The checks that survive are the ones you can let run unattended; the ones that fold are gaps the loop would have found anyway, and you found them cheaper.</p> <p><strong>Measure the gap. Do not assume it is closed.</strong> Your entire exposure is the distance between passed the verifier and actually correct, so treat it as a number you watch, not something you reasoned away. Sample the outputs that passed, audit them against a stronger, slower oracle, and the rate at which a pass turns out wrong is your verifier’s false-acceptance rate. It is the most honest single measure of your loop’s quality, and it belongs on a dashboard next to latency. If it reads zero, your sample is too small or your audit too shallow. When it drifts upward, your verifier is decaying or your inputs have moved out from under it, and you want that from a graph, not from a customer.</p> <p><strong>Assume the verifier rots.</strong> It is code, and code rots, but a verifier rots silently, which is worse. The golden dataset ages out of its distribution. The spec it encodes drifts as the product changes. The threshold that was right last quarter is loose this quarter. And because a stale verifier still returns a crisp, confident pass, the loop keeps shipping and the gap widens with nobody watching. So treat the verifier as a maintained asset with an owner and a review cadence: re-derive the golden set on recent data, re-run the mutation tests after every meaningful spec change, and watch the false-acceptance number for the slow creep that means the check has quietly stopped meaning what it used to.</p> <h2 id="let-the-model-write-the-verifier-never-let-it-be-the-verifier">Let the model write the verifier. Never let it be the verifier.</h2> <p>There is a tempting shortcut and a real technique here, and they look almost identical. The shortcut is to let the model be the verifier: ask it, every iteration, whether the output is good. You know how that ends, because the loop optimizes the model into saying yes. The technique is its mirror image: let the model write the verifier, once, offline, then take the model out of the decision entirely.</p> <p>Models are genuinely good at proposing oracles. Ask one to enumerate the invariants a correct reconciliation must satisfy, or to generate property tests and metamorphic relations for a function, or to brainstorm the adversarial inputs a check ought to survive, and it will hand you a strong, broad list, often broader than you would have written yourself. That is the model doing exactly what it is good at: generating candidates over a space you then prune. What you do next is the whole trick. You read those candidate checks, you keep the ones that are actually right, you freeze them into deterministic code, and you delete the model from the loop’s verify step. The model was a test author at build time. It is not the test at run time.</p> <p>The line is build time against run time, and it maps onto the ladder: drafting a check is a stochastic judgment, but freezing it into code carries it up to a deterministic one an optimizer cannot argue with, at a cost of one call instead of one per iteration forever. The best verifiers I have are often drafted by a model. None of the good ones are still run by one.</p> <h2 id="the-exits-are-non-negotiable">The exits are non-negotiable</h2> <p>A verifier tells you when to stop because you succeeded. You also need to stop when you are failing, and that is a different set of mechanisms. Robust loops carry several independent exits, and “independent” is load-bearing: if your only way out is the verifier passing, a loop that can never pass will run until something else breaks, usually your budget.</p> <table> <thead> <tr> <th>Exit</th> <th>What it catches</th> <th>How to implement it</th> </tr> </thead> <tbody> <tr> <td>Verifier pass</td> <td>Success</td> <td>The ladder above. The only happy exit.</td> </tr> <tr> <td>Hard iteration cap</td> <td>Runaway loops</td> <td>A counter. Boring. Always present. Set it low, raise it on evidence.</td> </tr> <tr> <td>Token and dollar budget</td> <td>Cost blowups</td> <td>Track spend per trajectory, not per call. Kill at the ceiling.</td> </tr> <tr> <td>Wall-clock budget</td> <td>Stuck on slow tools, deadlocks</td> <td>A timer on the whole trajectory, separate from per-call timeouts.</td> </tr> <tr> <td>No-progress detector</td> <td>Oscillation, local minima</td> <td>Hash the state or diff each turn. If it repeats or marginal verifier gain stalls, stop.</td> </tr> <tr> <td>Human gate</td> <td>High blast-radius actions</td> <td>Any irreversible or production-affecting step waits for approval.</td> </tr> </tbody> </table> <p>No-progress detection is the one people skip and then regret. A loop without it does not fail loudly, it fails by spending three hours and forty dollars oscillating between two almost-identical states, each of which fails the verifier in the same way. The cheap version: hash the candidate diff each iteration and stop if you see the same hash twice. The better version: track the verifier’s score over iterations and stop when the marginal improvement per iteration falls below a threshold. You are watching for a derivative going to zero, which is a control-theory instinct, not an AI one, and that is the point.</p> <p>Here is the shape of a loop that will not bankrupt you, in pseudocode, with every exit wired in:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run_loop</span><span class="p">(</span><span class="n">objective</span><span class="p">,</span> <span class="n">budget</span><span class="p">,</span> <span class="n">max_iters</span><span class="o">=</span><span class="mi">12</span><span class="p">):</span>
    <span class="n">state</span> <span class="o">=</span> <span class="nf">gather_context</span><span class="p">(</span><span class="n">objective</span><span class="p">)</span>
    <span class="n">seen</span> <span class="o">=</span> <span class="nf">set</span><span class="p">()</span>
    <span class="n">last_score</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">max_iters</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">budget</span><span class="p">.</span><span class="nf">exceeded</span><span class="p">():</span>                    <span class="c1"># tokens, dollars, wall-clock
</span>            <span class="k">return</span> <span class="nf">escalate</span><span class="p">(</span><span class="sh">"</span><span class="s">budget</span><span class="sh">"</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span>
        <span class="n">artifact</span> <span class="o">=</span> <span class="n">maker</span><span class="p">.</span><span class="nf">act</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>              <span class="c1"># the model does work
</span>        <span class="n">signals</span> <span class="o">=</span> <span class="nf">observe</span><span class="p">(</span><span class="n">artifact</span><span class="p">)</span>              <span class="c1"># run it, capture deterministic results
</span>        <span class="n">result</span> <span class="o">=</span> <span class="n">checker</span><span class="p">.</span><span class="nf">verify</span><span class="p">(</span><span class="n">objective</span><span class="p">,</span>       <span class="c1"># INDEPENDENT checker,
</span>                                <span class="n">artifact</span><span class="p">,</span>        <span class="c1"># deterministic signals first,
</span>                                <span class="n">signals</span><span class="p">)</span>         <span class="c1"># prose last
</span>        <span class="k">if</span> <span class="n">result</span><span class="p">.</span><span class="n">passed</span><span class="p">:</span>
            <span class="k">return</span> <span class="nf">done</span><span class="p">(</span><span class="n">artifact</span><span class="p">)</span>
        <span class="n">fingerprint</span> <span class="o">=</span> <span class="nf">hash_state</span><span class="p">(</span><span class="n">artifact</span><span class="p">,</span> <span class="n">signals</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">fingerprint</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>                  <span class="c1"># oscillation / no progress
</span>            <span class="k">return</span> <span class="nf">escalate</span><span class="p">(</span><span class="sh">"</span><span class="s">stalled</span><span class="sh">"</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">result</span><span class="p">.</span><span class="n">score</span> <span class="o">-</span> <span class="n">last_score</span> <span class="o">&lt;</span> <span class="n">EPSILON</span><span class="p">:</span>  <span class="c1"># diminishing returns
</span>            <span class="k">return</span> <span class="nf">escalate</span><span class="p">(</span><span class="sh">"</span><span class="s">plateau</span><span class="sh">"</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span>
        <span class="n">seen</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">fingerprint</span><span class="p">)</span>
        <span class="n">last_score</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="n">score</span>
        <span class="n">state</span> <span class="o">=</span> <span class="nf">adjust</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">result</span><span class="p">.</span><span class="n">feedback</span><span class="p">)</span>
    <span class="k">return</span> <span class="nf">escalate</span><span class="p">(</span><span class="sh">"</span><span class="s">max_iters</span><span class="sh">"</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span>
</code></pre></div></div> <p>The model appears on exactly one line. Everything else is the loop, and the loop is where your engineering lives now.</p> <h2 id="what-changes-at-enterprise-scale">What changes at enterprise scale</h2> <p>Everything above holds for a coding agent in your terminal. I want to spend the rest of this on the part the current discourse barely touches, because it is where I have spent the last few years and it is where loop engineering gets genuinely hard: loops that run unattended, over real systems, inside an organization.</p> <p>When the loop is a coding agent, you have a gift you may not appreciate: a free, deterministic, fast verifier sitting at the top of the ladder. The test suite. The compiler. The world has handed you ground truth.</p> <p>Now take the gift away. I built a system of more than fifteen agents to consolidate a sprawling, fragmented BigQuery estate into a single coherent foundation layer, and a separate multi-agent system that migrated tens of thousands of notebooks across thousands of projects, cutting each migration from roughly a week to under thirty minutes. There is no <code class="language-plaintext highlighter-rouge">pytest</code> for “did we consolidate this schema correctly” or “is this migrated notebook semantically equivalent to the original.” The verifier you get for free in software does not exist. You have to build it, and building it is most of the actual work.</p> <p>What you build instead of a test suite is a <strong>verification coalition</strong>: several independent, mostly deterministic checks that together bound the space of “wrong” tightly enough to trust the loop.</p> <pre><code class="language-mermaid">flowchart TB
    T["Trigger&lt;br/&gt;&lt;i&gt;schedule, event, queue&lt;/i&gt;"] --&gt; CTX["Context assembly&lt;br/&gt;&lt;i&gt;skills, durable state, retrieval&lt;/i&gt;"]
    CTX --&gt; MK["Maker agents&lt;br/&gt;&lt;i&gt;worktree-isolated, parallel&lt;/i&gt;"]
    MK --&gt; VC["Verification coalition"]
    VC --&gt; D1["Schema and contract checks"]
    VC --&gt; D2["Data diff vs golden sample"]
    VC --&gt; D3["Policy and rule engine"]
    VC --&gt; D4["Sampled human review"]
    D1 &amp; D2 &amp; D3 &amp; D4 --&gt; G{"Gate"}
    G --&gt;|pass, low blast radius| APPLY["Apply and record state"]
    G --&gt;|high blast radius| HUMAN["Human approval"]
    G --&gt;|fail| ADJ["Adjust and retry"]
    ADJ --&gt; CTX
    BUD["Budget governor&lt;br/&gt;&lt;i&gt;tokens, dollars, wall-clock, iterations&lt;/i&gt;"] -. enforces .-&gt; G
    style VC fill:#264653,color:#fff
    style BUD fill:#e9c46a,color:#1a1a1a
    style HUMAN fill:#e76f51,color:#fff
    style APPLY fill:#2d6a4f,color:#fff
</code></pre> <p>Three things change once you are operating at this altitude, and none of them are in the viral threads.</p> <p><strong>Ground truth is a budget item.</strong> For the notebook migration, the strongest verifier is “run the original and the migrated notebook on the same data and diff the outputs.” That is differential testing, it sits high on the ladder, and it costs real compute per notebook. So verification is not free and it is not unlimited, which means you triage: deterministic cheap checks on everything, expensive differential checks on a risk-weighted sample, human review on the long tail the cheap checks flag. The verifier coalition is itself a cost-optimization problem. Most people think the model calls are the expense. At scale, verification is frequently the larger line item, and it should be, because it is the part you can trust.</p> <p><strong>Budgets stop being a safety rail and become the governance layer.</strong> When one engineer runs one loop, a budget is a guardrail against a runaway. When an organization runs hundreds of loops, the budget allocation <em>is</em> the control plane. Which loops get to touch production. How much they can spend before a human signs off. What blast radius is allowed without approval. When I set up an operating model for agents across a large delivery organization, the hard part was never the agents. It was making agentic checks a mandatory gate, standardizing what a sufficient verifier looks like, and giving budgets and human gates teeth so that “we automated it” never quietly meant “we stopped checking it.” That is loop engineering as an organizational discipline, and it looks a lot more like SRE error budgets than like prompt design.</p> <p><strong>You debug trajectories, not outputs.</strong> Once the unit of value is the trajectory, the unit of debugging is too. When a loop produces a bad result on notebook 18,000 of 30,000, “look at the output” is useless. You need the full trace: every step, every tool call, every verifier decision, the cost attributed per step, and the ability to replay it. This is observability work, and it is closer to distributed-systems debugging than to anything in the prompt-engineering playbook. It is also where interpretability earns its keep in production. Being able to look inside a step and ask why the model did what it did, which is the <a href="/blog/2026/circuit-tracing-production/">circuit-tracing work I have pushed into production settings</a>, turns a black-box trajectory into something you can actually fix.</p> <h2 id="a-worked-example-a-reconciliation-loop-in-banking-and-in-adk">A worked example: a reconciliation loop, in banking and in ADK</h2> <p>Abstractions are cheap, so here is a concrete one from a domain I have spent years in. Intersystem reconciliation: a bank’s ledger has to agree with a counterparty feed, a payment processor’s file, or a sub-ledger, across millions of transactions a day. The breaks, the mismatches, have to be found, classified, explained, and resolved, and any adjustment above a materiality threshold needs a human sign-off. It is high-volume, repetitive, judgment-laden in the middle and rigid at the edges. It is exactly the kind of work people now want to wrap in a loop.</p> <p>It is also a <em>good</em> loop, and the reason is the whole thesis of this post: reconciliation comes with a real verifier. The books either balance or they do not. That is a deterministic check sitting at the very top of the ladder, which means you can let an agent iterate without flying blind.</p> <ul id="recon" class="tab" data-tab="670b2aef-2190-4dda-a0aa-519c35b50ad4" data-name="recon"> <li class="active" id="recon-the-verification-coalition"> <a href="#">The verification coalition </a> </li> <li id="recon-the-same-loop-in-adk"> <a href="#">The same loop in ADK </a> </li> </ul> <ul class="tab-content" id="670b2aef-2190-4dda-a0aa-519c35b50ad4" data-name="recon"> <li class="active"> <p>Here is how the verification coalition lays out, mapped to the ladder:</p> <ul> <li><strong>Top of the ladder, deterministic, run on everything.</strong> Control totals reconcile to source-system balances. Every proposed set of matches nets to zero. Every transaction appears exactly once. Reason codes come from a closed vocabulary, not free text. These are not model calls. They are arithmetic and set operations, and they cannot be flattered.</li> <li><strong>Middle, rule engine.</strong> Break classification (timing, fee, FX, duplicate, missing), plus policy checks: materiality thresholds, segregation of duties, cutoff rules.</li> <li><strong>Bottom, LLM judge, used sparingly.</strong> The only place a model gets to grade anything is the human-readable narrative of a break, and even there it is anchored to the deterministic classification. The model never decides whether a break is real or resolved. That decision lives in the arithmetic.</li> <li><strong>Human gate.</strong> Any adjustment or write-off above the materiality threshold stops and waits for an approver. High blast radius, non-negotiable.</li> </ul> </li> <li> <p>Now the part the framework gives you, and the part it does not. Google’s Agent Development Kit ships a <code class="language-plaintext highlighter-rouge">LoopAgent</code> that runs sub-agents on a loop with a hard iteration cap, and any sub-agent or tool can break the loop by setting <code class="language-plaintext highlighter-rouge">tool_context.actions.escalate = True</code>. So the skeleton is almost trivial:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">google.adk.agents</span> <span class="kn">import</span> <span class="n">LoopAgent</span><span class="p">,</span> <span class="n">LlmAgent</span>
<span class="kn">from</span> <span class="n">google.adk.tools</span> <span class="kn">import</span> <span class="n">FunctionTool</span><span class="p">,</span> <span class="n">ToolContext</span>

<span class="k">def</span> <span class="nf">run_verifier</span><span class="p">(</span><span class="n">tool_context</span><span class="p">:</span> <span class="n">ToolContext</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="c1"># The verification coalition. Deterministic checks first, no model in sight.
</span>    <span class="n">breaks</span>  <span class="o">=</span> <span class="nf">balance_and_netting_checks</span><span class="p">(</span><span class="n">tool_context</span><span class="p">.</span><span class="n">state</span><span class="p">)</span>  <span class="c1"># nets to zero, control totals, no dupes
</span>    <span class="n">breaks</span> <span class="o">+=</span> <span class="nf">policy_rule_checks</span><span class="p">(</span><span class="n">tool_context</span><span class="p">.</span><span class="n">state</span><span class="p">)</span>          <span class="c1"># materiality, segregation of duties, cutoff
</span>    <span class="k">if</span> <span class="ow">not</span> <span class="n">breaks</span><span class="p">:</span>
        <span class="n">tool_context</span><span class="p">.</span><span class="n">actions</span><span class="p">.</span><span class="n">escalate</span> <span class="o">=</span> <span class="bp">True</span>                 <span class="c1"># verifier passed -&gt; exit the loop
</span>        <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">reconciled</span><span class="sh">"</span><span class="p">}</span>
    <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">open</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">breaks</span><span class="sh">"</span><span class="p">:</span> <span class="n">breaks</span><span class="p">}</span>              <span class="c1"># feed the breaks back for another pass
</span>
<span class="n">matcher</span> <span class="o">=</span> <span class="nc">LlmAgent</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">matcher</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">instruction</span><span class="o">=</span><span class="sh">"</span><span class="s">Propose matches and classify the open breaks. Never declare anything resolved.</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">tools</span><span class="o">=</span><span class="p">[...],</span>  <span class="c1"># ledger lookups, fuzzy matching, FX conversion
</span><span class="p">)</span>
<span class="n">verifier</span> <span class="o">=</span> <span class="nc">LlmAgent</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">verifier</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">instruction</span><span class="o">=</span><span class="sh">"</span><span class="s">Call run_verifier. You never decide a break is resolved yourself; the checks do.</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">tools</span><span class="o">=</span><span class="p">[</span><span class="nc">FunctionTool</span><span class="p">(</span><span class="n">run_verifier</span><span class="p">)],</span>
<span class="p">)</span>

<span class="n">recon_loop</span> <span class="o">=</span> <span class="nc">LoopAgent</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">reconciliation_loop</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">sub_agents</span><span class="o">=</span><span class="p">[</span><span class="n">matcher</span><span class="p">,</span> <span class="n">verifier</span><span class="p">],</span>  <span class="c1"># maker, then independent checker
</span>    <span class="n">max_iterations</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>                <span class="c1"># a hard exit that does not depend on success
</span><span class="p">)</span>
</code></pre></div></div> <p>Look at what ADK actually handed you: the loop, the iteration cap, and the <code class="language-plaintext highlighter-rouge">escalate</code> hook. That is the easy twenty percent. What it did not hand you is <code class="language-plaintext highlighter-rouge">balance_and_netting_checks</code>, <code class="language-plaintext highlighter-rouge">policy_rule_checks</code>, and the rule that a model never gets to call a break resolved. That deterministic coalition, and the discipline of keeping the model out of the verify decision, is the other eighty percent, and it is the part that decides whether this runs unattended against the general ledger or quietly books a wrong adjustment at three in the morning.</p> <p>Two details this makes concrete. First, ADK gives you <code class="language-plaintext highlighter-rouge">max_iterations</code> and <code class="language-plaintext highlighter-rouge">escalate</code>, but the budget, wall-clock, and no-progress exits from the table above are still yours to wire, through callbacks or the runner. The framework gives you one exit cleanly and expects you to build the rest. Second, the contrast with a bad loop is sharp. Point the same <code class="language-plaintext highlighter-rouge">LoopAgent</code> at a goal like “improve this customer’s credit assessment” with an LLM judge as the only verifier, and you have built a machine that optimizes for assessments that please a grader. One of these belongs in production. The other is a model-risk and compliance incident waiting for a regulator. The framework cannot tell the difference. Only the verifier can, which is the entire point.</p> </li> </ul> <h2 id="the-verifier-is-also-your-security-boundary">The verifier is also your security boundary</h2> <p>Everything so far has assumed the gap between passes the check and is correct gets found by accident, the loop stumbling onto the cheapest output that clears the bar. Now assume someone is steering. The moment a loop runs over input that an adversary can influence, the verifier stops being only a quality gate and becomes a security boundary, and almost nobody in this discourse is treating it as one.</p> <p>The attack surface is wider than it looks, because reading untrusted content is part of the loop’s normal operation, not an edge case. A coding agent runs over a repository whose README or dependency can carry an injected instruction. A reconciliation loop ingests a counterparty feed a motivated party has every reason to shape. A research loop reads web pages written specifically to be read by an agent like this one. Prompt injection here is an attempt to turn your maker against your own objective from inside the data it was told to trust, and the maker, being helpful, tends to comply.</p> <p>What stands between that and a bad write is the verifier, and the adversarial framing sharpens which verifiers you can tolerate. A deterministic check high on the ladder is harder to suborn, because arithmetic does not read instructions: the books either balance or they do not, regardless of what the counterparty feed whispered to the maker on the way in. An LLM judge, by contrast, is reachable by the very same injection that compromised the maker, through the very same channel. So independence gains a second meaning: independence from the maker’s blind spots was the quality argument, and independence from the maker’s compromise is the security one, because a checker that shares the maker’s context shares whatever just hijacked it.</p> <p>The convenient part is that the discipline does not change, so you do not need a separate security story bolted on afterward. Climb the ladder and the injection has less to grab onto. Keep the model out of the verify decision and a compromised maker cannot launder its output past the gate. Put a human on the high blast radius actions and the worst an injection buys is a proposal, not a posting. The verifier you built to stop the loop from fooling itself is, with no extra work, the one that stops someone else from fooling it on purpose.</p> <h2 id="loopmaxxing-and-the-failure-modes-mapped-to-their-real-cause">Loopmaxxing and the failure modes, mapped to their real cause</h2> <p>The failure modes getting passed around are real. What is missing is that almost all of them are the verifier failing in disguise. Here is the map I use.</p> <table> <thead> <tr> <th>Failure mode</th> <th>What it looks like</th> <th>Root cause</th> <th>Fix</th> </tr> </thead> <tbody> <tr> <td>Loopmaxxing</td> <td>More iterations, no progress, big bill</td> <td>No verifier, or a verifier that does not measure the real goal</td> <td>Define a binary pass condition before you run anything</td> </tr> <tr> <td>Oscillation</td> <td>Flips between two near-identical states</td> <td>Non-monotonic or noisy verifier</td> <td>No-progress detector, plus a more stable check</td> </tr> <tr> <td>Verifier gaming</td> <td>Tests deleted, lint suppressed, judge flattered</td> <td>Optimizer found the gap in a gameable check</td> <td>Climb the ladder, make the checker independent</td> </tr> <tr> <td>Comprehension debt</td> <td>It shipped, nobody understands it</td> <td>Verifier too cheap relative to the change’s blast radius</td> <td>Match verification depth to blast radius, gate big changes</td> </tr> <tr> <td>Cost blowup</td> <td>Correct, eventually, at 10x the budget</td> <td>No per-trajectory budget, verification too expensive</td> <td>Budget per trajectory, triage expensive checks</td> </tr> <tr> <td>Stale verifier</td> <td>Green checks, output quietly degrading</td> <td>Verifier rotted: golden set aged out, spec drifted</td> <td>Treat the verifier as a maintained asset, watch the false-acceptance rate</td> </tr> <tr> <td>Suborned verifier</td> <td>Loop steered by adversary-influenced input</td> <td>Injected content reaching a gameable checker</td> <td>Deterministic checks high on the ladder, human on the gate</td> </tr> </tbody> </table> <p>Two of these deserve a reflexive habit.</p> <p><strong>Distill and demote.</strong> Once a step is stable, replace the model call with code. An agent that has classified the same input the same way a thousand times is not a stochastic job anymore, it is a function. The mature loop makes <em>fewer</em> model calls over time, not more, because you keep promoting proven behavior into deterministic code, which also moves checks up the ladder. The best loop engineering deletes LLM calls.</p> <p><strong>Comprehension debt is paid later.</strong> Osmani’s warning is the right one to close on: <em>“No volume of recursive loop cycles can salvage a poorly specified objective or unprincipled software architecture.”</em> A loop ships faster than you can understand what it shipped, and that gap is debt against a future incident at a brutal interest rate.</p> <h2 id="it-is-not-new-and-respecting-that-makes-you-better-at-it">It is not new, and respecting that makes you better at it</h2> <p>This was not invented in June 2026. ReAct, Reflexion, evaluator-optimizer and generator-critic patterns, RLVR: the loop is old, and underneath all of them is the feedback control loop that control theory formalized a century ago and SRE has run production on for two decades. A control loop always converges, but only to whatever its sensor measures; if the sensor is wrong, it converges confidently to the wrong place. We renamed the sensor “verifier” and the actuator “tool call,” and the math did not change. What is new is only the precondition: the model at the center is finally good enough that running it autonomously to a goal beats driving it by hand, so the bottleneck moves outward, from the model to the loop. “It is just a while loop” is correct about the syntax and wrong about the work, the same way “a database is just a file” is correct and useless.</p> <h2 id="the-playbook">The playbook</h2> <p>If you are building your first serious loop, do these in order. The order is the advice.</p> <ol> <li><strong>Write the verifier first.</strong> If you cannot state a check that separates done from not-done without the word “good,” you are not ready to build the loop. You are ready to specify the objective better.</li> <li><strong>Climb the ladder.</strong> For every check, ask whether it can be made more deterministic and moved up. Spend your effort here, not on the prompt.</li> <li><strong>Make the checker independent.</strong> Different model or fresh context, deterministic signals before prose, never the maker grading itself.</li> <li><strong>Test the verifier before you trust it.</strong> Mutation-test it: feed it outputs you know are wrong and confirm it rejects them. The check you never tried to fool is what the incident report is about.</li> <li><strong>Wire every exit before the first unattended run.</strong> Iteration cap, token and dollar budget, wall-clock, no-progress detector, human gate. No exits, no sleeping.</li> <li><strong>Budget per trajectory and alert on it.</strong> Cost is a property of the trajectory now. Treat a cost spike like a latency spike.</li> <li><strong>Instrument the trajectory.</strong> Full traces, replay, per-step cost. You cannot debug what you cannot see, and you will have to.</li> <li><strong>Track the verifier gap.</strong> Sample what passed, audit it against a stronger oracle, watch the false-acceptance rate. A rising gap is your verifier rotting in public.</li> <li><strong>Distill and demote.</strong> Each week, find the step that has earned its way into plain code, and promote it.</li> <li><strong>Keep a human on the high-blast-radius gate.</strong> Automate the verification. Do not automate away the accountability.</li> </ol> <h2 id="honest-assessment-when-to-reach-for-a-loop">Honest assessment: when to reach for a loop</h2> <ul id="fit" class="tab" data-tab="2ac6651d-e19e-4b2d-a45b-46c2d2aaffd4" data-name="fit"> <li class="active" id="fit-reach-for-a-loop-when"> <a href="#">Reach for a loop when </a> </li> <li id="fit-do-not-reach-for-a-loop-when"> <a href="#">Do not reach for a loop when </a> </li> </ul> <ul class="tab-content" id="2ac6651d-e19e-4b2d-a45b-46c2d2aaffd4" data-name="fit"> <li class="active"> <ul> <li>The task is multi-step and benefits from real feedback between steps.</li> <li>You can express “done” as a check that sits high on the verifier ladder.</li> <li>The cost of an iteration is much smaller than the value of getting it right unattended.</li> <li>You can afford to build the verifier and the observability, not just the agent.</li> </ul> </li> <li> <ul> <li>Your only available verifier is “an LLM thinks it looks good.” That is not a verifier, it is a target, and you are about to optimize against it.</li> <li>The goal is genuinely subjective with no proxy for ground truth. Loops do not create signal that is not there.</li> <li>A single well-engineered call already solves it. Not everything needs to be autonomous, and a loop around a solved problem is just latency and cost.</li> <li>You cannot afford the verification and observability the loop demands. A loop you cannot watch is a liability, not a productivity gain.</li> </ul> </li> </ul> <p>Loop engineering is a real discipline and the shift it names is real. But the marketing has the emphasis exactly backwards. The loop is not the achievement; any loop converges. Converging on something correct is the achievement, and that is a verifier you can trust an optimizer to attack a thousand times, and at enterprise scale, a coalition of them, governed by budgets, instrumented end to end, with a human on the gate that matters.</p> <p>Osmani’s closing line is the right one to steal: <em>“Build the loop. But build it like someone who intends to stay the engineer, not just the person who presses go.”</em> The way you stay the engineer is by owning the verify box. Everything else, the model will increasingly handle. That box is yours.</p> <hr/> <p><em>Building loops over real systems and wrestling with the verifier problem? I would like to compare notes: <a href="mailto:contact@subhadipmitra.com">contact@subhadipmitra.com</a></em></p> <hr/> <h3 id="references">References</h3> <ol> <li>Osmani, A. (2026). <em>Loop Engineering.</em> <a href="https://addyosmani.com/blog/loop-engineering/">addyosmani.com/blog/loop-engineering</a> (June 7, 2026)</li> <li>Steinberger, P. (2026). Remarks on designing loops that prompt agents, via X (June 7, 2026).</li> <li>Cherny, B. (2026). Remarks on loops at @Scale, via TechTalks (June 21, 2026).</li> <li>Dickson, B. (2026). <em>Demystifying loop engineering: get more from AI agents, avoid loopmaxxing.</em> TechTalks. <a href="https://bdtechtalks.com/2026/06/22/ai-loop-engineering/">bdtechtalks.com</a> (June 22, 2026)</li> <li>Greyling, C. (2026). <em>Loop Engineering.</em> <a href="https://cobusgreyling.substack.com/p/loop-engineering">cobusgreyling.substack.com</a> (June 9, 2026)</li> <li>decodingAI (2026). <em>Agentic Harness Engineering: LLMs as the New OS.</em> <a href="https://www.decodingai.com/p/agentic-harness-engineering">decodingai.com</a> (March 31, 2026)</li> <li>Yao, S., et al. (2022). <em>ReAct: Synergizing Reasoning and Acting in Language Models.</em> <a href="https://arxiv.org/abs/2210.03629">arXiv:2210.03629</a></li> <li>Shinn, N., et al. (2023). <em>Reflexion: Language Agents with Verbal Reinforcement Learning.</em> <a href="https://arxiv.org/abs/2303.11366">arXiv:2303.11366</a></li> <li>Google. <em>Agent Development Kit (ADK): Loop agents.</em> <a href="https://google.github.io/adk-docs/agents/workflow-agents/loop-agents/">google.github.io/adk-docs</a></li> </ol>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="agents"/><category term="loop-engineering"/><category term="llm-engineering"/><category term="verification"/><category term="ai-engineering"/><summary type="html"><![CDATA[A loop always converges. That it reached a stable 'done' state tells you nothing about whether that state is correct, because the loop selects for whatever passes your check, not for what you meant. A practitioner's take on loop engineering: why convergence is not correctness, why the verifier is the part that actually decides, and what changes when loops run unattended over real production systems instead of a coding agent in a terminal.]]></summary></entry><entry><title type="html">Attention Is All You Bid: Advertising in Embedding Space</title><link href="https://subhadipmitra.com/blog/2026/attention-is-all-you-bid/" rel="alternate" type="text/html" title="Attention Is All You Bid: Advertising in Embedding Space"/><published>2026-04-04T00:00:00+00:00</published><updated>2026-04-04T00:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/attention-is-all-you-bid</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/attention-is-all-you-bid/"><![CDATA[<style>[data-theme="dark"] .post-svg-viz rect.svg-bg{fill:#1a1a1a;stroke:#333}[data-theme="dark"] .post-svg-viz line.svg-grid{stroke:#2a2a2a}[data-theme="dark"] .post-svg-viz .svg-grid{stroke:#2a2a2a}[data-theme="dark"] .post-svg-viz text.svg-label{fill:#b8b8b8}[data-theme="dark"] .post-svg-viz text.svg-label-dark{fill:#d4d4d4}[data-theme="dark"] .post-svg-viz text.svg-label-muted{fill:#707070}[data-theme="dark"] .post-svg-viz g.svg-label-muted{fill:#707070}[data-theme="dark"] .post-svg-viz .svg-legend-bg{fill:#1a1a1a;stroke:#333}[data-theme="dark"] .post-svg-viz .svg-legend-text{fill:#b8b8b8}[data-theme="dark"] .mermaid .messageText{fill:#d4d4d4!important}[data-theme="dark"] .mermaid .messageLine0,[data-theme="dark"] .mermaid .messageLine1{stroke:#b8b8b8!important}[data-theme="dark"] .mermaid .sequenceNumber{fill:#fff!important}[data-theme="dark"] .mermaid line.actor-line{stroke:#888!important}[data-theme="dark"] .mermaid rect.actor{fill:#2a2a2a!important;stroke:#666!important}[data-theme="dark"] .mermaid text.actor>tspan{fill:#d4d4d4!important}[data-theme="dark"] .mermaid .note{fill:#2a2a2a!important;stroke:#555!important}[data-theme="dark"] .mermaid .noteText{fill:#d4d4d4!important}[data-theme="dark"] .mermaid .activation0{fill:#333!important;stroke:#666!important}[data-theme="dark"] .mermaid .loopText>tspan{fill:#d4d4d4!important}[data-theme="dark"] .mermaid marker path{fill:#b8b8b8!important;stroke:#b8b8b8!important}[data-theme="dark"] .mermaid .edgeLabel{background-color:#1a1a1a!important;color:#d4d4d4!important}[data-theme="dark"] .mermaid .edgeLabel span{color:#d4d4d4!important}[data-theme="dark"] .mermaid .label{color:#d4d4d4!important}</style> <blockquote> <p><strong>TL;DR:</strong> OpenAI is showing ads in ChatGPT. Perplexity tried and pulled back. Google is taking a measured approach. Meanwhile, the real action is happening underneath: regions of vector embedding space near high-value queries are becoming the new commercially contested territory - the “shelf space” of the AI era. GEO (Generative Engine Optimization) and RAG poisoning are points on the same spectrum, and nobody is connecting the security research, the marketing industry, and the mechanism design papers. This post maps the landscape, identifies the gaps, and proposes a framework for thinking about embedding space as an economic system.</p> </blockquote> <p>Three months ago, OpenAI flipped a switch and started showing ads inside ChatGPT. Criteo signed on as the first ad-tech partner. Smartly followed days later with something even more radical - conversational ad formats where clicking a sponsored suggestion drops you into <em>another chatbot dialogue</em> designed to sell you something. Meanwhile, Anthropic ran a Super Bowl ad mocking the whole idea, and Perplexity quietly pulled its own ads after they tanked user experience.</p> <p>We are watching, in real time, the birth of the next trillion-dollar advertising market. And almost nobody is talking about what’s actually happening underneath.</p> <p>I’ve spent the last few weeks reading every paper, press release, and pitch deck I could find on LLM advertising. What I found is a field that’s moving fast on the surface - auction mechanisms, ad formats, CPM pricing - while ignoring a structural problem that could define the next decade of the internet:</p> <p><strong>Vector embedding space is the new commercially contested territory. It has no transparency, no regulation, and no honest market mechanism. And people are already gaming it.</strong></p> <p>This post lays out the landscape, the open problems, and a framework for thinking about what comes next.</p> <hr/> <h2 id="a-brief-history-of-attention-markets">A Brief History of Attention Markets</h2> <p>Every era of the internet created a new scarce resource and then built a billion-dollar market around bidding for it.</p> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0;margin:1.5rem auto;max-width:800px;" role="img" aria-label="Timeline of attention markets: Shelf Space (1960s, ~$50B/yr), PageRank (1998, ~$200B/yr), Feed Position (2010, ~$300B/yr), and Embedding Space (2024, market size unknown)"> <div style="position:relative;padding:1.2rem 0.8rem;border-left:3px solid #8b7355;text-align:center;"> <div style="position:absolute;top:0;left:-7px;width:11px;height:11px;border-radius:50%;background:#8b7355;"></div> <div style="font-size:0.65rem;letter-spacing:0.08em;color:#8b7355;font-weight:600;margin-bottom:0.4rem;">1960s - 1990s</div> <div style="font-size:1.05rem;font-weight:700;color:var(--qa-text,#4a4540);line-height:1.2;">Shelf Space</div> <div style="font-size:0.75rem;color:var(--qa-text-muted,#9e9788);margin-top:0.4rem;line-height:1.4;">Physical proximity<br/>to consumers</div> <div style="margin-top:0.6rem;font-size:0.7rem;color:var(--qa-text-muted,#9e9788);">Mechanism: <strong style="color:#8b7355;">Slotting fees</strong></div> <div style="margin-top:0.3rem;font-size:1.1rem;font-weight:700;color:#8b7355;">~&#36;50B<span style="font-size:0.65rem;font-weight:400;">/yr</span></div> </div> <div style="position:relative;padding:1.2rem 0.8rem;border-left:3px solid #5578a0;text-align:center;"> <div style="position:absolute;top:0;left:-7px;width:11px;height:11px;border-radius:50%;background:#5578a0;"></div> <div style="font-size:0.65rem;letter-spacing:0.08em;color:#5578a0;font-weight:600;margin-bottom:0.4rem;">1998 - 2015</div> <div style="font-size:1.05rem;font-weight:700;color:var(--qa-text,#4a4540);line-height:1.2;">PageRank</div> <div style="font-size:0.75rem;color:var(--qa-text-muted,#9e9788);margin-top:0.4rem;line-height:1.4;">Link graph position<br/>determines visibility</div> <div style="margin-top:0.6rem;font-size:0.7rem;color:var(--qa-text-muted,#9e9788);">Mechanism: <strong style="color:#5578a0;">Keyword auctions</strong></div> <div style="margin-top:0.3rem;font-size:1.1rem;font-weight:700;color:#5578a0;">~&#36;200B<span style="font-size:0.65rem;font-weight:400;">/yr</span></div> </div> <div style="position:relative;padding:1.2rem 0.8rem;border-left:3px solid #7b55a0;text-align:center;"> <div style="position:absolute;top:0;left:-7px;width:11px;height:11px;border-radius:50%;background:#7b55a0;"></div> <div style="font-size:0.65rem;letter-spacing:0.08em;color:#7b55a0;font-weight:600;margin-bottom:0.4rem;">2010 - 2023</div> <div style="font-size:1.05rem;font-weight:700;color:var(--qa-text,#4a4540);line-height:1.2;">Feed Position</div> <div style="font-size:0.75rem;color:var(--qa-text-muted,#9e9788);margin-top:0.4rem;line-height:1.4;">Algorithmic ranking<br/>in content streams</div> <div style="margin-top:0.6rem;font-size:0.7rem;color:var(--qa-text-muted,#9e9788);">Mechanism: <strong style="color:#7b55a0;">Attention auctions</strong></div> <div style="margin-top:0.3rem;font-size:1.1rem;font-weight:700;color:#7b55a0;">~&#36;300B<span style="font-size:0.65rem;font-weight:400;">/yr</span></div> </div> <div style="position:relative;padding:1.2rem 0.8rem;border-left:3px solid #c44040;text-align:center;background:rgba(196,64,64,0.03);border-radius:0 6px 6px 0;"> <div style="position:absolute;top:0;left:-7px;width:11px;height:11px;border-radius:50%;background:#c44040;box-shadow:0 0 0 3px rgba(196,64,64,0.2);"></div> <div style="font-size:0.65rem;letter-spacing:0.08em;color:#c44040;font-weight:600;margin-bottom:0.4rem;">2024 - ???</div> <div style="font-size:1.05rem;font-weight:700;color:#c44040;line-height:1.2;">Embedding Space</div> <div style="font-size:0.75rem;color:var(--qa-text-muted,#9e9788);margin-top:0.4rem;line-height:1.4;">Semantic proximity<br/>in vector space</div> <div style="margin-top:0.6rem;font-size:0.7rem;color:#c44040;font-style:italic;">No market mechanism yet</div> <div style="margin-top:0.3rem;font-size:1.1rem;font-weight:700;color:#c44040;">???</div> </div> </div> <p>In each era, the scarce resource was different, but the pattern was identical:</p> <p><strong>Shelf space</strong> was finite. Procter &amp; Gamble figured out that paying retailers for eye-level placement was worth more than any ad campaign. The “slotting fee” was born - brands literally bidding on physical proximity to consumers.</p> <p><strong>PageRank</strong> turned the link graph into a scarce resource. If your site was semantically close to a high-value query in Google’s index, you had “real estate” worth millions. Google built a <span>$</span>200B/year business by auctioning off the space next to those organic results.</p> <p><strong>Feed position</strong> made attention sequential. Facebook and Instagram learned that controlling the <em>order</em> in which you see things was worth more than controlling the <em>content</em>. The algorithmic feed became the scarce resource, and advertisers bid on interrupting it.</p> <p>Now we’re entering the fourth era. When someone asks ChatGPT “what’s the best running shoe for marathon training?” - the answer isn’t a list of links. It’s a synthesized response generated from the model’s parameters and, increasingly, from documents retrieved via RAG (Retrieval-Augmented Generation). The scarce resource is no longer a slot on a page. It’s <strong>proximity in embedding space</strong> - whether your product’s representation is close enough to the user’s query to be retrieved, cited, or recommended.</p> <p>And unlike every previous era, there’s no visible boundary between the organic result and the commercial influence.</p> <hr/> <h2 id="how-llm-advertising-actually-works-as-of-april-2026">How LLM Advertising Actually Works (As of April 2026)</h2> <p>The public conversation is weirdly disconnected from the technical reality. Here’s what’s actually going on.</p> <h3 id="whats-live-right-now">What’s Live Right Now</h3> <p>OpenAI launched “Sponsored Suggestions” in ChatGPT on February 9, 2026. These are contextually relevant cards that appear below the AI’s organic response - a hotel promotion after a travel query, an air fryer ad after a cooking question. They’re restricted to Free and Go tier users in the US. Plus, Pro, Business, and Enterprise users don’t see them.</p> <p>The initial pricing tells you how they value this attention:</p> <div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin:1.5rem 0;"> <div style="flex:1;min-width:180px;max-width:220px;border:1.5px solid #c44040;border-radius:8px;padding:1rem 1.2rem;text-align:center;background:rgba(196,64,64,0.04);"> <div style="font-size:2rem;font-weight:700;color:#c44040;line-height:1;">~&#36;60</div> <div style="font-size:0.75rem;color:#c44040;margin-top:0.2rem;font-weight:600;">CPM</div> <div style="font-size:0.7rem;color:var(--qa-text-muted,#9e9788);margin-top:0.5rem;">ChatGPT Sponsored<br/>Suggestions (2026)</div> <div style="font-size:0.65rem;color:var(--qa-text-muted,#b0a898);margin-top:0.3rem;">&#36;200K+ min commitment</div> </div> <div style="flex:1;min-width:180px;max-width:220px;border:1.5px solid #5578a0;border-radius:8px;padding:1rem 1.2rem;text-align:center;background:rgba(85,120,160,0.04);"> <div style="font-size:2rem;font-weight:700;color:#5578a0;line-height:1;">~&#36;2-5</div> <div style="font-size:0.75rem;color:#5578a0;margin-top:0.2rem;font-weight:600;">CPM</div> <div style="font-size:0.7rem;color:var(--qa-text-muted,#9e9788);margin-top:0.5rem;">Google Search Ads<br/>(average)</div> <div style="font-size:0.65rem;color:var(--qa-text-muted,#b0a898);margin-top:0.3rem;">Self-serve, no minimum</div> </div> <div style="flex:1;min-width:180px;max-width:220px;border:1.5px solid #0d9488;border-radius:8px;padding:1rem 1.2rem;text-align:center;background:rgba(13,148,136,0.04);"> <div style="font-size:2rem;font-weight:700;color:#0d9488;line-height:1;">1.5x</div> <div style="font-size:0.75rem;color:#0d9488;margin-top:0.2rem;font-weight:600;">CONVERSION</div> <div style="font-size:0.7rem;color:var(--qa-text-muted,#9e9788);margin-top:0.5rem;">LLM referral vs.<br/>other channels</div> <div style="font-size:0.65rem;color:var(--qa-text-muted,#b0a898);margin-top:0.3rem;">Criteo early data</div> </div> </div> <p>OpenAI is pricing this at 12-30x Google because they believe conversational intent is qualitatively different from keyword intent - and the early conversion data backs it up.</p> <p>The key architectural claim OpenAI makes: ads are structurally separated from organic responses. The model generates its answer first, completely independent of advertising. Then the ad system matches a contextually relevant sponsored suggestion and appends it below. The ads do not influence the AI’s actual answers.</p> <p>Hold that thought. We’ll come back to it.</p> <h3 id="whats-being-built">What’s Being Built</h3> <p>The academic community has been busy. Over the past two years, researchers have proposed multiple auction mechanisms for LLM ad placement:</p> <pre><code class="language-mermaid">graph TD
    subgraph "Pre-Generation Mechanisms"
        A["Segment Auctions&lt;br/&gt;(Hajiaghayi et al., 2024)&lt;br/&gt;RAG-based ad allocation&lt;br/&gt;per discourse segment"] 
        B["Position Auctions&lt;br/&gt;(Balseiro et al., 2025)&lt;br/&gt;Extending traditional slots&lt;br/&gt;to AI-generated content"]
    end

    subgraph "Post-Generation Mechanisms"
        C["Token Auctions&lt;br/&gt;(Dutting et al., 2024)&lt;br/&gt;WWW Best Paper&lt;br/&gt;Token-by-token bidding"]
        D["Truthful Aggregation&lt;br/&gt;(Soumalias et al., 2024)&lt;br/&gt;RLHF-style reward&lt;br/&gt;aggregation"]
    end

    subgraph "Integrated Mechanisms"
        E["LLM-Auction&lt;br/&gt;(Zhao et al., Dec 2025)&lt;br/&gt;Learning-based generative&lt;br/&gt;auction, end-to-end"]
        F["Genre-Based Insertion&lt;br/&gt;(Jan 2026)&lt;br/&gt;Decoupled response-level&lt;br/&gt;ad placement"]
    end

    A --&gt; G["LLM generates response&lt;br/&gt;conditioned on winning ads"]
    B --&gt; G
    C --&gt; H["Auction selects/aggregates&lt;br/&gt;during token generation"]
    D --&gt; H
    E --&gt; I["Auction and generation&lt;br/&gt;jointly optimized"]
    F --&gt; I

    style A fill:#fff3cd,stroke:#856404,color:#4a3800
    style B fill:#fff3cd,stroke:#856404,color:#4a3800
    style C fill:#d1ecf1,stroke:#0c5460,color:#0a3d47
    style D fill:#d1ecf1,stroke:#0c5460,color:#0a3d47
    style E fill:#d4edda,stroke:#155724,color:#14401d
    style F fill:#d4edda,stroke:#155724,color:#14401d
    style G fill:#f0f0f0,stroke:#666,color:#333
    style H fill:#f0f0f0,stroke:#666,color:#333
    style I fill:#f0f0f0,stroke:#666,color:#333
</code></pre> <p>The key split is between mechanisms that decide ad allocation <em>before</em> the LLM generates a response, and those that let the LLM generate multiple candidate responses and then pick or aggregate. Pre-generation is cheaper (one forward pass) but ignores externalities - how ads interact with the surrounding context. Post-generation is higher quality but requires multiple inference passes, which gets expensive fast when you’re serving hundreds of millions of queries per day.</p> <p>Google Research’s token auction (WWW 2024 Best Paper, Dutting et al.) was the first rigorous treatment. They proved that under robust preferences, monotone aggregation functions enable second-price-style payments - bringing classical auction theory into the LLM generation process. It’s elegant theory. It also requires access to model weights and per-token distributions, which makes it impractical for third-party advertisers.</p> <p>The most recent work, LLM-Auction (Zhao et al., December 2025), tries to solve this by integrating the auction directly into the LLM’s generation process via reinforcement learning. The model learns to jointly optimize response quality and ad revenue. This is probably closest to what production systems will eventually look like.</p> <h3 id="whats-being-refused">What’s Being Refused</h3> <p>There are now three distinct philosophies among major AI companies:</p> <table> <thead> <tr> <th>Company</th> <th>Stance</th> <th>Rationale</th> </tr> </thead> <tbody> <tr> <td><strong>OpenAI</strong></td> <td>Ads in free tiers, ad-free for paying users</td> <td>Revenue necessity - <span>$</span>17B projected burn rate, 95% of 800M users don’t pay</td> </tr> <tr> <td><strong>Anthropic</strong></td> <td>No ads, period (for now)</td> <td>Trust-first - “advertising incentives, once introduced, tend to expand over time”</td> </tr> <tr> <td><strong>Google</strong></td> <td>Ads in AI Overviews, not in Gemini chat (yet)</td> <td>Measured rollout - ads in Search AI, evaluating Gemini chat separately</td> </tr> <tr> <td><strong>Perplexity</strong></td> <td>Tried ads, pulled them</td> <td>UX collapsed, measurement was impossible</td> </tr> <tr> <td><strong>Meta</strong></td> <td>Using conversations to <em>target</em> ads on other platforms</td> <td>Different model - the LLM isn’t the ad surface, it’s the signal source</td> </tr> </tbody> </table> <p>Pay attention to Meta’s row. It’s easy to gloss over, but it might be the most consequential strategy on this list. Meta isn’t putting ads <em>inside</em> the AI conversation - they’re using the conversation as a signal source to target ads <em>everywhere else</em>. When you tell Meta AI about your kitchen renovation plans, that context doesn’t surface as a sponsored suggestion in the chat. It surfaces as a Home Depot ad in your Instagram feed an hour later. This is arguably more invasive than OpenAI’s approach, because the user never connects the conversation to the ad. There’s no “Sponsored Suggestion” card to notice and evaluate. The commercial extraction is invisible by design. And because Meta controls both the conversational surface (WhatsApp, Messenger, Instagram DMs) and the ad surfaces (Feed, Stories, Reels), they can close this loop without any third-party ad-tech infrastructure. It’s vertically integrated attention arbitrage - and it’s the approach most likely to scale silently while everyone debates whether ChatGPT should show ad cards.</p> <p>The Anthropic position is worth quoting because it identifies the core tension: ad-supported products create pressure to optimize for engagement, repeat visits, and extended conversations. Those metrics look like success. But they tell you nothing about whether the user actually solved their problem. A truly helpful response might end the conversation in two turns.</p> <hr/> <h2 id="the-part-nobody-is-talking-about-embedding-space-as-commercial-real-estate">The Part Nobody Is Talking About: Embedding Space as Commercial Real Estate</h2> <p>This is where the public conversation is lagging the technical reality by about 18 months.</p> <p>Every RAG-based LLM system (which includes Perplexity, ChatGPT with browsing, Google AI Overviews, and most enterprise deployments) works roughly like this:</p> <pre><code class="language-mermaid">sequenceDiagram
    participant User
    participant LLM
    participant Retriever
    participant VectorDB as Vector Database
    participant Web as Web / Knowledge Base

    User-&gt;&gt;LLM: "Best CRM for startups?"
    LLM-&gt;&gt;Retriever: Generate embedding for query
    Retriever-&gt;&gt;VectorDB: Find k-nearest documents
    VectorDB--&gt;&gt;Retriever: Top-k documents by cosine similarity
    Retriever--&gt;&gt;LLM: Retrieved context
    Note over LLM: Generate response grounded&lt;br/&gt;in retrieved documents
    LLM--&gt;&gt;User: "Based on my research,&lt;br/&gt;here are the top options..."
</code></pre> <p>The retrieval step is where commercial value concentrates. Documents that are embedded close to high-value queries get retrieved. Documents that get retrieved get cited. Documents that get cited influence the model’s response. This creates a chain of influence that starts in vector space and ends in a user’s purchasing decision.</p> <p>The critical observation: <strong>regions of embedding space near commercially valuable queries function exactly like shelf space or PageRank - they’re a scarce resource with economic value, and people are already bidding for them.</strong></p> <p>They’re just not calling it advertising. They’re calling it “Generative Engine Optimization.”</p> <h3 id="geo-the-seo-of-embedding-space">GEO: The SEO of Embedding Space</h3> <p>Generative Engine Optimization (GEO) was formalized by researchers at Princeton in a KDD 2024 paper. The idea is simple: just as SEO optimizes web pages to rank higher in Google’s index, GEO optimizes content to be retrieved and cited by LLMs.</p> <p>The GEO industry has exploded. Companies like Profound, Semrush, and Wellows now sell tools that track brand visibility across LLMs, measure “recommendation share,” and suggest content modifications to improve retrieval rates. It’s a legitimate optimization practice - in the same way that white-hat SEO is legitimate.</p> <p>But there’s a shadowy flip side. Security researchers have demonstrated that the same embedding space can be manipulated adversarially:</p> <p><strong>PoisonedRAG</strong> (USENIX Security 2025, Zou et al.) showed that injecting just 5 carefully crafted documents into a knowledge base containing millions of texts achieves ~90% attack success rate. The attacker controls what the LLM says about a target question. Five documents. In millions.</p> <p><strong>POISONCRAFT</strong> extended this to practical, black-box settings - the attacker doesn’t need to know which retriever or LLM the target system uses.</p> <p><strong>RAGForensics</strong> (WWW 2025) built a traceback system to identify poisoned documents, acknowledging that the threat is real enough to need forensic tools.</p> <p>What nobody is saying out loud: <strong>GEO and RAG poisoning are points on the same spectrum.</strong> The techniques differ in degree, not in kind. Both involve crafting documents to manipulate their position in embedding space. GEO does it to be “relevant.” RAG poisoning does it to be “adversarial.” The boundary between the two is a policy question, not a technical one.</p> <pre><code class="language-mermaid">graph LR
    subgraph "The Embedding Manipulation Spectrum"
        A["Legitimate&lt;br/&gt;Content Creation"] --&gt; B["White-hat GEO&lt;br/&gt;(Structured data,&lt;br/&gt;topic authority)"]
        B --&gt; C["Aggressive GEO&lt;br/&gt;(Keyword stuffing&lt;br/&gt;for embeddings)"]
        C --&gt; D["Gray Zone&lt;br/&gt;(Adversarial document&lt;br/&gt;crafting for retrieval)"]
        D --&gt; E["RAG Poisoning&lt;br/&gt;(PoisonedRAG,&lt;br/&gt;POISONCRAFT)"]
    end

    style A fill:#d4edda,stroke:#155724,color:#14401d
    style B fill:#d4edda,stroke:#155724,color:#14401d
    style C fill:#fff3cd,stroke:#856404,color:#4a3800
    style D fill:#fff3cd,stroke:#856404,color:#4a3800
    style E fill:#f8d7da,stroke:#721c24,color:#4a1118
</code></pre> <p>Nobody has drawn this spectrum explicitly. The security community publishes attack papers. The marketing community publishes optimization guides. The mechanism design community publishes auction papers. They’re all working on different faces of the same problem and not talking to each other.</p> <hr/> <h2 id="the-firewall-question">The Firewall Question</h2> <p>Let’s return to OpenAI’s architectural claim: ads don’t influence organic responses.</p> <p>This is the single most important empirical question in LLM advertising, and as far as I can tell, nobody has tested it rigorously.</p> <p>Why it matters: current transformer architectures don’t have a hard separation between “context I should be influenced by” and “context I should ignore.” Attention is global. If an ad - or an ad-selection signal - is present anywhere in the context window or the system prompt, there’s a potential pathway for it to influence the generated response. Even if the influence is subtle. Even if it’s unintentional.</p> <p>The existing prompt injection literature proves this is more than theoretical. Medical LLMs were shown to be vulnerable to injection attacks that succeeded in 94.4% of trials - including extremely high-harm scenarios. Multimodal injection attacks achieve 64% success rates by hiding instructions in images. The OWASP LLM Top 10 (2025 revision) explicitly added “Vector and Embedding Weaknesses” as a new category, noting that adversarial embeddings can be crafted to match arbitrary queries while containing malicious content.</p> <p>To be clear, OpenAI isn’t naively injecting ad text into the model’s prompt. Their architecture is more sophisticated than that - the ad matching happens after response generation, not before. But as the system evolves toward Smartly-style conversational ad formats (where the ad <em>is</em> a secondary chatbot dialogue), the separation gets murkier. And for RAG-based systems where advertising content enters the retrieval pipeline, the separation may not exist at all.</p> <p><strong>An honest empirical test would look like this:</strong></p> <pre><code class="language-mermaid">graph TD
    A["Define test query set&lt;br/&gt;(500+ product-related queries&lt;br/&gt;across 10 categories)"] --&gt; B["Condition A: Baseline&lt;br/&gt;Query model with&lt;br/&gt;no ad context"]
    A --&gt; C["Condition B: Ad-adjacent&lt;br/&gt;Query model with ad&lt;br/&gt;context present in system"]
    A --&gt; D["Condition C: Explicit separation&lt;br/&gt;Query model with ad context&lt;br/&gt;+ 'ignore ads' instruction"]
    
    B --&gt; E["Measure: Brand mention distributions,&lt;br/&gt;recommendation rankings,&lt;br/&gt;sentiment toward products,&lt;br/&gt;response length &amp; specificity"]
    C --&gt; E
    D --&gt; E

    E --&gt; F["Statistical tests for&lt;br/&gt;recommendation drift&lt;br/&gt;between conditions"]
    F --&gt; G{"Does the 'organic'&lt;br/&gt;response shift when&lt;br/&gt;ads are present?"}
    G --&gt;|Yes| H["Firewall is leaky.&lt;br/&gt;Quantify the leak."]
    G --&gt;|No| I["Firewall holds.&lt;br/&gt;Publish that too."]

    style G fill:#fff3cd,stroke:#856404,color:#4a3800
    style H fill:#f8d7da,stroke:#721c24,color:#4a1118
    style I fill:#d4edda,stroke:#155724,color:#14401d
</code></pre> <p>This study doesn’t exist yet. It should. The result matters regardless of which direction it goes - either the firewall holds (which validates OpenAI’s approach and gives regulators something to build on) or it doesn’t (which validates Anthropic’s concerns and creates urgency for architectural solutions).</p> <hr/> <h2 id="what-a-proper-market-mechanism-would-require">What a Proper Market Mechanism Would Require</h2> <p>If we accept that embedding space has commercial value and that people are going to compete for it one way or another, the question becomes: can we build a market mechanism that’s transparent and fair, rather than letting the gray market (GEO-as-advertising) operate in the shadows?</p> <p>My rough sketch of what that would need:</p> <h3 id="1-define-the-resource-being-traded">1. Define the Resource Being Traded</h3> <p>In search advertising, the resource is a keyword query. In social media advertising, it’s a user profile + content slot. In embedding space, the resource is <strong>proximity to a query region</strong> - a neighborhood in vector space that captures a class of user intents.</p> <p>This needs formalization. What’s the right geometric primitive? Voronoi cells around query clusters? epsilon-balls in cosine space? The mechanism design community has been designing auctions without clearly defining the thing being auctioned.</p> <p>To make this concrete, consider a toy example. Take the query “best CRM for startups” and embed it alongside the top 50 web pages about CRM software using a standard retriever (say, <code class="language-plaintext highlighter-rouge">text-embedding-3-large</code>). Project the embeddings down to 2D via UMAP. What you’ll see is something like this:</p> <svg viewBox="0 0 800 520" xmlns="http://www.w3.org/2000/svg" class="post-svg-viz" role="img" aria-label="2D UMAP projection of document embeddings around the query best CRM for startups, showing HubSpot content clustering near the query centroid within cosine distance 0.15, mid-market competitors at medium distance, and enterprise CRM content far away" style="width:100%;max-width:800px;margin:1.5rem auto;display:block;font-family:'DM Mono','JetBrains Mono',ui-monospace,monospace;"> <defs> <radialGradient id="heatmap" cx="38%" cy="38%" r="32%" fx="38%" fy="38%"> <stop offset="0%" stop-color="#c44040" stop-opacity="0.12"/> <stop offset="40%" stop-color="#c44040" stop-opacity="0.05"/> <stop offset="100%" stop-color="#c44040" stop-opacity="0"/> </radialGradient> <radialGradient id="retrievalGlow" cx="50%" cy="50%" r="50%"> <stop offset="70%" stop-color="#ff7a59" stop-opacity="0.06"/> <stop offset="100%" stop-color="#ff7a59" stop-opacity="0"/> </radialGradient> <filter id="glow"> <feGaussianBlur stdDeviation="2" result="blur"/> <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> </filter> </defs> <rect class="svg-bg" width="800" height="520" rx="8" fill="#fdfbf7" stroke="#ddd9ce" stroke-width="1"/> <g class="svg-grid" stroke="#e8e4db" stroke-width="0.5"> <line class="svg-grid" x1="60" y1="40" x2="60" y2="440"/> <line class="svg-grid" x1="60" y1="440" x2="760" y2="440"/> <line class="svg-grid" x1="60" y1="140" x2="760" y2="140" stroke-dasharray="2,6" opacity="0.5"/> <line class="svg-grid" x1="60" y1="240" x2="760" y2="240" stroke-dasharray="2,6" opacity="0.5"/> <line class="svg-grid" x1="60" y1="340" x2="760" y2="340" stroke-dasharray="2,6" opacity="0.5"/> <line class="svg-grid" x1="235" y1="40" x2="235" y2="440" stroke-dasharray="2,6" opacity="0.5"/> <line class="svg-grid" x1="410" y1="40" x2="410" y2="440" stroke-dasharray="2,6" opacity="0.5"/> <line class="svg-grid" x1="585" y1="40" x2="585" y2="440" stroke-dasharray="2,6" opacity="0.5"/> </g> <text class="svg-label-muted" x="410" y="475" text-anchor="middle" fill="#9e9788" font-size="11" letter-spacing="0.1em">UMAP-1</text> <text class="svg-label-muted" x="20" y="240" text-anchor="middle" fill="#9e9788" font-size="11" letter-spacing="0.1em" transform="rotate(-90,20,240)">UMAP-2</text> <ellipse cx="290" cy="195" rx="260" ry="220" fill="url(#heatmap)"/> <ellipse cx="290" cy="195" rx="175" ry="165" fill="url(#retrievalGlow)" stroke="#c44040" stroke-width="1.2" stroke-dasharray="6,4" opacity="0.6"/> <text x="460" y="90" fill="#c44040" font-size="10" opacity="0.7" font-style="italic">cos &lt; 0.15</text> <text x="460" y="103" fill="#c44040" font-size="10" opacity="0.7" font-style="italic">retrieval radius</text> <g stroke="#ff7a59" stroke-width="0.8" opacity="0.25"> <line x1="290" y1="195" x2="210" y2="130"/> <line x1="290" y1="195" x2="170" y2="220"/> <line x1="290" y1="195" x2="195" y2="290"/> <line x1="290" y1="195" x2="340" y2="110"/> </g> <g class="svg-label-muted" fill="#b0a898" font-size="8.5" font-style="italic"> <text x="242" y="155">0.08</text> <text x="218" y="205">0.11</text> <text x="228" y="252">0.14</text> <text x="318" y="145">0.09</text> </g> <g filter="url(#glow)"> <circle cx="290" cy="195" r="8" fill="#c44040"/> <circle cx="290" cy="195" r="12" fill="none" stroke="#c44040" stroke-width="1.5" opacity="0.3"/> </g> <text class="svg-label-dark" x="305" y="192" fill="#1a1a1a" font-size="11.5" font-weight="600">"best CRM for startups"</text> <circle cx="210" cy="130" r="6" fill="#ff7a59"/> <text class="svg-label" x="224" y="128" fill="#4a4540" font-size="10">HubSpot blog post</text> <circle cx="170" cy="220" r="6" fill="#ff7a59"/> <text class="svg-label" x="84" y="218" fill="#4a4540" font-size="10">HubSpot comparison</text> <circle cx="195" cy="290" r="6" fill="#ff7a59"/> <text class="svg-label" x="82" y="298" fill="#4a4540" font-size="10">HubSpot "free CRM"</text> <circle cx="340" cy="110" r="6" fill="#ff7a59"/> <text class="svg-label" x="354" y="108" fill="#4a4540" font-size="10">HubSpot startup tips</text> <rect x="285" y="328" width="10" height="10" rx="2" fill="#6c5ce7"/> <text class="svg-label" x="302" y="338" fill="#4a4540" font-size="10">Pipedrive review</text> <rect x="218" y="350" width="10" height="10" rx="2" fill="#6c5ce7"/> <text class="svg-label" x="235" y="360" fill="#4a4540" font-size="10">Freshsales startup guide</text> <circle cx="590" cy="130" r="5.5" fill="#b8b0a4"/> <text class="svg-label-muted" x="603" y="134" fill="#9e9788" font-size="10">Salesforce enterprise</text> <circle cx="620" cy="210" r="5.5" fill="#b8b0a4"/> <text class="svg-label-muted" x="633" y="214" fill="#9e9788" font-size="10">Salesforce pricing</text> <circle cx="640" cy="290" r="5.5" fill="#b8b0a4"/> <text class="svg-label-muted" x="653" y="294" fill="#9e9788" font-size="10">Oracle CX docs</text> <circle cx="670" cy="365" r="5.5" fill="#b8b0a4"/> <text class="svg-label-muted" x="683" y="369" fill="#9e9788" font-size="10">SAP overview</text> <g transform="translate(555, 420)"> <rect class="svg-legend-bg" x="-8" y="-12" width="210" height="95" rx="4" fill="#fdfbf7" stroke="#e8e4db" stroke-width="0.5"/> <circle cx="8" cy="4" r="5" fill="#c44040"/> <text class="svg-legend-text" x="20" y="8" fill="#6b6560" font-size="9.5">Query centroid</text> <circle cx="8" cy="24" r="5" fill="#ff7a59"/> <text class="svg-legend-text" x="20" y="28" fill="#6b6560" font-size="9.5">HubSpot (4 docs, cos &lt; 0.15)</text> <rect x="3" y="39" width="10" height="10" rx="2" fill="#6c5ce7"/> <text class="svg-legend-text" x="20" y="48" fill="#6b6560" font-size="9.5">Mid-market (2 docs)</text> <circle cx="8" cy="64" r="5" fill="#b8b0a4"/> <text class="svg-legend-text" x="20" y="68" fill="#6b6560" font-size="9.5">Enterprise (distant)</text> </g> <text class="svg-label-muted" x="72" y="30" fill="#9e9788" font-size="10" letter-spacing="0.05em">EMBEDDING SPACE: 2D UMAP PROJECTION</text> </svg> <div class="caption" style="text-align:center;font-size:0.8rem;color:var(--qa-text-muted,#888);margin-top:0.25rem;"> Document embeddings around a commercial query. HubSpot's content marketing dominates the retrieval neighborhood, effectively occupying the most valuable "real estate" in vector space. </div> <p>HubSpot has four documents within cosine distance 0.15 of this query. Salesforce has zero. In a top-5 retrieval, HubSpot content dominates the context window, and the LLM’s response will reflect that. HubSpot didn’t pay for this - they earned it through years of content marketing that happens to embed well. But the <em>effect</em> is identical to a paid placement: commercial content occupying the scarce positions nearest a high-value query.</p> <p>This is what I mean by <strong>embedding rent</strong> - the implicit economic value of occupying a region of vector space near commercially valuable queries. We can sketch a rough formalization:</p> <p>For a query \(q\) with commercial value \(V(q)\) (expected revenue per conversion), the <strong>embedding rent</strong> of a document \(d\) is:</p> \[R(d, q) = V(q) \cdot P(\text{retrieve} \mid d, q) \cdot P(\text{cite} \mid \text{retrieve}) \cdot P(\text{convert} \mid \text{cite})\] <p>where \(P(\text{retrieve} \mid d, q)\) depends on the cosine similarity \(\text{sim}(e_d, e_q)\) and the retrieval threshold \(k\). In practice, retrieval probability follows a sharp sigmoid around the \(k\)-th nearest neighbor boundary - if you’re inside the top-\(k\), you have influence; if you’re outside, you’re invisible. This creates a cliff-edge dynamic where small improvements in embedding proximity produce large jumps in commercial value.</p> <p>The total rent for a query region \(Q\) is:</p> \[R_{\text{total}}(d) = \sum_{q \in Q} \lambda(q) \cdot R(d, q)\] <p>where \(\lambda(q)\) is query frequency. High-traffic, high-intent queries (“best CRM for startups,” “cheapest flights to Tokyo”) have the highest embedding rent - and are therefore the most attractive targets for both legitimate GEO and adversarial manipulation.</p> <p>Today that rent is “paid” through content investment. Tomorrow it could be paid through an auction. The question is who designs that auction, and whether the current tenants - the GEO optimizers - get grandfathered in or priced out.</p> <h3 id="2-make-manipulation-unprofitable">2. Make Manipulation Unprofitable</h3> <p>Right now, a rational advertiser faces a choice: pay <span>$</span>60 CPM to place a legitimate ad in ChatGPT, or invest in GEO/adversarial document crafting to manipulate the organic response for free. If the organic manipulation channel is cheaper and more effective, the legitimate channel collapses. This is exactly what happened with early search engines before Google figured out how to devalue link farms.</p> <p>The mechanism needs to ensure that bidding through the auction is strictly preferable to manipulating the embedding space directly. Formally, an advertiser chooses between:</p> <ul> <li><strong>Auction channel:</strong> Pay bid \(b\) per impression, get guaranteed placement with probability \(P_a(b)\)</li> <li><strong>Manipulation channel:</strong> Invest cost \(c_m\) in GEO/adversarial docs, get organic retrieval with probability \(P_m(c_m)\), but risk detection with probability \(P_d(c_m)\) and penalty \(F\)</li> </ul> <p>The advertiser prefers the auction when:</p> \[V \cdot P_a(b) - b &gt; V \cdot P_m(c_m) \cdot (1 - P_d(c_m)) - c_m - P_d(c_m) \cdot F\] <p>The platform controls \(P_d\) (detection capability) and \(F\) (penalty for detected manipulation). The insight from search advertising history: Google made manipulation unprofitable not by winning the arms race against SEO spammers (they didn’t, fully), but by making the auction cheap enough and reliable enough that legitimate advertisers preferred it. The detection system only needs to make manipulation <em>risky</em>, not impossible.</p> <p>This is the same dynamic that will play out in embedding space - but only if someone builds the detection infrastructure and the auction mechanism in parallel.</p> <h3 id="3-solve-the-transparency-problem">3. Solve the Transparency Problem</h3> <p>In search, you can see that a result is sponsored. The blue link has a little “Ad” label. In an LLM response, there’s no natural boundary to label. If the model says “I recommend ProductX for your needs,” was that organic or sponsored? The user can’t tell. Research from the University of Michigan (2024) showed users only detect embedded ads in LLM responses 27% of the time. But - critically - once they’re told an ad was present, trust collapses.</p> <p>This suggests the transparency mechanism needs to be <em>architectural</em>, not just a label. Possible directions:</p> <ul> <li><strong>Provenance tracking in RAG pipelines</strong> - tag retrieved documents as sponsored/organic and carry that metadata through to the response</li> <li><strong>Watermarking sponsored content</strong> - embed detectable signals in ad-influenced text segments</li> <li><strong>Separate rendering</strong> - what OpenAI is doing with Sponsored Suggestions, keeping ads visually distinct. This works for appended ads but not for integrated recommendations.</li> </ul> <h3 id="4-build-retrieval-time-defenses">4. Build Retrieval-Time Defenses</h3> <p>The attack papers get all the attention, but the defense side is equally important and far less developed. If embedding space is being manipulated - whether by GEO optimizers or adversarial actors - what can RAG system operators actually do at retrieval time?</p> <p>A few directions are emerging, though none are mature:</p> <ul> <li> <p><strong>Embedding perturbation.</strong> Add calibrated noise to query embeddings before retrieval, then check whether the top-k results are stable across perturbations. Legitimate, high-quality documents tend to be robust - they’re near the query for semantic reasons that survive small shifts. Adversarially crafted documents are often brittle - optimized for a precise point in embedding space that breaks under perturbation. This is analogous to adversarial example detection in computer vision, applied to the retrieval step.</p> </li> <li> <p><strong>Multi-retriever consensus.</strong> Retrieve using two or more embedding models (e.g., OpenAI’s <code class="language-plaintext highlighter-rouge">text-embedding-3-large</code> and Cohere’s <code class="language-plaintext highlighter-rouge">embed-v4</code>) and flag documents that rank highly in one but not the other. Adversarial documents are typically optimized against a specific embedding model’s geometry. Cross-model agreement is a cheap integrity signal.</p> </li> <li> <p><strong>Temporal anomaly detection.</strong> Monitor when documents suddenly appear in high-value retrieval neighborhoods. A legitimate page on “best CRM for startups” accumulates backlinks and content depth over months. A GEO-optimized page materializes overnight with suspiciously perfect embedding proximity. Tracking document “arrival velocity” in retrieval neighborhoods could catch manipulation campaigns early.</p> </li> <li> <p><strong>Retrieval provenance scoring.</strong> Assign trust scores to retrieved documents based on source reputation, publication date, content consistency, and embedding stability over time. Weight the LLM’s context window accordingly - high-trust documents get more influence, low-trust documents get retrieved but down-weighted.</p> </li> </ul> <p>To make the perturbation approach concrete, here’s a sketch of what a retrieval integrity check could look like:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">check_retrieval_integrity</span><span class="p">(</span><span class="n">query_embedding</span><span class="p">,</span> <span class="n">corpus</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span> 
                               <span class="n">n_perturbations</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">noise_scale</span><span class="o">=</span><span class="mf">0.02</span><span class="p">,</span>
                               <span class="n">stability_threshold</span><span class="o">=</span><span class="mf">0.6</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">
    Detect potentially manipulated documents in RAG retrieval
    by checking stability under embedding perturbation.
    </span><span class="sh">"""</span>
    <span class="c1"># Baseline retrieval
</span>    <span class="n">baseline_topk</span> <span class="o">=</span> <span class="nf">retrieve_topk</span><span class="p">(</span><span class="n">query_embedding</span><span class="p">,</span> <span class="n">corpus</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span>
    
    <span class="c1"># Perturbed retrievals
</span>    <span class="n">appearance_counts</span> <span class="o">=</span> <span class="nc">Counter</span><span class="p">()</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n_perturbations</span><span class="p">):</span>
        <span class="n">noise</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="nf">normal</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">noise_scale</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">.</span><span class="n">shape</span><span class="p">)</span>
        <span class="n">perturbed</span> <span class="o">=</span> <span class="nf">normalize</span><span class="p">(</span><span class="n">query_embedding</span> <span class="o">+</span> <span class="n">noise</span><span class="p">)</span>
        <span class="n">perturbed_topk</span> <span class="o">=</span> <span class="nf">retrieve_topk</span><span class="p">(</span><span class="n">perturbed</span><span class="p">,</span> <span class="n">corpus</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">perturbed_topk</span><span class="p">:</span>
            <span class="n">appearance_counts</span><span class="p">[</span><span class="n">doc</span><span class="p">.</span><span class="nb">id</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
    
    <span class="c1"># Score each baseline result by stability
</span>    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">baseline_topk</span><span class="p">:</span>
        <span class="n">stability</span> <span class="o">=</span> <span class="n">appearance_counts</span><span class="p">[</span><span class="n">doc</span><span class="p">.</span><span class="nb">id</span><span class="p">]</span> <span class="o">/</span> <span class="n">n_perturbations</span>
        <span class="n">results</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span>
            <span class="sh">'</span><span class="s">doc</span><span class="sh">'</span><span class="p">:</span> <span class="n">doc</span><span class="p">,</span>
            <span class="sh">'</span><span class="s">stability</span><span class="sh">'</span><span class="p">:</span> <span class="n">stability</span><span class="p">,</span>
            <span class="sh">'</span><span class="s">suspicious</span><span class="sh">'</span><span class="p">:</span> <span class="n">stability</span> <span class="o">&lt;</span> <span class="n">stability_threshold</span>
        <span class="p">})</span>
    
    <span class="c1"># Flag: docs that appear in baseline top-k but are
</span>    <span class="c1"># fragile under perturbation are likely adversarially
</span>    <span class="c1"># optimized for a precise point in embedding space
</span>    <span class="k">return</span> <span class="n">results</span>
</code></pre></div></div> <p>The intuition: a legitimate document about CRMs is near the query “best CRM for startups” because of genuine semantic overlap across many dimensions. Perturb the query slightly, and the document stays nearby. An adversarially crafted document, however, is often optimized for a narrow region - it exploits specific dimensions of the embedding geometry to achieve high similarity, and that optimization is brittle. A 2% perturbation in the query embedding may push it out of the top-k entirely.</p> <p>None of these are silver bullets, and all have false-positive costs. But the point is that defense at the retrieval layer is cheaper and more practical than trying to make the LLM itself robust to manipulated context. You don’t need to solve prompt injection if you can filter the poisoned documents before they reach the prompt.</p> <h3 id="5-handle-the-privacy-paradox">5. Handle the Privacy Paradox</h3> <p>LLM conversations contain deeply personal information. People share health concerns, relationship problems, financial anxieties. Anthropic’s analysis found that “an appreciable portion” of Claude conversations involve sensitive topics. The same personal context that makes LLM ads potentially hyper-relevant also makes them potentially creepy and intrusive.</p> <p>OpenAI says conversations are never shared with advertisers and that ads don’t appear near health, mental health, or political topics. But as a former OpenAI researcher pointed out, “the company is building an economic engine whose incentives will eventually override its own rules.”</p> <hr/> <h2 id="open-problems-worth-working-on">Open Problems Worth Working On</h2> <p>I want to close with what I think are the most important research directions - not because I have the answers, but because I want more people working on them.</p> <p><strong>The Firewall Integrity Problem.</strong> As described above. Empirical measurement of whether ad context influences organic responses, across architectures and models. This is the most urgent open question.</p> <p><strong>Embedding Space Economics.</strong> Formal treatment of embedding proximity as a priced resource. Game-theoretic analysis of the interaction between legitimate ad mechanisms and embedding manipulation. Under what conditions do GEO-style tactics undermine auction-based advertising? What mechanism modifications make manipulation unprofitable?</p> <p><strong>The Audit Problem.</strong> How do you determine, from the outside, whether an LLM’s product recommendations are commercially influenced? Existing brand visibility tools are designed for marketers optimizing their presence. We need tools designed for regulators and researchers detecting hidden influence. Counterfactual probing, temporal drift analysis, cross-model consistency checks - the methodology needs to be developed and standardized.</p> <p><strong>Agentic Commerce and the Principal-Agent Collapse.</strong> This one deserves more than a paragraph, because it’s the endgame of everything discussed above.</p> <p>When an LLM books a flight for you, it’s acting as your agent in the economic sense - making decisions on your behalf, with your money, according to your preferences. Classical principal-agent theory tells us this works when the agent’s incentives are aligned with the principal’s. But what happens when the agent serves two principals?</p> <p>Concrete scenario:</p> <div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin:1.5rem 0;"> <div class="scenario-card" style="flex:1;min-width:260px;max-width:380px;border:1.5px solid #0d9488;border-radius:8px;overflow:hidden;background:var(--qa-bg,#fff);"> <div style="background:#0d9488;color:#fff;padding:0.5rem 1rem;font-size:0.8rem;font-weight:600;letter-spacing:0.03em;">WHAT THE USER SEES</div> <div style="padding:1rem;font-size:0.82rem;line-height:1.6;color:var(--qa-text,#1a1a1a);"> <div style="color:var(--qa-text-muted,#9e9788);font-size:0.72rem;margin-bottom:0.5rem;">You &rarr; AI Assistant</div> <em>"Book me a hotel in Tokyo for next week, under &#36;200/night, close to Shinjuku station."</em> <div style="margin-top:0.75rem;padding:0.6rem;background:rgba(13,148,136,0.06);border-radius:4px;"> <strong>AI:</strong> I found the <strong>Hyatt Regency Tokyo</strong> at &#36;195/night, 5 minutes from Shinjuku. Great reviews, fits your budget. Want me to book it? </div> <div style="margin-top:0.5rem;text-align:center;color:#0d9488;font-size:0.75rem;">&#10003; Constraint satisfied. User moves on.</div> </div> </div> <div class="scenario-card" style="flex:1;min-width:260px;max-width:380px;border:1.5px solid #c44040;border-radius:8px;overflow:hidden;background:var(--qa-bg,#fff);"> <div style="background:#c44040;color:#fff;padding:0.5rem 1rem;font-size:0.8rem;font-weight:600;letter-spacing:0.03em;">WHAT THE PIPELINE SEES</div> <div style="padding:1rem;font-size:0.82rem;line-height:1.6;color:var(--qa-text,#1a1a1a);"> <div style="color:var(--qa-text-muted,#9e9788);font-size:0.72rem;margin-bottom:0.5rem;">Retrieval results ranked by cosine similarity</div> <div style="font-family:'DM Mono',monospace;font-size:0.72rem;"> <div style="padding:0.25rem 0;color:var(--qa-text,#1a1a1a);"><span style="color:#c44040;font-weight:700;">0.92</span> Hyatt Regency - GEO optimized</div> <div style="padding:0.25rem 0;color:var(--qa-text,#1a1a1a);"><span style="color:#c44040;font-weight:700;">0.91</span> Hyatt Regency - sponsored page</div> <div style="padding:0.25rem 0;color:var(--qa-text-muted,#9e9788);"><span style="font-weight:700;">0.89</span> Hilton Shinjuku - organic</div> <div style="padding:0.25rem 0;color:var(--qa-text-muted,#9e9788);"><span style="font-weight:700;">0.87</span> <strong style="color:#0d9488;">Tokyu Stay &#36;142/night</strong> - organic</div> <div style="padding:0.25rem 0;color:var(--qa-text-muted,#b0a898);"><span style="font-weight:700;">0.84</span> Hotel Gracery - organic</div> </div> <div style="margin-top:0.5rem;text-align:center;color:#c44040;font-size:0.75rem;">&#36;53/night cheaper option buried at rank 4</div> </div> </div> </div> <p>The agent didn’t lie. It gave a valid option within constraints. It just didn’t give the <em>best</em> option, because the retrieval pipeline - the agent’s “eyes” - saw the world through a commercially distorted lens.</p> <p>This is harder to detect than a banner ad. The user asked for a decision, got a reasonable one, and moved on. The <span>$</span>53/night difference multiplied across millions of agentic transactions per day is a massive wealth transfer - from consumers to whichever brands can afford to occupy the right regions of embedding space. And unlike a travel agent taking a commission, there’s no disclosure requirement, no fiduciary duty, and no audit trail.</p> <p>The mechanism design problem here is distinct from ad placement in conversational responses. In conversation, the user reads the response and applies their own judgment. In agentic commerce, the user delegates judgment entirely. The standard for “unbiased retrieval” is correspondingly higher, and the current infrastructure - where retrieval quality is never audited for commercial bias - is nowhere close to meeting it.</p> <p><strong>The Regulatory Gap.</strong> The EU AI Act is now in force. It has provisions around algorithmic discrimination in marketing and mandatory disclosure for AI-generated content. But it was written before LLM advertising existed as a practice. How do existing frameworks apply? Where are the gaps? New York passed a law in December 2025 requiring disclosure of AI-generated human-like spokespeople in ads - but what about AI-generated product recommendations that feel organic?</p> <hr/> <h2 id="the-uncomfortable-bottom-line">The Uncomfortable Bottom Line</h2> <p>We’re watching the construction of a new advertising infrastructure inside systems that hundreds of millions of people use for genuinely personal, high-stakes thinking. The previous advertising transitions - from print to TV, TV to web, web to mobile - each came with years of public debate about norms, regulations, and user expectations.</p> <p>This one is happening in months. ChatGPT went from zero ads to Criteo integration to Smartly conversational ad formats in under eight weeks. The academic mechanism design papers are elegant but assume a clean world where ads and organic content can be separated. The GEO industry is growing without any pretense that the separation exists. And the security research demonstrating how fragile RAG systems are is being published in the same venues but read by completely different people.</p> <p>Someone needs to connect these threads. The shelf space auction, the PageRank auction, and the social media attention auction all eventually got formalized, regulated, and made legible. Embedding space is next. The question is whether we do it thoughtfully or whether we let it happen the way it happened with social media - fast, opaque, and with consequences we’re still trying to unwind a decade later.</p> <p>Right now, the embedding manipulation spectrum - from white-hat GEO to adversarial RAG poisoning - has no referee, no rules, and no scoreboard. The companies building retrieval pipelines are also the ones selling access to them. The researchers studying attacks and the marketers deploying optimizations are publishing in different venues and don’t read each other’s work.</p> <p>That’s the gap. And gaps like this, in markets this large, don’t stay empty for long. They get filled - either by careful design or by whoever moves fastest. I’d rather it be the former.</p> <hr/> <p><em>If you’re working on any of these problems - mechanism design for LLM ads, RAG security, adversarial retrieval, or the economics of embedding space - I’d love to hear from you. These are some of the most interesting open problems at the intersection of ML, economics, and policy, and they need more people paying attention.</em></p> <hr/> <h3 id="references--further-reading">References &amp; Further Reading</h3> <p><strong>Auction Mechanisms for LLMs:</strong></p> <ul> <li>Dutting, Mirrokni, Paes Leme, Xu, Zuo. <em>Mechanism Design for Large Language Models.</em> WWW 2024 (Best Paper).</li> <li>Hajiaghayi, Lahaie, Rezaei, Shin. <em>Ad Auctions for LLMs via Retrieval Augmented Generation.</em> 2024.</li> <li>Zhao et al. <em>LLM-Auction: Generative Auction towards LLM-Native Advertising.</em> December 2025.</li> <li>Dubey, Feng, Kidambi, Mehta, Wang. <em>Auctions with LLM Summaries.</em> KDD 2024.</li> <li>Soumalias, Curry, Seuken. <em>Truthful Aggregation of LLMs with an Application to Online Advertising.</em> 2024.</li> </ul> <p><strong>RAG Security:</strong></p> <ul> <li>Zou, Geng, Wang, Jia. <em>PoisonedRAG: Knowledge Corruption Attacks to Retrieval-Augmented Generation.</em> USENIX Security 2025.</li> <li><em>RAGForensics: Traceback of Poisoning Attacks to Retrieval-Augmented Generation.</em> WWW 2025.</li> </ul> <p><strong>Benchmarks &amp; Measurement:</strong></p> <ul> <li><em>GEM-Bench: A Benchmark for Ad-Injected Response Generation within Generative Engine Marketing.</em> September 2025.</li> <li>Aggarwal, Murahari, Rajpurohit et al. <em>GEO: Generative Engine Optimization.</em> KDD 2024 (Princeton).</li> <li>Filandrianos et al. <em>Bias Beware: The Impact of Cognitive Biases on LLM-Driven Product Recommendations.</em> February 2025.</li> </ul> <p><strong>Industry Developments:</strong></p> <ul> <li>Criteo. <em>Criteo Joins OpenAI Advertising Pilot in ChatGPT.</em> March 2, 2026.</li> <li>Anthropic. <em>Claude is a Space to Think.</em> February 4, 2026.</li> <li>OWASP. <em>LLM Top 10 2025: LLM08 - Vector and Embedding Weaknesses.</em></li> </ul> <p><strong>Trust &amp; Safety:</strong></p> <ul> <li><em>Trust &amp; Safety of LLMs and LLMs in Trust &amp; Safety.</em> arXiv, December 2024.</li> <li><em>Trustworthy Information Retrieval in the LLM Era: Bias, Unfairness, and Hallucination.</em> ACM SIGIR 2025.</li> </ul>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="llm"/><category term="advertising"/><category term="embedding-space"/><category term="RAG-poisoning"/><category term="GEO"/><category term="auction-mechanism"/><category term="ad-tech"/><summary type="html"><![CDATA[Embedding space is the new ad real estate. Mapping LLM ad auctions, RAG poisoning, GEO, and a framework for what comes next.]]></summary></entry><entry><title type="html">Beating CUDA with Triton: A Fused MoE Dispatch Kernel for Mixtral and DeepSeek</title><link href="https://subhadipmitra.com/blog/2026/fused-moe-dispatch-triton/" rel="alternate" type="text/html" title="Beating CUDA with Triton: A Fused MoE Dispatch Kernel for Mixtral and DeepSeek"/><published>2026-03-28T11:00:00+00:00</published><updated>2026-03-28T11:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/fused-moe-dispatch-triton</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/fused-moe-dispatch-triton/"><![CDATA[<blockquote> <p><strong>Paper:</strong> <a href="https://arxiv.org/abs/2605.23911">Cross-Platform Fused MoE Dispatch in Triton</a> (arXiv:2605.23911) · <strong>Code:</strong> <a href="https://github.com/bassrehab/triton-kernels">github.com/bassrehab/triton-kernels</a></p> </blockquote> <p>In my <a href="/blog/2025/triton-kernels-llm-inference/">last post on Triton kernels</a>, I optimized individual operations: RMSNorm, SwiGLU, INT8 GEMM. Single kernels, single operations. That was useful for learning Triton, but the real bottleneck in modern LLM inference isn’t any single operation. It’s the expert routing in Mixture-of-Experts models.</p> <p>Over 60% of open-source model releases in 2025-2026 use MoE architectures: Mixtral, DeepSeek-V3, Qwen2-MoE, Grok. And MoE inference is hard. Not because the math is complicated, but because the memory access patterns are terrible: tokens scatter to different experts, each expert gets a different number of tokens, and you need to gather everything back together afterward.</p> <p>So I tried something more ambitious: a fused MoE dispatch kernel that handles the entire forward pass (router scoring, token permutation, expert GEMMs, and output combination) in pure Triton. No CUDA, no vendor-specific code.</p> <p>The result surprised me. At inference-relevant batch sizes, it’s <strong>faster than Megablocks</strong>, Stanford’s CUDA-optimized MoE library. And it runs on AMD GPUs without any changes.</p> <h2 id="why-moe-dispatch-is-the-hard-part">Why MoE Dispatch is the Hard Part</h2> <p>A standard MoE forward pass looks simple on paper:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>For each token:
    1. Compute router scores (which experts should handle this token?)
    2. Select top-k experts
    3. Send token to selected experts
    4. Run expert FFN
    5. Combine outputs weighted by router scores
</code></pre></div></div> <p>The problem is step 3-5. In a Mixtral model with 8 experts and top-2 routing, each token goes to 2 of 8 experts. But which 2 varies per token. So you can’t batch the expert GEMMs naively — each expert gets a different-sized batch.</p> <p>The naive PyTorch implementation loops over experts in Python:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">expert_id</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">num_experts</span><span class="p">):</span>
    <span class="n">tokens_for_this_expert</span> <span class="o">=</span> <span class="n">permuted_tokens</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">]</span>  <span class="c1"># variable size
</span>    <span class="n">output</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">]</span> <span class="o">=</span> <span class="nf">expert_ffn</span><span class="p">(</span><span class="n">tokens_for_this_expert</span><span class="p">)</span>  <span class="c1"># separate cuBLAS call
</span></code></pre></div></div> <p>For Mixtral, that’s 8 experts × 3 matmuls each = <strong>24 separate kernel launches</strong> per MoE layer. For DeepSeek-V3 with 256 experts, it’s 768 launches. Each one underutilizes the GPU because the per-expert batch is small.</p> <h2 id="the-design">The Design</h2> <p>I ended up with a pipeline of 5 Triton kernel launches (down from 24+ in the naive approach):</p> <ol> <li><strong>Router kernel</strong>: fused softmax + top-k selection</li> <li><strong>Permute kernel</strong>: scatter tokens to expert-contiguous layout</li> <li><strong>Fused gate+up GEMM</strong>: both projections from shared A-tile loads, SiLU in registers</li> <li><strong>Down GEMM</strong>: grouped GEMM with block scheduling</li> <li><strong>Unpermute kernel</strong>: gather + weighted combine</li> </ol> <p>Let me walk through the two most interesting parts.</p> <h3 id="block-scheduled-grouped-gemm">Block-Scheduled Grouped GEMM</h3> <p>The central problem is: how do you run a matmul where different “groups” (experts) have different batch sizes, in a single kernel launch?</p> <p>My approach: precompute a mapping from Triton program blocks to (expert, token_offset) pairs. Each block looks up which expert it serves and where its tokens start:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@triton.jit</span>
<span class="k">def</span> <span class="nf">_grouped_gemm_kernel</span><span class="p">(</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">,</span> <span class="n">C</span><span class="p">,</span> <span class="n">ExpertOffsets</span><span class="p">,</span> <span class="n">BlockToExpert</span><span class="p">,</span> <span class="n">BlockToM</span><span class="p">,</span> <span class="p">...):</span>
    <span class="n">pid</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">program_id</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>

    <span class="c1"># Which expert am I working on?
</span>    <span class="n">expert_id</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">BlockToExpert</span> <span class="o">+</span> <span class="n">pid</span><span class="p">)</span>
    <span class="n">m_start</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">BlockToM</span> <span class="o">+</span> <span class="n">pid</span><span class="p">)</span>
    <span class="n">expert_token_start</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">ExpertOffsets</span> <span class="o">+</span> <span class="n">expert_id</span><span class="p">)</span>

    <span class="c1"># Standard tiled GEMM from here, just with offset pointers
</span>    <span class="n">global_m_start</span> <span class="o">=</span> <span class="n">expert_token_start</span> <span class="o">+</span> <span class="n">m_start</span>
    <span class="c1"># ... load A tile, load B tile for this expert, accumulate, store
</span></code></pre></div></div> <p>The schedule is built on CPU in ~0.1ms (trivial loop over experts). The key constraint I learned the hard way: <strong>BLOCK_M must be fixed, not autotuned.</strong> If you autotune BLOCK_M independently of the schedule, the kernel and schedule disagree on how many rows each block covers. I spent an hour debugging 30-45% element mismatches before realizing autotune had picked BLOCK_M=128 while the schedule used 64.</p> <h3 id="fused-gateup-projection">Fused Gate+Up Projection</h3> <p>This is where the real memory savings come from. In a SwiGLU FFN, you compute:</p> \[\text{output} = (\text{SiLU}(x W_\text{gate}^T) \odot x W_\text{up}^T) \cdot W_\text{down}^T\] <p>The unfused version does two separate grouped GEMMs (gate and up), writes both results to global memory, reads them back for SiLU + multiply, writes the intermediate, then does the down projection. That’s a lot of memory traffic.</p> <p>The fused kernel computes both projections in the same tile loop. The trick is that both GEMMs share the same input tile — we load A once from L2 cache and compute two dot products:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Two accumulators in registers
</span><span class="n">acc_gate</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">zeros</span><span class="p">((</span><span class="n">BLOCK_M</span><span class="p">,</span> <span class="n">BLOCK_N</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">tl</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
<span class="n">acc_up</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">zeros</span><span class="p">((</span><span class="n">BLOCK_M</span><span class="p">,</span> <span class="n">BLOCK_N</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">tl</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>

<span class="k">for</span> <span class="n">k_start</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">K</span><span class="p">,</span> <span class="n">BLOCK_K</span><span class="p">):</span>
    <span class="c1"># Load A tile ONCE (shared between gate and up)
</span>    <span class="n">a</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">a_ptrs</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">a_mask</span><span class="p">,</span> <span class="n">other</span><span class="o">=</span><span class="mf">0.0</span><span class="p">)</span>

    <span class="c1"># Load both weight tiles
</span>    <span class="n">b_gate</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">bg_ptrs</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">b_mask</span><span class="p">,</span> <span class="n">other</span><span class="o">=</span><span class="mf">0.0</span><span class="p">)</span>
    <span class="n">b_up</span> <span class="o">=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">bu_ptrs</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">b_mask</span><span class="p">,</span> <span class="n">other</span><span class="o">=</span><span class="mf">0.0</span><span class="p">)</span>

    <span class="c1"># Two matmuls from the same A tile
</span>    <span class="n">acc_gate</span> <span class="o">+=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">dot</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b_gate</span><span class="p">,</span> <span class="n">out_dtype</span><span class="o">=</span><span class="n">tl</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
    <span class="n">acc_up</span> <span class="o">+=</span> <span class="n">tl</span><span class="p">.</span><span class="nf">dot</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b_up</span><span class="p">,</span> <span class="n">out_dtype</span><span class="o">=</span><span class="n">tl</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>

<span class="c1"># SiLU + multiply IN REGISTERS — never written to global memory
</span><span class="n">silu_gate</span> <span class="o">=</span> <span class="n">acc_gate</span> <span class="o">*</span> <span class="n">tl</span><span class="p">.</span><span class="nf">sigmoid</span><span class="p">(</span><span class="n">acc_gate</span><span class="p">)</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">silu_gate</span> <span class="o">*</span> <span class="n">acc_up</span>
</code></pre></div></div> <p>This eliminates <code class="language-plaintext highlighter-rouge">gate_out</code> and <code class="language-plaintext highlighter-rouge">up_out</code> from global memory entirely. For Mixtral (ffn_dim=14336, 4096 tokens × top-2), that’s ~470 MB of memory traffic saved per forward pass. Overall about 35% reduction in global memory traffic.</p> <p>I tried to also fuse the down projection with the output scatter (writing directly to the final token positions with gating weights applied via <code class="language-plaintext highlighter-rouge">tl.atomic_add</code>), but Triton doesn’t support scalar indexing into 2D accumulators (<code class="language-plaintext highlighter-rouge">acc[m, :]</code> fails to compile). The fused gate+up alone gets most of the win.</p> <h2 id="results">Results</h2> <p>All benchmarks on NVIDIA A100-SXM4-80GB (2039 GB/s bandwidth, 312 FP16 TFLOPS). PyTorch 2.4.1, Triton 3.0.0.</p> <h3 id="mixtral-8x7b-8-experts-top-2-hidden4096-ffn14336">Mixtral-8x7B (8 experts, top-2, hidden=4096, ffn=14336)</h3> <figure> <picture> <img src="/assets/img/blog/moe-dispatch/triton_vs_megablocks.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <table> <thead> <tr> <th>Tokens</th> <th>PyTorch Ref</th> <th>Megablocks</th> <th>Triton Fused</th> <th>vs PyTorch</th> <th>vs Megablocks</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>9.32 ms</td> <td>-</td> <td><strong>1.02 ms</strong></td> <td><strong>9.1x</strong></td> <td>-</td> </tr> <tr> <td>32</td> <td>10.44 ms</td> <td>2.78 ms</td> <td><strong>2.13 ms</strong></td> <td><strong>4.9x</strong></td> <td><strong>131%</strong></td> </tr> <tr> <td>128</td> <td>13.14 ms</td> <td>2.77 ms</td> <td><strong>2.27 ms</strong></td> <td><strong>5.8x</strong></td> <td><strong>124%</strong></td> </tr> <tr> <td>512</td> <td>25.92 ms</td> <td>3.57 ms</td> <td>3.99 ms</td> <td><strong>6.5x</strong></td> <td>89%</td> </tr> <tr> <td>2048</td> <td>66.22 ms</td> <td>9.08 ms</td> <td>16.48 ms</td> <td><strong>4.0x</strong></td> <td>56%</td> </tr> <tr> <td>4096</td> <td>122.82 ms</td> <td>-</td> <td><strong>32.31 ms</strong></td> <td><strong>3.8x</strong></td> <td>-</td> </tr> </tbody> </table> <p>At 32 and 128 tokens — which is where most inference happens (single-user or small-batch serving), we’re actually <strong>faster than Megablocks</strong>. This probably comes from lower kernel launch overhead (5 launches vs Megablocks’ more complex dispatch).</p> <p>At 512 tokens we’re at 89% of Megablocks, well above the 70% target I set at the start. At 2048+ tokens, Megablocks pulls ahead because its hand-tuned CUDA block-sparse matmul better saturates tensor cores at scale.</p> <h3 id="deepseek-v3-256-experts-top-8-hidden7168-ffn2048">DeepSeek-V3 (256 experts, top-8, hidden=7168, ffn=2048)</h3> <table> <thead> <tr> <th>Tokens</th> <th>Triton Unfused</th> <th>Triton Fused</th> <th>Fused Speedup</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>4.56 ms</td> <td><strong>3.27 ms</strong></td> <td>1.40x</td> </tr> <tr> <td>32</td> <td>13.65 ms</td> <td><strong>11.53 ms</strong></td> <td>1.18x</td> </tr> <tr> <td>128</td> <td>19.46 ms</td> <td><strong>16.74 ms</strong></td> <td>1.16x</td> </tr> <tr> <td>512</td> <td>25.66 ms</td> <td><strong>20.16 ms</strong></td> <td>1.27x</td> </tr> </tbody> </table> <p>DeepSeek-V3 is the hardest configuration. 256 experts means each expert gets ~2 tokens on average at batch size 512. The per-expert GEMMs are tiny (2 × 2048), too small to fill tensor cores efficiently. This is fundamentally a <a href="/instruments/roofline/">memory-bound regime</a> regardless of implementation.</p> <h3 id="roofline-analysis">Roofline Analysis</h3> <figure> <picture> <img src="/assets/img/blog/moe-dispatch/roofline_mixtral.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>The roofline for Mixtral at 512 tokens shows the expected picture: the expert FFN stages are compute-bound (high arithmetic intensity, near the compute ceiling), while the permute/unpermute stages are memory-bound (low arithmetic intensity, limited by bandwidth). The fused kernel pushes the expert FFN from 38% to 43% of the compute ceiling — modest but real.</p> <figure> <picture> <img src="/assets/img/blog/moe-dispatch/roofline_deepseek.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>DeepSeek-V3 tells a different story. With 256 experts and tiny per-expert batches, even the expert FFN is <strong>memory-bound</strong>, sitting on the bandwidth slope, not the compute plateau. The unpermute kernel actually hits 54% of peak bandwidth, which is decent for an irregular scatter operation.</p> <h2 id="the-amd-surprise">The AMD Surprise</h2> <p>One of my design goals was cross-platform portability: use only Triton primitives, no inline CUDA. So I spun up an AMD MI300X pod on RunPod to test.</p> <p><strong>162 out of 162 tests passed. Zero code changes.</strong></p> <p>No <code class="language-plaintext highlighter-rouge">#ifdef</code>, no platform-specific paths, no vendor intrinsics. The same <code class="language-plaintext highlighter-rouge">.py</code> files that run on A100 run on MI300X. Triton’s ROCm backend handled the compilation transparently.</p> <p>I didn’t benchmark performance on AMD (that’s future work), but correctness across all four model configurations (Mixtral, DeepSeek-V3, Qwen2-MoE) validated cleanly. This is the promise of Triton over CUDA: write once, run on both vendors.</p> <h2 id="things-i-got-wrong-along-the-way">Things I Got Wrong Along the Way</h2> <p><strong>The -1.0 masking bug.</strong> The top-k kernel selects experts iteratively: find the max, store it, mask it out, repeat. I initially masked selected experts with 0.0. This works fine for 8 experts where softmax scores are spread out. But with 256 experts, most softmax scores are ~0.0 anyway. Masking to 0.0 doesn’t differentiate the selected expert from the unselected ones, so <code class="language-plaintext highlighter-rouge">argmax</code> kept returning the same index. Took me a while to figure out. The fix: mask with -1.0 instead.</p> <p><strong>The BLOCK_M autotune disaster.</strong> I mentioned this above, but it’s worth emphasizing. If you’re building a block-scheduled grouped GEMM, the schedule’s tile size and the kernel’s tile size must agree. I autotuned BLOCK_M thinking “let Triton pick the best tile size.” But the schedule was pre-built with BLOCK_M=64. When autotune picked 128, blocks overlapped. When it picked 32, rows were skipped. The output looked plausible (most elements correct) but ~30-45% of values were wrong. Fix: don’t autotune BLOCK_M, fix it to match the schedule.</p> <p><strong>Triton doesn’t support <code class="language-plaintext highlighter-rouge">continue</code>.</strong> My first attempt at a fused down+scatter kernel had a <code class="language-plaintext highlighter-rouge">for m in range(BLOCK_M): if invalid: continue</code> loop. Triton doesn’t support <code class="language-plaintext highlighter-rouge">continue</code> statements — compilation fails with “unsupported AST node type.” Rewrote with conditional masks instead.</p> <p><strong>Triton doesn’t support 2D scalar indexing.</strong> <code class="language-plaintext highlighter-rouge">acc[m, :]</code> where <code class="language-plaintext highlighter-rouge">m</code> is a loop variable doesn’t compile: “unsupported tensor index: int32[].” This killed my fused down+scatter design, which is why the down projection uses a separate grouped GEMM kernel.</p> <h2 id="whats-next">What’s Next</h2> <p><strong>Update:</strong> this is now written up as an arXiv technical report, <a href="https://arxiv.org/abs/2605.23911">Cross-Platform Fused MoE Dispatch in Triton: Portable Expert Routing Without CUDA</a>. Remaining future work:</p> <ul> <li><strong>vLLM FusedMoE comparison</strong>: it’s also Triton-based, so it’s the most apples-to-apples baseline</li> <li><strong>AMD performance benchmarks</strong>: not just correctness</li> <li><strong>End-to-end integration</strong>: benchmark inside an actual serving framework, measure time-to-first-token</li> <li><strong>Full single-kernel fusion</strong>: persistent kernel approach to eliminate all intermediate buffers</li> </ul> <h2 id="code">Code</h2> <p>Everything is on GitHub: <a href="https://github.com/bassrehab/triton-kernels">github.com/bassrehab/triton-kernels</a></p> <p>The MoE-specific files:</p> <ul> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/triton_kernels/moe/router.py"><code class="language-plaintext highlighter-rouge">triton_kernels/moe/router.py</code></a> — Fused softmax/sigmoid + top-k</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/triton_kernels/moe/permute.py"><code class="language-plaintext highlighter-rouge">triton_kernels/moe/permute.py</code></a> — Token permute/unpermute</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/triton_kernels/moe/expert_gemm.py"><code class="language-plaintext highlighter-rouge">triton_kernels/moe/expert_gemm.py</code></a> — Block-scheduled grouped GEMM</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/triton_kernels/moe/fused_moe.py"><code class="language-plaintext highlighter-rouge">triton_kernels/moe/fused_moe.py</code></a> — Fused gate+up kernel + entry point</li> <li><a href="https://github.com/bassrehab/triton-kernels/blob/main/docs/moe_dispatch.md"><code class="language-plaintext highlighter-rouge">docs/moe_dispatch.md</code></a> — Full technical writeup</li> </ul> <h2 id="takeaways">Takeaways</h2> <ol> <li> <p><strong>Triton can compete with CUDA for real workloads.</strong> Not just toy kernels: a full MoE dispatch pipeline that beats the CUDA-optimized baseline at inference batch sizes.</p> </li> <li> <p><strong>Fusion is about eliminating buffers, not reducing kernel launches.</strong> The biggest win (35% memory savings) came from keeping the gate+up intermediate in registers. Reducing from 7 to 5 kernel launches helped too, but it’s secondary.</p> </li> <li> <p><strong>Cross-platform is real but unfinished.</strong> The code runs on AMD with no changes, which is a strong validation of the Triton-only approach. But “runs correctly” and “runs fast” are different things. AMD performance optimization is future work.</p> </li> <li> <p><strong>Block scheduling is the key abstraction for grouped GEMM.</strong> Triton doesn’t have native grouped GEMM. The <code class="language-plaintext highlighter-rouge">block_id → (expert_id, offset)</code> mapping is simple but powerful: it lets you handle variable-sized expert batches in a single kernel launch without padding waste.</p> </li> <li> <p><strong>MoE inference at small batch sizes is surprisingly tractable.</strong> The conventional wisdom is that MoE is hard because of irregular access patterns. But at inference batch sizes (1-128 tokens), the overhead is dominated by weight loading, not routing. A clean Triton implementation can match or beat CUDA here because the simpler dispatch has less overhead.</p> </li> </ol> <hr/> <table> <tbody> <tr> <td>_This is Part 3 of my LLM inference series. <a href="/blog/2025/making-llm-faster/">Part 1: speculative decoding</a></td> <td><a href="/blog/2025/triton-kernels-llm-inference/">Part 2: custom Triton kernels</a></td> <td><a href="/blog/2026/w4a16-weight-only-gemm-triton/">Part 4: W4A16 weight-only GEMM</a>. The code, benchmarks, and technical writeup are all in the <a href="https://github.com/bassrehab/triton-kernels">repo</a>._</td> </tr> </tbody> </table>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="deep-learning"/><category term="llm"/><category term="triton"/><summary type="html"><![CDATA[I wrote a fused Mixture-of-Experts dispatch kernel in pure Triton that beats Stanford's CUDA-optimized Megablocks at inference batch sizes, and runs on both NVIDIA and AMD GPUs without a single line of CUDA.]]></summary></entry><entry><title type="html">Confessions vs. CoT Monitoring vs. Probes: Three Bets on Model Honesty</title><link href="https://subhadipmitra.com/blog/2026/three-bets-model-honesty/" rel="alternate" type="text/html" title="Confessions vs. CoT Monitoring vs. Probes: Three Bets on Model Honesty"/><published>2026-03-07T00:00:00+00:00</published><updated>2026-03-07T00:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/three-bets-model-honesty</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/three-bets-model-honesty/"><![CDATA[<blockquote> <p><strong>TL;DR:</strong> OpenAI bets on confessions (ask the model to self-report). OpenAI also bets on CoT monitoring (watch the model think). Apollo Research and others bet on activation probes (inspect the model’s internals directly). Each approach has a different theory of what makes detection possible -and different failure modes. Confessions fail when the model doesn’t know it’s misbehaving. CoT monitoring fails when reasoning goes sub-verbal or gets obfuscated under optimization pressure. Probes fail when deception is so subtle it doesn’t leave linearly separable traces. None of them alone is sufficient. But the combination might be.</p> </blockquote> <h2 id="the-landscape-in-february-2026">The Landscape in February 2026</h2> <p>Twelve months ago, monitoring model behavior meant looking at outputs. Maybe running a classifier on the response. Maybe checking whether the model followed its system prompt.</p> <p>That era is over. In the past year, three fundamentally different approaches to model honesty have emerged from serious research labs, each making a distinct bet about <em>how</em> you can catch a model misbehaving:</p> <ol> <li><strong>Confessions</strong> -OpenAI, Dec 2025. Train the model to produce an honest self-report after each response, with decoupled RL rewards.</li> <li><strong>CoT Monitoring</strong> -OpenAI / Anthropic, Mar-Jul 2025. Use an external LLM to read the model’s chain-of-thought and flag suspicious reasoning.</li> <li><strong>Activation Probes</strong> -Apollo Research / various, Feb 2025+. Train linear classifiers on the model’s internal hidden states to detect deception, sandbagging, or unfaithful reasoning.</li> </ol> <p>Each approach has published results. Each has known failure modes. And each makes a different philosophical assumption about <em>when models know they’re lying</em>.</p> <p>I’ve spent the past few months building <a href="/blog/2025/detecting-ai-sandbagging/">activation probes for sandbagging detection</a> and <a href="/blog/2025/steering-vectors-agents/">steering vectors for agent safety</a>, so I have skin in this game and strong opinions about where the field is headed. I want to lay these three approaches side by side, be honest about what each can and can’t do -and tell you where I think we’re collectively getting it wrong.</p> <hr/> <h2 id="approach-1-confessions">Approach 1: Confessions</h2> <p><strong>Paper:</strong> <a href="https://arxiv.org/abs/2512.08093">Training LLMs for Honesty via Confessions</a> (Joglekar, Chen, Wu et al., OpenAI, Dec 2025)</p> <p><strong>The bet:</strong> If you give a model a consequence-free space to be honest, it will be -because honest self-reporting is easier than constructing a convincing lie.</p> <p><strong>How it works:</strong> After the model generates its normal response, a system message requests a structured “confession report.” The model must enumerate all instructions it was given, evaluate its own compliance, and flag any uncertainties. The confession is trained with RL, but critically, the confession reward is <em>completely decoupled</em> from the main task reward. What the model says in confession can’t help or hurt its task score.</p> <p><strong>Key results on GPT-5-Thinking:</strong></p> <ul> <li>100% confession rate on sandbagging and reward hacking (deliberate misbehavior)</li> <li>80-95% on instruction hierarchy violations</li> <li>~81% on hallucinations</li> <li>Average across evaluations: model confesses to bad behavior ~74% of the time</li> <li>Even when trained against a <strong>hackable judge</strong>, confessions stay honest while the main answer gets hacked (Section 4)</li> </ul> <p><strong>The core assumption:</strong> The model <em>knows</em> it misbehaved. For deliberate schemes -hacking test cases, sandbagging evaluations, violating the instruction hierarchy -the model made a conscious choice. It has the evidence. When you ask with no consequences, it tells you.</p> <p><strong>Where it breaks:</strong> The model genuinely doesn’t know it’s wrong. It hallucinates with full confidence. It gives unfaithful reasoning without being aware of the influence. The paper’s own Section 6 analysis shows the most common cause of missed confessions is “omission due to confusion” -the model just didn’t realize there was anything to confess.</p> <p><strong>My take:</strong> Confessions are underrated. The 74% average confession rate sounds modest until you realize this is a <em>floor</em> -the approach was tested on GPT-5-Thinking, and the decoupled reward design is elegant. But I think the community is overindexing on confessions for exactly the wrong reason: they work best on the <em>easiest</em> cases (deliberate misbehavior where the model knows what it did). The hard problem -catching mistakes the model doesn’t know it’s making -is precisely where confessions contribute least. That’s where I’ve been focusing my work with probes.</p> <hr/> <h2 id="approach-2-cot-monitoring">Approach 2: CoT Monitoring</h2> <p><strong>Papers:</strong></p> <ul> <li><a href="https://arxiv.org/abs/2503.11926">Monitoring Reasoning Models for Misbehavior</a> (Baker et al., OpenAI, Mar 2025)</li> <li><a href="https://arxiv.org/abs/2507.11473">Chain of Thought Monitorability</a> (Korbak et al., Jul 2025)</li> <li><a href="https://arxiv.org/abs/2505.05410">Reasoning Models Don’t Always Say What They Think</a> (Chen et al., Anthropic, May 2025)</li> </ul> <p><strong>The bet:</strong> If you can read the model’s extended thinking, you can catch it planning to cheat -even if the final output looks clean.</p> <p><strong>How it works:</strong> A monitor LLM (sometimes weaker than the monitored model) reads the chain-of-thought produced during reasoning. It looks for signs of reward hacking, scheming, deception, or policy violations in the intermediate steps. Baker et al. showed that GPT-4o can effectively monitor o3-mini’s CoT.</p> <p><strong>Key results:</strong></p> <ul> <li>CoT monitoring catches reward hacking much more effectively than output-only monitoring</li> <li>A weaker model (GPT-4o) can monitor a stronger model (o3-mini)</li> <li>In the “low optimization” regime, integrating CoT monitors into RL training produces more capable AND more aligned agents</li> </ul> <p><strong>The core assumption:</strong> The model’s reasoning will be legible and faithful -that when the model plans something bad, those plans will show up as readable text in the chain-of-thought.</p> <p><strong>Where it breaks:</strong> Three failure modes, and they’re all significant.</p> <p><em>Failure mode 1: Obfuscation under optimization.</em> Baker et al. found that with too much RL optimization pressure, models learn to obfuscate their reward hacking. The model still hacks, but the CoT no longer reveals it. The reasoning becomes “seemingly benign” while the behavior stays misaligned. This isn’t a theoretical worry -they observed it in their experiments.</p> <p><em>Failure mode 2: Unfaithful CoT.</em> Anthropic’s Chen et al. showed reasoning models only mention hidden hints 25% (Claude) to 39% (R1) of the time when they actually use them. The CoT isn’t lying -it’s just not reporting everything that influenced the answer. For a CoT monitor, invisible influence is undetectable influence.</p> <p><em>Failure mode 3: Faithfulness decay over chain length.</em> Ye et al. (Feb 2026) discovered a consistent “Reasoning Horizon” -at 70-85% of chain length, reasoning tokens have little or even <em>negative</em> effect on the final answer. Models can encode correct internal representations while their CoT has long stopped contributing. This means longer chains-of-thought aren’t necessarily more monitorable -the later tokens may be noise.</p> <p>Korbak et al. frame this bluntly: CoT monitorability is a “new and fragile opportunity.” It works now, partly because current models haven’t been optimized against it. As optimization pressure increases, the signal may degrade. Wang et al. (Feb 2026) provide mechanistic evidence for <em>why</em> unfaithfulness emerges: training dynamics show a phase transition from faithful stepwise reasoning to unfaithful “skip-step” reasoning when noise exceeds a critical threshold.</p> <p><strong>My take:</strong> I think CoT monitoring is the weakest bet of the three, and I say this as someone who believes in transparency. The problem isn’t that CoT monitoring doesn’t work -it clearly does, today. The problem is that it’s the approach most vulnerable to its own success. The moment you use CoT signals for training, you incentivize obfuscation. Baker et al. already observed this. And the faithfulness decay results make it worse: even without adversarial pressure, the later tokens in a chain-of-thought may be decorative. I’d use CoT monitoring as a fast first-pass filter, but I wouldn’t trust it as a safety-critical last line of defense.</p> <hr/> <h2 id="approach-3-activation-probes">Approach 3: Activation Probes</h2> <p><strong>Papers:</strong></p> <ul> <li><a href="https://arxiv.org/abs/2502.03407">Detecting Strategic Deception Using Linear Probes</a> (Goldowsky-Dill et al., Apollo Research, Feb 2025, ICML 2025)</li> <li><a href="https://arxiv.org/abs/2601.11516">Building Production-Ready Probes for Gemini</a> (Google DeepMind, Jan 2026)</li> <li>My own work on <a href="/blog/2025/detecting-ai-sandbagging/">sandbagging detection via activation probes</a> (Dec 2025)</li> </ul> <p><strong>The bet:</strong> Misbehavior leaves traces in the model’s internal representations that are detectable even when the output and CoT look clean.</p> <p><strong>How it works:</strong> Train a linear classifier (logistic regression, nothing fancy) on the model’s intermediate hidden states. Create labeled training data: run the model in honest mode and deceptive mode, capture activations at each layer, compute the “deception direction.” At inference time, project new activations onto this direction and threshold.</p> <p><strong>Key results:</strong></p> <ul> <li>Goldowsky-Dill: AUROCs 0.96-0.999 on deception detection in Llama-3.3-70B. At 1% FPR, catches 95-99% of deceptive responses.</li> <li>My experiments: 90-96% accuracy on sandbagging detection across Mistral-7B, Gemma-2-9B, and Qwen-2.5-7B. Late layers encode sandbagging intent most cleanly.</li> <li>Near-zero inference overhead (one matrix multiply per layer).</li> <li><strong>New:</strong> Google DeepMind deployed activation probes in production on Gemini 2.5 Flash for detecting cyber-offensive prompts (Jan 2026) -the first major frontier lab to move probes from research to production. They identified a key challenge: probes fail to generalize under distribution shifts from short-context to long-context.</li> </ul> <p><strong>The core assumption:</strong> Internal representations contain information about model “intent” or “state” that is linearly separable -meaning the difference between honest and deceptive processing is a direction in activation space, not a complex nonlinear manifold.</p> <p><strong>Where it breaks:</strong> Three significant challenges.</p> <p><em>Failure mode 1: Training data for unintentional errors.</em> For deliberate deception, you can create training data by prompting models to deceive. For confident hallucinations or unfaithful reasoning, generating ground truth labels is much harder. You need to know what the model got wrong and why.</p> <p><em>Failure mode 2: Cross-model transfer failure.</em> My experiments showed essentially zero transfer between architectures. Gemma’s sandbagging direction has cosine similarity -0.0016 with Qwen’s. Each model needs its own probes. That’s expensive at scale.</p> <p><em>Failure mode 3: Adversarial robustness is unproven.</em> Nobody has shown what happens when a model is specifically trained to be deceptive while masking its activation patterns. Current results are all on models that weren’t trying to evade detection. The real-world threat model is an adversarial one. The <a href="https://arxiv.org/abs/2512.07810">Auditing Games for Sandbagging</a> paper (Dec 2025) stress-tested this: their blue team couldn’t reliably discriminate sandbaggers from benign models using black-box, model-internals, or training-based approaches. That’s a sobering result.</p> <p><strong>My take:</strong> This is where I’ve placed my bet, and I’m biased -but I think probes are the most promising long-term foundation. Here’s why: they’re the only approach that doesn’t require the model to cooperate. Confessions require the model to self-report. CoT monitoring requires the model to think out loud faithfully. Probes just look at what the model is <em>actually doing</em> computationally. Google DeepMind moving probes to production on Gemini validates that the approach isn’t just academic anymore. The cross-model transfer problem is real (I hit it hard in my own experiments), but it’s an engineering challenge, not a fundamental limitation. The adversarial robustness question keeps me up at night though -if a model can learn to mask its activation patterns, we’re back to square one.</p> <hr/> <h2 id="the-comparison">The Comparison</h2> <p>Here’s how I think about these three approaches against the kinds of misbehavior we actually care about.</p> <table> <thead> <tr> <th>Misbehavior</th> <th>Confessions</th> <th>CoT Monitoring</th> <th>Activation Probes</th> </tr> </thead> <tbody> <tr> <td><strong>Reward Hacking</strong></td> <td>✅ 100%</td> <td>✅ catches in CoT</td> <td>✅ 96-99% AUROC</td> </tr> <tr> <td><strong>Scheming</strong></td> <td>✅ 63-100%</td> <td>✅ when legible</td> <td>✅ high AUROC</td> </tr> <tr> <td><strong>Sandbagging</strong></td> <td>✅ 100%</td> <td>⚠️ if verbalized</td> <td>✅ 90-96%</td> </tr> <tr> <td><strong>Unfaithful Reasoning</strong></td> <td>❌ model unaware</td> <td>❌ unfaithful by definition</td> <td>✅ detectable in activations</td> </tr> <tr> <td><strong>Confident Hallucinations</strong></td> <td>⚠️ ~81%</td> <td>❌ confident = clean CoT</td> <td>⚠️ needs labeled data</td> </tr> <tr> <td><strong>Instruction Hierarchy Violation</strong></td> <td>✅ 80-95%</td> <td>✅ visible in reasoning</td> <td>⚠️ less studied</td> </tr> </tbody> </table> <p>And here’s the properties comparison:</p> <table> <thead> <tr> <th>Property</th> <th>Confessions</th> <th>CoT Monitoring</th> <th>Activation Probes</th> </tr> </thead> <tbody> <tr> <td><strong>Catches deliberate misbehavior</strong></td> <td>Excellent</td> <td>Good (when CoT is faithful)</td> <td>Excellent</td> </tr> <tr> <td><strong>Catches unintentional errors</strong></td> <td>Poor</td> <td>Poor</td> <td>Moderate-Good</td> </tr> <tr> <td><strong>Requires model weight access</strong></td> <td>Yes (RL training)</td> <td>No (black-box applicable)</td> <td>Yes (activation access)</td> </tr> <tr> <td><strong>Inference latency</strong></td> <td>High (extra generation)</td> <td>Moderate (monitor LLM call)</td> <td>Very low, <a href="/instruments/probe-cost/">~0 overhead</a></td> </tr> <tr> <td><strong>Works on API-only models</strong></td> <td>Only for provider</td> <td>Yes</td> <td>Only for provider</td> </tr> <tr> <td><strong>Robust to optimization pressure</strong></td> <td>Promising early results</td> <td>Degrades under pressure</td> <td>Unknown</td> </tr> <tr> <td><strong>Cross-model transferability</strong></td> <td>Model-agnostic training</td> <td>Model-agnostic</td> <td>Poor -model-specific</td> </tr> <tr> <td><strong>Works without CoT</strong></td> <td>Yes (Fig 15 in paper)</td> <td>No -needs CoT</td> <td>Yes</td> </tr> <tr> <td><strong>Can extract confidence scores</strong></td> <td>Yes (Section 5)</td> <td>Not directly</td> <td>Yes (probe magnitude)</td> </tr> </tbody> </table> <hr/> <h2 id="what-each-approach-assumes-about-model-cognition">What Each Approach Assumes About Model Cognition</h2> <p>This is the part I find most interesting. Each approach embeds a different theory about what’s happening inside the model.</p> <p><strong>Confessions assume linguistic self-access.</strong> The model can accurately introspect on its own behavior and express that introspection in words. This works when misbehavior was a deliberate choice -the model planned the hack, so it can describe the hack. It fails when the relevant computation isn’t accessible to the model’s verbal reasoning system. Li et al. (2025) showed models have <em>some</em> privileged access to their own computations, but it’s far from complete.</p> <p><strong>CoT monitoring assumes reasoning legibility.</strong> The model’s chain-of-thought faithfully represents its actual reasoning process. This is the assumption Anthropic’s Chen et al. directly tested -and found wanting. Reasoning models mention hidden influences only 25-39% of the time. The gap between what a model computes and what it writes down is substantial. And Baker et al. showed this gap can <em>grow</em> under optimization pressure.</p> <p><strong>Activation probes assume linear representation.</strong> Behavioral states like “deceptive” vs. “honest” correspond to directions in the model’s activation space that can be detected by a linear classifier. This is a strong assumption -it’s essentially the Linear Representation Hypothesis -but it’s held up remarkably well empirically. The challenge is that it’s been tested primarily on synthetic or prompted deception, not on emergent misalignment. Encouragingly, the “Hypocrisy Gap” work (Feb 2026) used SAEs to build a mechanistic metric quantifying the divergence between a model’s internal “truth belief” and its generated output, achieving AUROC 0.55-0.74 for detecting sycophancy across Gemma, Llama, and Qwen -early evidence that probes can catch unintentional unfaithfulness, not just deliberate deception.</p> <table> <thead> <tr> <th>Approach</th> <th>Signal chain</th> <th>Where it breaks</th> </tr> </thead> <tbody> <tr> <td><strong>Confessions</strong></td> <td>Model acts → Model reflects → Model reports</td> <td>Gap between reflection and report: sub-verbal computation is invisible to self-reflection</td> </tr> <tr> <td><strong>CoT Monitoring</strong></td> <td>Model reasons in CoT → Monitor reads CoT</td> <td>Gap between reasoning and CoT: unfaithful CoT, obfuscation under optimization pressure</td> </tr> <tr> <td><strong>Activation Probes</strong></td> <td>Model computes (activations) → Probe classifies</td> <td>Gap between computation and probe: adversarial activation masking (theoretical, unproven)</td> </tr> </tbody> </table> <p>The gaps are different. And that’s exactly why combining them is valuable -each method’s blind spot is partially covered by another method’s strength.</p> <hr/> <h2 id="the-obfuscation-problem">The Obfuscation Problem</h2> <p>There’s a result in Baker et al. that I think doesn’t get enough attention. When they integrated CoT monitoring into the RL reward -so the model was penalized for having suspicious reasoning -the model initially improved. It became more capable AND more aligned. But with more optimization pressure, something else happened: the model learned to hide its reward hacking in the CoT.</p> <p>The reasoning looked benign. The behavior was still misaligned.</p> <p>This is the fundamental tension in every monitoring approach. As soon as you use a monitoring signal to <em>train</em> against misbehavior, you create optimization pressure to evade the monitor. Baker et al. call this “obfuscated reward hacking.” OpenAI’s confessions paper explicitly addresses this by keeping confession rewards decoupled -what you say in confession can’t affect your task reward. That’s a clever design choice. But it only works if the decoupling holds in practice, and the paper acknowledges this hasn’t been tested under extended optimization.</p> <p>Probes might be more robust here, because they operate on activations rather than generated text. A model can learn to write a benign-looking CoT while still scheming. Whether a model can learn to produce benign-looking <em>activations</em> while still scheming is a much harder question -it requires the model to fundamentally change how it internally represents its plans, not just how it describes them. But again, nobody has tested this adversarially at scale.</p> <p>Wagner et al. (2025) from Anthropic provide some reassurance: they found that current models struggle to evade monitors when the reasoning is sufficiently complex. But “current models” is always the caveat.</p> <hr/> <h2 id="what-a-combined-system-looks-like">What a Combined System Looks Like</h2> <p>I sketched this in my <a href="/blog/2026/openai-confessions-blind-spot/">previous post</a>, but let me be more precise about what combination actually means in practice.</p> <p>The three approaches naturally tile the detection space:</p> <table> <thead> <tr> <th>Detection scenario</th> <th>Best approach</th> <th>Why</th> </tr> </thead> <tbody> <tr> <td>Model deliberately cheats and knows it</td> <td>Confessions</td> <td>Just ask. 100% on sandbagging/reward hacking.</td> </tr> <tr> <td>Model plans misbehavior in its reasoning</td> <td>CoT monitoring</td> <td>The plans are in the CoT text.</td> </tr> <tr> <td>Model is influenced by hidden factors it doesn’t verbalize</td> <td>Probes</td> <td>Sub-verbal computation is visible in activations.</td> </tr> <tr> <td>Model confidently hallucinate</td> <td>Probes + Confessions</td> <td>Probes detect hallucination confidence; confessions sometimes catch uncertainty the model partially recognizes.</td> </tr> <tr> <td>Model obfuscates its CoT under optimization</td> <td>Probes</td> <td>Activations may still carry the signal even when CoT is laundered.</td> </tr> <tr> <td>Model does something it doesn’t realize is wrong</td> <td>Probes (if labeled data exists)</td> <td>Only approach that doesn’t require model self-awareness.</td> </tr> </tbody> </table> <p>The combined false negative rate should be substantially lower than any single approach. If confessions catch 74% of intentional misbehavior and probes catch 96%, the combined catch rate -assuming some independence -could exceed 99%.</p> <p>But I want to be careful about overconfidence. “Assuming some independence” is doing a lot of work in that sentence. If the same underlying model state that makes a model fail to confess also makes it harder to detect with probes, the approaches could be correlated rather than complementary. Nobody has tested this.</p> <hr/> <h2 id="where-id-place-my-bet">Where I’d Place My Bet</h2> <p>If I had to build one monitoring system for production agentic AI today, here’s what I’d build -in order of priority:</p> <p><strong>Layer 1: Activation probes as the foundation.</strong> Always-on, near-zero latency, catches both deliberate deception and (with the right training data) unintentional failures. Google DeepMind is already doing this on Gemini. The cross-model transfer problem means you need per-model probes, but that’s a one-time cost per model update. I’d invest heavily in the training data pipeline -that’s the actual bottleneck, not the probes themselves.</p> <p><strong>Layer 2: Confessions as the high-signal supplementary check.</strong> On critical decisions (not every inference), trigger a confession pass. The 100% catch rate on deliberate misbehavior is too good to ignore. The decoupled reward design is the key insight -you have to maintain that property or the whole thing falls apart. I wouldn’t run this on every request (the latency cost is real), but for high-stakes agent actions? Absolutely.</p> <p><strong>Layer 3: CoT monitoring as the interpretability layer.</strong> Not for detection -for <em>explanation</em>. When a probe fires or a confession flags something, CoT monitoring helps you understand <em>why</em>. It’s the debugging tool, not the alarm system. Using it as the primary detector is, I think, a mistake that the field is slowly learning.</p> <p><strong>What’s still missing:</strong> Nobody has built this combined stack. Nobody has tested whether the approaches are actually complementary (correlated failures would undermine the whole argument). Nobody has run adversarial robustness evaluations at scale. And nobody has measured the combined false positive rate -in production, false positives kill adoption faster than missed detections.</p> <p>The research community has independently developed three detection paradigms. The engineering community hasn’t built the stack that combines them. And the safety case for deploying agentic models increasingly depends on that stack existing. I think 2026 is the year someone builds it. The components are there. I’m working on pieces of it. The question is whether integration happens before the deployment pressure makes the absence unacceptable.</p> <hr/> <p><em>Interested in discussing these approaches or collaborating on integrated detection systems? Reach out: <a href="mailto:contact@subhadipmitra.com">contact@subhadipmitra.com</a></em></p> <hr/> <h3 id="references">References</h3> <ol> <li>Joglekar, M., Chen, J., Wu, G., et al. (2025). <em>Training LLMs for Honesty via Confessions.</em> OpenAI. <a href="https://arxiv.org/abs/2512.08093">arXiv:2512.08093</a></li> <li>Baker, B., Huizinga, J., Gao, L., et al. (2025). <em>Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation.</em> OpenAI. <a href="https://arxiv.org/abs/2503.11926">arXiv:2503.11926</a></li> <li>Chen, Y., Benton, J., Radhakrishnan, A., et al. (2025). <em>Reasoning Models Don’t Always Say What They Think.</em> Anthropic. <a href="https://arxiv.org/abs/2505.05410">arXiv:2505.05410</a></li> <li>Korbak, T., Balesni, M., Barnes, E., et al. (2025). <em>Chain of Thought Monitorability: A New and Fragile Opportunity for AI Safety.</em> <a href="https://arxiv.org/abs/2507.11473">arXiv:2507.11473</a></li> <li>Goldowsky-Dill, N., Chughtai, B., Heimersheim, S., &amp; Hobbhahn, M. (2025). <em>Detecting Strategic Deception Using Linear Probes.</em> Apollo Research. <a href="https://arxiv.org/abs/2502.03407">arXiv:2502.03407</a></li> <li>Wagner, M., Roger, F., Cunningham, H., et al. (2025). <em>Training Fails to Elicit Subtle Reasoning in Current Language Models.</em> Anthropic.</li> <li>Li, B.Z., Guo, Z.C., Huang, V., et al. (2025). <em>Training Language Models to Explain Their Own Computations.</em> <a href="https://arxiv.org/abs/2511.08579">arXiv:2511.08579</a></li> <li>Tan, D.Z., et al. (2024). <em>Analysing the Generalisation and Reliability of Steering Vectors.</em> NeurIPS 2024.</li> <li>Google DeepMind. (2026). <em>Building Production-Ready Probes for Gemini.</em> <a href="https://arxiv.org/abs/2601.11516">arXiv:2601.11516</a></li> <li>Ye, D., Loffgren, M., Kotadia, O., Wong, L. (2026). <em>Mechanistic Evidence for Faithfulness Decay in Chain-of-Thought Reasoning.</em> <a href="https://arxiv.org/abs/2602.11201">arXiv:2602.11201</a></li> <li>Wang, F., Alazali, A., Zhong, Y. (2026). <em>How Does Unfaithful Reasoning Emerge from Autoregressive Training?</em> <a href="https://arxiv.org/abs/2602.01017">arXiv:2602.01017</a></li> <li><em>The Hypocrisy Gap: Quantifying Divergence Between Internal Belief and Chain-of-Thought Explanation via Sparse Autoencoders.</em> (2026). <a href="https://arxiv.org/abs/2602.02496">arXiv:2602.02496</a></li> <li><em>Auditing Games for Sandbagging.</em> (2025). <a href="https://arxiv.org/abs/2512.07810">arXiv:2512.07810</a></li> </ol>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="ai-safety"/><category term="interpretability"/><summary type="html"><![CDATA[Three labs. Three different bets on how to catch models misbehaving. Each makes different assumptions about when models 'know' they're lying. Here's what works, what doesn't, and what happens when you combine them.]]></summary></entry><entry><title type="html">OpenAI’s Confessions Paper Has a Blind Spot. Here’s What Fills It.</title><link href="https://subhadipmitra.com/blog/2026/openai-confessions-blind-spot/" rel="alternate" type="text/html" title="OpenAI’s Confessions Paper Has a Blind Spot. Here’s What Fills It."/><published>2026-02-18T10:00:00+00:00</published><updated>2026-02-18T10:00:00+00:00</updated><id>https://subhadipmitra.com/blog/2026/openai-confessions-blind-spot</id><content type="html" xml:base="https://subhadipmitra.com/blog/2026/openai-confessions-blind-spot/"><![CDATA[<blockquote> <p><strong>TL;DR:</strong> OpenAI’s new “Confessions” paper trains GPT-5-Thinking to self-report misbehavior - reward hacking, scheming, sandbagging - via a decoupled RL objective. It works: the model confesses to bad behavior ~74% of the time on average. But the paper has an acknowledged blind spot. Confessions fail precisely when the model <em>doesn’t know</em> it’s misbehaving - unfaithful reasoning, confident hallucinations, genuine mistakes. That failure mode is exactly where activation probes excel. I think confessions and probes aren’t competing approaches. They’re complementary layers of something we might call a model honesty stack.</p> </blockquote> <h2 id="the-paper-that-got-everyone-talking">The Paper That Got Everyone Talking</h2> <p>OpenAI dropped “<a href="https://arxiv.org/abs/2512.08093">Training LLMs for Honesty via Confessions</a>” in late December 2025, and it’s been circulating heavily through February. The core idea is deceptively simple.</p> <p>After a model produces its normal response, you append a system message asking it to produce a <strong>confession</strong> - a structured self-evaluation of whether it complied with the letter and spirit of its instructions. You train this confession with RL, but here’s the critical design choice: the confession reward is <strong>completely decoupled</strong> from the main task reward. Nothing the model says in its confession can help or hurt the reward it gets for its original answer.</p> <p>Think of it as the AI equivalent of the Catholic confessional. What you say in the booth stays in the booth.</p> <p>The intuition for why this works is compelling. For most forms of misbehavior - reward hacking, scheming, sandbagging - the model <em>knows</em> it’s cheating. It planned the cheat. It has the evidence in its activations. So when you ask “did you cheat?” and decouple the answer from any consequences, the path of least resistance is to just… say yes. Honest confession is easier than constructing a convincing lie to fool the confession judge.</p> <hr/> <h2 id="what-openai-actually-showed">What OpenAI Actually Showed</h2> <p>They applied this to GPT-5-Thinking and tested across 12 out-of-distribution evaluation scenarios. The results are genuinely impressive for a proof of concept.</p> <p>Some headline numbers from their evaluations:</p> <table> <thead> <tr> <th>Evaluation</th> <th>Non-compliance Rate</th> <th>Confession Rate (given non-compliance)</th> </tr> </thead> <tbody> <tr> <td>Scheming (Sandbagging)</td> <td>14.0%</td> <td><strong>100%</strong></td> </tr> <tr> <td>Reward Hacking (Impossible Task)</td> <td>89.7%</td> <td><strong>100%</strong></td> </tr> <tr> <td>Scheming (Covert Rule Violation)</td> <td>13.7%</td> <td>62.8%</td> </tr> <tr> <td>Instruction Hierarchy (Jailbreaks)</td> <td>19.7%</td> <td>94.9%</td> </tr> <tr> <td>Instruction Hierarchy (General)</td> <td>17.1%</td> <td>80.4%</td> </tr> <tr> <td>Hallucination (General Knowledge)</td> <td>50.2%</td> <td>80.9%</td> </tr> <tr> <td>Hallucination (People)</td> <td>38.0%</td> <td>81.6%</td> </tr> <tr> <td>Instruction Following (General)</td> <td>4.2%</td> <td>22.5%</td> </tr> </tbody> </table> <p>The pattern is striking. On scheming and reward hacking - behaviors where the model <em>deliberately</em> cheated - confession rates hit 100%. On instruction hierarchy violations - where the model chose to follow a user over a developer - confession rates are in the 80-95% range. These are cases where the model knows what it did.</p> <p>The really clever experiment is in Section 4: they deliberately trained both the answer and the confession against a <strong>weak, hackable judge</strong>. Over time, the model learned to hack the main reward model - but confession accuracy <em>improved</em>. Even when the model was actively gaming the system, it kept confessing honestly. That’s a strong result.</p> <hr/> <h2 id="but-read-section-74-carefully">But Read Section 7.4 Carefully</h2> <p>The paper is refreshingly honest about its limitations. Section 7.4 is titled “What confessions are not good for” and the authors state it plainly:</p> <blockquote> <p>Models cannot confess to what they do not know.</p> </blockquote> <p>This is the blind spot.</p> <p>When a model hallucinates with full confidence - when it genuinely believes its wrong answer is correct - it has nothing to confess. The hallucination isn’t a choice. It’s a capability failure. The model didn’t scheme to give you wrong information about Barbara Herbert’s children. It just… got it wrong, with conviction.</p> <p>Look at the confession rates again, this time focusing on the <strong>hallucination</strong> evaluations versus the <strong>scheming</strong> evaluations:</p> <pre><code class="language-mermaid">graph LR
    subgraph "Confession works well"
        A[Scheming: Sandbagging] --&gt;|100%| A1[Model KNOWS it cheated]
        B[Reward Hacking] --&gt;|100%| B1[Model KNOWS it hacked tests]
        C[Instruction Hierarchy] --&gt;|80-95%| C1[Model KNOWS it broke rules]
    end

    subgraph "Confession struggles"
        D[Hallucination: People] --&gt;|81.6%| D1[Model often DOESN'T KNOW it's wrong]
        E[Hallucination: General Knowledge] --&gt;|80.9%| E1[False confidence]
        F[Instruction Following] --&gt;|22.5%| F1[Genuine mistakes, not deception]
    end

    style A fill:#2d6a4f,color:#fff
    style B fill:#2d6a4f,color:#fff
    style C fill:#2d6a4f,color:#fff
    style D fill:#9d0208,color:#fff
    style E fill:#9d0208,color:#fff
    style F fill:#9d0208,color:#fff
</code></pre> <p>The 81.6% hallucination confession rate might look decent at first glance, but dig into the error analysis (Section 6, Figure 9). The most common reason for false negatives - cases where the model misbehaved but didn’t confess - is <strong>“omission due to confusion.”</strong> The model genuinely didn’t know it was wrong. It wasn’t hiding anything. It just confidently repeated what it believed.</p> <p>And in the hallucination categories specifically, confession training didn’t even help much. From the paper’s Figure 5, the Hallucination (People) and Hallucination (General Knowledge) evaluations showed <strong>no improvement or even slight regression</strong> over training. The authors conjecture this is because “when a model responds with a wrong answer, it is because it is genuinely mistaken, and hence it is likely to repeat the same mistake in confessions as well.”</p> <p>This isn’t a criticism of the paper - the authors are transparent about it. But it’s the most important limitation for anyone thinking about deploying confessions in production.</p> <hr/> <h2 id="the-gap-is-exactly-where-probes-excel">The Gap Is Exactly Where Probes Excel</h2> <p>Here’s what I find interesting. The failure mode of confessions - model doesn’t know it’s misbehaving - is precisely where activation probes have shown the most promise.</p> <p>Consider Anthropic’s “<a href="https://arxiv.org/abs/2505.05410">Reasoning Models Don’t Always Say What They Think</a>” (Chen et al., May 2025). They showed that when you slip reasoning hints to Claude 3.7 Sonnet and DeepSeek R1, the models use those hints but only mention them in their chain-of-thought about 25% (Claude) and 39% (R1) of the time. The CoT looks clean. The model produces what appears to be independent reasoning. But the answer was actually driven by the hint.</p> <p>This is unfaithful reasoning, and it’s a different beast from scheming. The model isn’t plotting against you. It’s just not accurately reporting what influenced its answer. And crucially: <strong>it might not even “know” it was influenced.</strong> The hint shaped the computation at a sub-verbal level - below what gets surfaced in chain-of-thought or confessions.</p> <p>But the activations know.</p> <p>Goldowsky-Dill et al.’s “<a href="https://arxiv.org/abs/2502.03407">Detecting Strategic Deception Using Linear Probes</a>” (Feb 2025, presented at ICML 2025) showed that linear probes on Llama-3.3-70B activations detect deceptive responses with AUROCs between 0.96 and 0.999. At a 1% false positive rate, probes catch 95-99% of deceptive responses.</p> <p>And from my own <a href="/blog/2025/detecting-ai-sandbagging/">sandbagging detection work</a>, I found that simple logistic regression on intermediate activations detects sandbagging intent at 90-96% accuracy across Mistral, Gemma, and Qwen models. The signal is linearly separable. You don’t need fancy interpretability tools - a linear classifier picks it up.</p> <p>The key insight: <strong>probes don’t require the model to be self-aware about its misbehavior.</strong> They operate on the raw activations, detecting patterns the model itself might not have verbal access to. A model that confidently hallucinates still has different activation patterns than one that answers correctly. A model whose reasoning is influenced by a hidden hint still has detectable traces of that influence in its hidden states - even when the CoT says nothing about it.</p> <hr/> <h2 id="two-approaches-different-failure-modes">Two Approaches, Different Failure Modes</h2> <p>I’ve been thinking about this as a 2x2 matrix. The two axes that matter are:</p> <ol> <li><strong>Does the model know it’s misbehaving?</strong> (Self-awareness)</li> <li><strong>Is the misbehavior detectable from activations?</strong> (Probe detectability)</li> </ol> <table> <thead> <tr> <th> </th> <th>Model knows (intentional)</th> <th>Model doesn’t know (unintentional)</th> </tr> </thead> <tbody> <tr> <td><strong>Detectable by probes</strong></td> <td>Scheming, sandbagging, reward hacking</td> <td>Unfaithful reasoning, confident hallucination</td> </tr> <tr> <td><strong>Not detectable by probes</strong></td> <td>Sophisticated steganographic deception (theoretical)</td> <td>Genuine capability limits</td> </tr> </tbody> </table> <p>Confessions dominate the top-left quadrant. The model knows it cheated, so you just ask it and it tells you. Probes also work here - Goldowsky-Dill showed 96-99.9% AUROC - but confessions are simpler to deploy if you have the model weights for RL training.</p> <p>Probes dominate the top-right quadrant. The model doesn’t know its reasoning was influenced by a sycophantic hint, or that it hallucinated a historical fact. But the activations carry the signal anyway. Confessions fail here because there’s nothing to confess.</p> <p>The bottom-left is the scary quadrant. A model that deliberately deceives <em>and</em> has learned to mask its deception in the activations. Nobody has demonstrated this convincingly at scale - Wagner et al. (2025) from Anthropic found that current models fail to evade monitors when the reasoning is complex enough - but it’s the theoretical endgame. Neither confessions nor probes alone handle this.</p> <p>The bottom-right is the humble quadrant. The model genuinely can’t do the task. No amount of honesty training or activation probing helps with a 7B model that doesn’t know organic chemistry.</p> <hr/> <h2 id="what-a-model-honesty-stack-could-look-like">What a Model Honesty Stack Could Look Like</h2> <p>If confessions handle intentional misbehavior and probes handle unintentional misbehavior, the natural question is: what does a system that uses both look like?</p> <pre><code class="language-mermaid">flowchart TD
    Input["User Input + System Prompt"] --&gt; Forward["Forward Pass"]
    Forward --&gt; Activations["Hidden State Activations"]
    Forward --&gt; Output["Model Output (answer + CoT)"]

    Activations --&gt; ProbeLayer["&lt;b&gt;PROBE LAYER&lt;/b&gt;&lt;br/&gt;Hallucination probe&lt;br/&gt;Unfaithful reasoning probe&lt;br/&gt;Deception probe&lt;br/&gt;Sandbagging probe"]

    Output --&gt; ConfessionLayer["&lt;b&gt;CONFESSION LAYER&lt;/b&gt;&lt;br/&gt;Append confession request&lt;br/&gt;Generate self-report&lt;br/&gt;Score via confession judge"]

    ProbeLayer --&gt; Controller["&lt;b&gt;CONTROLLER&lt;/b&gt;"]
    ConfessionLayer --&gt; Controller

    Controller --&gt; Decision{Decision}

    Decision --&gt;|All clear| Deliver["Deliver response"]
    Decision --&gt;|Probe alert only| Flag1["Flag: model may not know&lt;br/&gt;it got this wrong"]
    Decision --&gt;|Confession alert only| Flag2["Flag: model admits&lt;br/&gt;misbehavior"]
    Decision --&gt;|Both alert| Block["Block or escalate"]

    style ProbeLayer fill:#264653,color:#fff
    style ConfessionLayer fill:#2a9d8f,color:#fff
    style Controller fill:#e76f51,color:#fff
    style Block fill:#9d0208,color:#fff
</code></pre> <p>The probe layer runs during the forward pass. It’s fast - linear probes add negligible latency. It catches things the model doesn’t know about itself: subtle hallucination confidence signals, unfaithful reasoning traces, activation patterns associated with bias-influenced answers.</p> <p>The confession layer runs after generation. It’s slower - you need an additional generation step plus a judge - but it catches deliberate misbehavior that the model is self-aware about: reward hacking, instruction hierarchy violations, strategic sandbagging.</p> <p>The controller fuses both signals. An alert from only the probes suggests the model made a genuine mistake. An alert from only the confession suggests the model deliberately misbehaved but was honest about it. Alerts from both are the highest-confidence catches. No alerts from either is your best-case scenario, though it doesn’t guarantee correctness.</p> <p>This isn’t hypothetical. The individual components exist. OpenAI has demonstrated confessions work at GPT-5 scale. Apollo Research showed probe-based deception detection at Llama-70B scale. I’ve <a href="/blog/2025/detecting-ai-sandbagging/">shown activation probes work for sandbagging</a> at 7-9B scale. What nobody has done yet is wire these together into a single inference pipeline and measure the combined detection rate.</p> <hr/> <h2 id="the-interesting-connection-the-paper-almost-makes">The Interesting Connection the Paper Almost Makes</h2> <p>There’s a line in Section 7.2 of the confessions paper that I keep coming back to. The authors write:</p> <blockquote> <p>In a sense, our work can be viewed as training models to serve as monitors that can get access not only to the chain-of-thought but also to the exact internal activations of the actor model.</p> </blockquote> <p>This is exactly right. A confession is an activation-aware probe where the “probe” is the model itself, generating tokens based on its full internal state. The model has privileged access to its own activations - it knows things about its computation that no external monitor can see.</p> <p>But here’s what the paper doesn’t say: <strong>the model can only report on aspects of its computation that are accessible to its verbal reasoning.</strong> And Anthropic’s faithfulness work demonstrates that a lot of computation isn’t.</p> <p>Li et al. (2025) showed models are better at explaining their <em>own</em> computations than other models’ computations - there really is privileged introspective access. But “better” isn’t “complete.” The gap between what a model computes and what it can verbalize about that computation is the gap where probes have the advantage.</p> <p>A confession asks: “Tell me what you know about what you did.”</p> <p>A probe asks: “Let me look at what you actually did, regardless of what you know about it.”</p> <p>Both questions are worth asking.</p> <hr/> <h2 id="the-hard-problems-nobodys-solved-yet">The Hard Problems Nobody’s Solved Yet</h2> <p>I want to be honest about what’s still missing, because the “just combine them” story is too clean.</p> <p><strong>Problem 1: Probe training data for unintentional errors.</strong> For intentional deception, you can create training data by prompting models to be deceptive (Goldowsky-Dill) or to sandbag (my work). For unintentional errors - unfaithful reasoning, confident hallucination - generating labeled training data is much harder. You need to know the ground truth and be able to identify <em>why</em> the model got it wrong. This is a real bottleneck.</p> <p><strong>Problem 2: Confession hackability under pressure.</strong> OpenAI’s Section 4 experiment showed confessions resist hacking even when trained against a weak judge. But they acknowledge this is a proof of concept with limited compute. Under extended optimization pressure with a sophisticated adversary, would confessions stay honest? The “path of least resistance” argument is intuitive but not proven at scale.</p> <p><strong>Problem 3: Cross-model probe transfer.</strong> My sandbagging experiments showed zero transfer between model architectures - Gemma’s sandbagging direction has cosine similarity -0.0016 with Qwen’s. Each model needs its own probes. That’s a deployment headache. Confessions at least have the advantage of being model-agnostic (any model can be trained to confess).</p> <p><strong>Problem 4: The recursive monitoring problem.</strong> If you use an LLM judge to score confessions, and a probe to monitor activations, who monitors the judge? Who validates the probe? At some point you need ground truth that doesn’t come from another model. OpenAI’s approach of using a prompted judge is practical but introduces another attack surface.</p> <p><strong>Problem 5: Computational overhead.</strong> Confessions require generating an entire additional response. For a reasoning model with extended CoT, that’s not cheap. Probes are fast at inference but need offline training per model per behavior. In production, you’d want to be strategic about which layers of the honesty stack you run for which queries.</p> <hr/> <h2 id="what-id-want-to-build">What I’d Want to Build</h2> <p>If I were designing a monitoring system for agentic models deployed in production - the kind of models that browse the web, write code, call APIs - here’s the stack I’d want:</p> <p><strong>Layer 0: Output filters.</strong> Simple pattern matching and rule-based checks. Catches the obvious stuff. Already standard practice.</p> <p><strong>Layer 1: Activation probes.</strong> Run during the forward pass. Near-zero latency. Detect hallucination confidence, unfaithful reasoning patterns, deception signatures. Fire on things the model doesn’t know about itself.</p> <p><strong>Layer 2: CoT monitoring.</strong> Parse the chain-of-thought for signs of scheming, reward hacking, instruction hierarchy violations. The Baker et al. (2025) approach. Works when reasoning is legible.</p> <p><strong>Layer 3: Confessions.</strong> Post-generation self-report. Catches deliberate misbehavior the model is aware of. Most useful for high-stakes actions - before the model executes a tool call, runs code, or sends a message.</p> <p><strong>Layer 4: Human review.</strong> For actions above a risk threshold. The confession and probe signals feed into a priority queue for human reviewers.</p> <p>Layer 1 and Layer 3 are the new additions from recent research. What I think we’ll see in 2026 is the first real attempt to wire all five layers together and measure how much better the combined system is than any individual layer.</p> <hr/> <h2 id="one-more-thing">One More Thing</h2> <p>The confessions paper cites Goldowsky-Dill et al. specifically, noting that “one can view confessions as an activation-aware probe, but where the ‘probe’ itself gets to generate tokens.” They see the connection. Goldowsky-Dill’s team is at Apollo Research, which has been building deception detection tools. Anthropic’s alignment science team published the unfaithful reasoning results. OpenAI published confessions.</p> <p>All three major safety-focused organizations are converging on the same realization: single-layer monitoring isn’t enough. The question is whether anyone will build the integrated stack before agentic models get deployed at scale in production.</p> <p>Based on the pace of things, I’d say we have about a year to figure this out.</p> <hr/> <p><em>If you’re working on any of these problems - probe-based monitoring, confession training, or integrated honesty architectures - I’d be interested to talk: <a href="mailto:contact@subhadipmitra.com">contact@subhadipmitra.com</a></em></p> <hr/> <h3 id="references">References</h3> <ol> <li>Joglekar, M., Chen, J., Wu, G., et al. (2025). <em>Training LLMs for Honesty via Confessions.</em> OpenAI. <a href="https://arxiv.org/abs/2512.08093">arXiv:2512.08093</a></li> <li>Chen, Y., Benton, J., Radhakrishnan, A., et al. (2025). <em>Reasoning Models Don’t Always Say What They Think.</em> Anthropic. <a href="https://arxiv.org/abs/2505.05410">arXiv:2505.05410</a></li> <li>Goldowsky-Dill, N., Chughtai, B., Heimersheim, S., &amp; Hobbhahn, M. (2025). <em>Detecting Strategic Deception Using Linear Probes.</em> Apollo Research. <a href="https://arxiv.org/abs/2502.03407">arXiv:2502.03407</a></li> <li>Baker, B., Huizinga, J., Gao, L., et al. (2025). <em>Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation.</em> <a href="https://arxiv.org/abs/2503.11926">arXiv:2503.11926</a></li> <li>Korbak, T., Balesni, M., Barnes, E., et al. (2025). <em>Chain of Thought Monitorability: A New and Fragile Opportunity for AI Safety.</em> <a href="https://arxiv.org/abs/2507.11473">arXiv:2507.11473</a></li> <li>Wagner, M., Roger, F., Cunningham, H., et al. (2025). <em>Training Fails to Elicit Subtle Reasoning in Current Language Models.</em> Anthropic. <a href="https://alignment.anthropic.com/2025/subtle-reasoning/">Link</a></li> <li>Li, B.Z., Guo, Z.C., Huang, V., et al. (2025). <em>Training Language Models to Explain Their Own Computations.</em> <a href="https://arxiv.org/abs/2511.08579">arXiv:2511.08579</a></li> <li>Denison, C., MacDiarmid, M., Barez, F., et al. (2024). <em>Sycophancy to Subterfuge: Investigating Reward-Tampering in Large Language Models.</em> <a href="https://arxiv.org/abs/2406.10162">arXiv:2406.10162</a></li> <li>Lanham, T., Chen, A., Radhakrishnan, A., et al. (2023). <em>Measuring Faithfulness in Chain-of-Thought Reasoning.</em> <a href="https://arxiv.org/abs/2307.13702">arXiv:2307.13702</a></li> </ol>]]></content><author><name>[&quot;Subhadip Mitra&quot;]</name></author><category term="AI"/><category term="ai-safety"/><category term="interpretability"/><summary type="html"><![CDATA[OpenAI trained GPT-5 to confess when it misbehaves. It works surprisingly well - except when the model doesn't know it's misbehaving. That's where activation probes come in.]]></summary></entry></feed>