<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Varun Nuthalapati on Medium]]></title>
        <description><![CDATA[Stories by Varun Nuthalapati on Medium]]></description>
        <link>https://medium.com/@nuthalapativarun?source=rss-455eed7ebf5a------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*TThBBB7qzKHposKyxVClsA.jpeg</url>
            <title>Stories by Varun Nuthalapati on Medium</title>
            <link>https://medium.com/@nuthalapativarun?source=rss-455eed7ebf5a------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 10:43:52 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@nuthalapativarun/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Siri Now Reads SKILL.md: Apple Turned App Intents Into an Agent Tool API]]></title>
            <link>https://medium.com/@nuthalapativarun/siri-now-reads-skill-md-apple-turned-app-intents-into-an-agent-tool-api-87ca4a9024e2?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/87ca4a9024e2</guid>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[siri]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sat, 14 Nov 2026 21:08:58 GMT</pubDate>
            <atom:updated>2026-11-14T21:08:58.966Z</atom:updated>
            <content:encoded><![CDATA[<h4>The iOS 27.3 developer preview gives Siri AI the same building blocks every agent harness uses (tools, model-filled arguments, skills and evals), under Apple’s names and Apple’s rules.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A1Mw4-VH8GxRZj9DN5Iufw.jpeg" /></figure><p>Buried in Apple’s documentation for the Siri AI Developer Preview is a sentence any agent developer will recognize: app skills use <strong>a subset of the Agent Skills format</strong>. That means a folder per skill, a SKILL.md file, YAML frontmatter with a name and a description, and a Markdown body of instructions. It&#39;s the same file layout coding agents load today, now compiled into iPhone apps and read by Siri.</p><p>That’s the clearest sign of what Apple shipped on November 3, 2026. Starting with the iOS, iPadOS, macOS and visionOS 27.3 betas, any developer can expose custom actions and content to Siri AI, Apple’s new conversational Siri, not just functionality that fits Apple’s predefined app schemas. Apple’s developer news post says App Store submissions open in spring, and that the features reach customers a few months after that.</p><p>Read as an agent developer, the preview maps almost one-to-one onto the architecture you’d build for any tool-calling model. This piece walks through that mapping (tools, generated arguments, skills and evals), with the concept in focus being <strong>how Siri decides what to call</strong>, and what you can and can’t control about it.</p><h3>The mapping, in one table</h3><ul><li><strong>Tool / function definition:</strong> @AppIntent(.tool) → ToolIntent. Marks an app intent as something Siri can call.</li><li><strong>Tool and parameter descriptions:</strong> title, description, @Parameter(title:description:). What the model reads to pick a tool and fill arguments.</li><li><strong>Structured output for arguments:</strong> @AppEntity(.generable) + GenerableEntity. Lets the model build an entity directly from the request.</li><li><strong>Retrieval:</strong> Spotlight index, EnumerableEntityQuery. How Siri finds your content to pass into tools.</li><li><strong>Skill / playbook:</strong> Skills.skillsbundle/Skills/&lt;name&gt;/SKILL.md. Natural-language guidance for multi-step tasks.</li><li><strong>Evals:</strong> SiriEvaluations framework. Scripted multi-turn scenarios with deterministic checks.</li><li><strong>Permission policy:</strong> IntentAuthenticationPolicy, IntentVisibility. Which data needs authentication, and what Siri can&#39;t see at all.</li></ul><h3>Tools: @AppIntent(.tool)</h3><p>App Intents have existed for years as the way apps expose actions to Shortcuts, Spotlight and widgets. The preview adds one trait. Annotate an intent with @AppIntent(.tool) and it conforms to ToolIntent, which Apple&#39;s docs define as an app intent Siri can call while handling a request.</p><p>Here’s a minimal example for a hypothetical recipe app, written against the preview documentation. It hasn’t been compiled, because it needs the 27.3 beta SDK, and Apple warns the API may change during the preview.</p><pre>import AppIntents<br><br>@AppIntent(.tool)<br>struct ScaleRecipe: AppIntent {<br>    static let title: LocalizedStringResource = &quot;Scale Recipe&quot;<br>    static let description = IntentDescription(<br>        &quot;Changes a saved recipe&#39;s ingredient amounts to serve a different number of people.&quot;<br>    )<br><br>    // RecipeEntity (an AppEntity) and RecipeStore are the app&#39;s own types.<br>    @Parameter(title: &quot;Recipe&quot;, description: &quot;A recipe the person has saved in the app&quot;)<br>    var recipe: RecipeEntity<br><br>    @Parameter(title: &quot;Servings&quot;, description: &quot;Number of people to serve, from 1 to 24&quot;)<br>    var servings: Int<br><br>    func perform() async throws -&gt; some IntentResult {<br>        try await RecipeStore.shared.scale(recipe, to: servings)<br>        return .result()<br>    }<br>}</pre><p>The descriptions aren’t decoration. They’re the prompt. Apple’s ToolIntent docs tell you to write each description for someone who doesn&#39;t know your app, and to include string formats, numeric ranges and units. That&#39;s exactly the advice you&#39;d give for an OpenAI or Anthropic tool schema, because it serves the same purpose.</p><p>Apple is also specific about what <strong>shouldn’t</strong> be a tool:</p><ul><li>Widget configuration intents, which don’t perform an action</li><li>Intents that are only one step inside a larger shortcut</li><li><strong>Intents with side effects a person needs to start explicitly, such as placing and paying for an order</strong></li></ul><p>The last one is a policy decision as much as a technical one. Siri can put things in your cart, but Apple’s guidance is that checkout isn’t something to hand the model.</p><p>Two schema rules are easy to trip over. If your intent already adopts one of Apple’s Siri-supported app schemas (for example sendMessage for a messaging app), Siri can already call it, and adding .tool on top is a build error. If it adopts a schema that only Shortcuts supports, you promote it with @AppIntent(.tool, schema: ...).</p><h3>Arguments the model fills itself: generable entities</h3><p>Normally, when a tool takes one of your app’s entity types as a parameter, Siri finds a value by running that entity’s query against your app. The preview adds a second path. Mark a transient entity with @AppEntity(.generable) (which conforms it to GenerableEntity and requires TransientAppEntity), and Siri&#39;s model can create the entity and fill in its properties straight from what the person said.</p><p>Apple’s example is a flight search: a request naming an origin, a destination, a date and a cabin class becomes a populated search entity that’s passed to the find-flight tool, with no lookup in the app. In harness terms, this is structured output for tool arguments. The docs add one constraint worth noting: every property of a generable entity must itself be a generable type.</p><p>The other path, finding real content the person already has, runs through Spotlight. Siri can surface entities your app puts in the Spotlight semantic index, and Apple’s news post says this now works for any entity in the index, not only ones that fit an existing schema. If an entity isn’t indexed, the system can also retrieve it from an EnumerableEntityQuery.</p><h3>When Siri can call your app at all</h3><p>This is the part that differs most from a desktop agent harness, where every tool is always in scope. Per the developer preview docs, Siri considers your app’s tools without the person naming your app only when:</p><ul><li>your app is in the <strong>foreground</strong>,</li><li>one of its <strong>Live Activities</strong> is active, or</li><li>the person is <strong>continuing a conversation</strong> in the Siri app that already mentioned your app.</li></ul><p>When your app is in the background, the person has to name it. Apple’s ride-share example makes the foreground rule concrete: while the ride’s Live Activity is showing, “change my ride pickup to</p><p>“ goes to the right app without naming it.</p><p>Requests can also cross apps. Siri can find an item in one app, resolve a contact, and send the item through a messaging app. For that to work, the entities that move between apps need a Transferable representation.</p><h3>Skills: progressive disclosure, Siri edition</h3><p>Tools tell Siri what it can do. <strong>App skills</strong> tell it how to combine them. Apple’s docs list what a skill should contain: how tool intents fit together for a task, which tool to prefer when several could apply, what to avoid, domain knowledge specific to your app, and guidance for clarifying conversations.</p><p>The format, per Apple’s “Orchestrating complex actions with app skills”:</p><ul><li>One Skills.skillsbundle per app target (Xcode has a &quot;Skills Bundle&quot; template), with a Skills/ folder inside.</li><li>One subfolder per skill, containing <strong>only</strong> a SKILL.md. No scripts, references or other supporting files.</li><li>Frontmatter with exactly two supported fields. name must be 1–64 lowercase letters, digits and hyphens, and match the folder name. description is required, a single line of up to 1,024 characters.</li><li>A free-form Markdown body of <strong>up to 10,000 characters</strong>.</li></ul><p>Here’s one for the recipe app:</p><pre>---<br>name: plan-dinner-party<br>description: Plans a dinner party from saved recipes, scales them to the guest count, and builds one combined shopping list.<br>---<br>Ask how many guests are coming before doing anything else.<br>Search saved recipes with the find recipes tool; prefer recipes the<br>person has marked as favorites. Scale each chosen recipe with the<br>scale recipe tool to the guest count, then use the add to shopping<br>list tool once per recipe. Never remove items the person already has<br>on their shopping list. If a recipe has no saved servings count,<br>ask instead of guessing.</pre><p>Compared with the open Agent Skills specification, Apple’s subset drops a lot. The spec allows optional scripts/, references/ and assets/ folders, plus license, compatibility, metadata and an experimental allowed-tools field. Siri supports none of them. Apple also adds limits the spec doesn&#39;t have: a single-line description and a hard 10,000-character body. A Siri skill can describe, but it can&#39;t bring code along.</p><p>How Siri uses this is the concept worth understanding. <strong>Siri reads only the frontmatter to decide whether a skill applies, and reads the body only after it decides the skill matches.</strong> That’s the progressive-disclosure design the Agent Skills spec describes. Every installed skill’s name and description is loaded up front, and the full instructions only when the skill is activated, so lots of skills can be installed cheaply and the model only pays for the ones it uses. In practice, the description line does most of the work. Apple&#39;s verification advice is to rephrase test requests and check that the description is specific enough to be picked and broad enough not to miss common phrasings.</p><p>Three more rules are easy to miss:</p><ul><li><strong>Skills are guidance, not workflows.</strong> Apple says plainly that you can’t use app skills to enforce deterministic behavior, and that Siri doesn’t execute the listed tool intents in order. If a step must happen, it belongs in code, in the intent’s perform(), not in the skill.</li><li><strong>Refer to tools by what they do.</strong> The docs recommend “call the get weather tool first” over the Swift type name, and say not to repeat information your intents’ titles and descriptions already provide.</li><li><strong>Names must be unique</strong> across the app, its extensions and embedded frameworks. If two skills share a name, the system uses one of them, and which one is undefined.</li></ul><h3>Permissions: what Siri may say, and what it can’t see</h3><p>Two controls govern data exposure.</p><p><strong>IntentAuthenticationPolicy</strong> can be set on a whole entity or on individual properties. Apple&#39;s example is a bank-account entity where the balance requires local device authentication before Siri presents it. The docs flag a subtlety: a property-level policy only covers how Siri presents that entity in responses. If an intent returns sensitive data as its result, set the policy on the intent itself.</p><p><strong>IntentVisibility.internal</strong> hides a property from Siri, Shortcuts and other system features entirely, so only your app and its extensions can read it. Apple&#39;s example is a bank routing number.</p><h3>Evals: SiriEvaluations</h3><p>The last piece is the one most teams skip with their own agents. The new SiriEvaluations framework (iOS/macOS 27.3+) lets you script multi-turn conversations, the SiriScenario and UserTurn types, and state expectations about each turn:</p><ul><li>Calls: Siri calls (or doesn&#39;t call) specific intents</li><li>Finds: Siri surfaces specific entities</li><li><strong>Loads: Siri loads specific app skills</strong></li><li>OnScreen: specific entities are on screen</li><li>SiriResponse: the reply contains specific text</li></ul><p>Each check is deterministic, but Siri isn’t, and Apple’s docs say so. They recommend building a broad scenario set with indirect, incomplete and unexpected phrasings, running it more than once, and improving your intents, parameter descriptions and skills until more of the set passes, and passes consistently. That’s the same discipline as evaluating any LLM agent: measure pass rates, not single runs. Xcode also adds a Siri Integration Advisor that reviews your App Intents code and suggests improvements.</p><h3>What you can do today, and what you can’t yet</h3><ul><li><strong>Testing is limited to your own devices.</strong> You need a development-signed build on a device that supports the preview, or internal TestFlight testers. External TestFlight testers and App Store users don’t get the Siri AI behavior yet, and the Skills verification guidance says to use a device, not the Simulator.</li><li><strong>Availability has gaps.</strong> Apple’s news post says Siri AI needs an Apple Intelligence device set to English, French, Japanese, Korean, Portuguese or Spanish, with Traditional Chinese, Norwegian, Turkish and Vietnamese coming soon. It isn’t currently available in the EU on iOS, iPadOS and watchOS, and isn’t available to users under 13.</li><li><strong>There’s a meter.</strong> Server-side Apple Intelligence features, Siri AI included, have daily usage limits that may vary, and Apple says expanded access will cost a fee in the future. It also says terms and usage policies for Siri AI experiences are still to come.</li><li><strong>The API may change.</strong> The preview docs repeat that warning on page after page.</li></ul><h3>The takeaway</h3><p>What Apple shipped isn’t a new idea. It’s an existing one (tools, structured arguments, skills and evals) adopted at the scale of an operating system, with App Intents as the tool layer and a deliberately constrained subset of an open skill format on top. For developers already building agents, the learning curve is mostly vocabulary: tool descriptions are prompts, the skill description is a router, and evals measure a pass rate. For Apple, the constraints are the product. Siri calls your app only when it&#39;s in context, Apple&#39;s guidance keeps checkout a human action, sensitive fields can sit behind authentication, and skills can suggest but never script. If those boundaries hold up in practice, every app on the phone becomes a tool for one assistant, and the quality of your descriptions decides whether yours gets called.</p><p><strong>Sources:</strong> — Apple Developer News, “Siri AI is now open to all developers” (November 3, 2026) — developer.apple.com/news/?id=gdk2umrf — Apple Developer Documentation, “Siri AI Developer Preview” — developer.apple.com/documentation/appintents/siri-ai-developer-preview — Apple Developer Documentation, “Bringing custom actions and content to Siri” — Apple Developer Documentation, “Orchestrating complex actions with app skills” — Apple Developer Documentation, ToolIntent and the Siri Evaluations framework - Agent Skills, &quot;Specification&quot; — agentskills.io/specification</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=87ca4a9024e2" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Three Mac Inference Servers Built for Agents, Not Chat — and No One Has Benchmarked Them Against…]]></title>
            <link>https://medium.com/@nuthalapativarun/three-mac-inference-servers-built-for-agents-not-chat-and-no-one-has-benchmarked-them-against-70e53f2f6c25?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/70e53f2f6c25</guid>
            <category><![CDATA[inference]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[apple]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sat, 14 Nov 2026 00:51:01 GMT</pubDate>
            <atom:updated>2026-11-14T00:51:01.509Z</atom:updated>
            <content:encoded><![CDATA[<h3>Three Mac Inference Servers Built for Agents, Not Chat — and No One Has Benchmarked Them Against Each Other</h3><h4>inferd, Locals and Rapid-MLX all shipped notable releases in the last two weeks. They make three different bets about what a Mac running a team of agents needs, and each measures itself against a different rival.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oXkzgre9aBrzdhkDFhB0Ig.jpeg" /></figure><p>On a MacBook Pro with an M2 Pro and 16 GB, running Qwen3.5–4B, Apple’s mlx-lm server writes <strong>87 tokens per second for four concurrent requests and the same 87 for eight</strong>, according to Locals’ README. Add agents and the total doesn’t move. Each agent just gets slower.</p><p>That flat line is the problem three projects set out to fix. Between October 29 and November 5, 2026, three Apache 2.0 Mac servers shipped releases that explicitly target agent workloads (many concurrent requests, long prompts that grow every turn, tool calls that have to parse) instead of a single chat window:</p><ul><li><strong>inferd</strong> (Incept5): a from-scratch Metal engine for big Macs and hybrid models</li><li><strong>Locals</strong> (Generated Computers): an MLX-based server with small-batch kernels and GPU-side tool-call grammars, aimed at 16 GB laptops</li><li><strong>Rapid-MLX 1.0</strong>: an MLX-based server that bets on agent-client compatibility and single-stream speed</li></ul><p>I wrote a separate deep dive on inferd’s caching internals. This piece is the map: why the plateau happens, what each project does about it, what each one compares itself to, and a script to run the comparison none of them have published.</p><h3>Why throughput plateaus: the small-batch problem</h3><p>On a Mac, generating one token for one request means reading essentially the whole model from memory once. The Locals paper calls this a “weight stream.” That’s bandwidth-bound work: the arithmetic per weight read is tiny. Batching is how you escape it. If eight requests decode in the same step, you read the weights once and do eight rows of math, so you get roughly eight tokens for the price of one weight read, until the math becomes the bottleneck.</p><p>The catch is the shape of those matrix multiplies. With one request, each layer is a matrix-vector product (M = 1 row). Prefilling a long prompt is a big matrix-matrix product (M in the thousands), which GPUs and their kernels are built for. Agent decoding lives in between, at M = 2 to 64 or so. That’s an awkward size, and kernels tuned for the two ends often handle it poorly. If the kernel for 8 rows costs nearly twice what it costs for 4, throughput stops scaling. That’s the shape of the 87-and-87 plateau.</p><p>inferd’s architecture doc shows what fixing this looks like. It routes each matrix multiply to a different kernel by row count, and for 3 to 255 rows it uses its own int8-activation path. Its measured linear-layer time per step on the 27B (M5 Ultra) is <strong>22.5 ms at 5 rows, 38.8 ms at 40 rows and 56.2 ms at 80 rows</strong>. That’s 16 times the rows for about 2.5 times the time. Locals describes its own fix the same way: GPU kernels written for a few requests at a time.</p><p>All three servers attack that middle band. They differ in everything around it.</p><h3>Bet 1 — inferd: rebuild the engine for big Macs and hybrid models</h3><p>inferd (v0.4.1, November 5) is C++, Objective-C++ and Metal, with no MLX runtime in the token path, though it vendors MLX’s kernels for some operations. It’s built around Qwen3.8–27B with a DFlash2 speculative drafter, and its hard problem is caching: Qwen3.8 is a hybrid model whose Gated DeltaNet layers carry a recurrent state that can’t be paged like a KV cache. inferd snapshots that state at chosen points, pages the attention KV in shared 8-bit blocks, parks evicted contexts on the SSD, and exposes an API (/v1/contexts, plus an inferd request field) so an agent framework can pin a shared context and fork agents from it.</p><p><strong>Headline numbers (maintainer-reported):</strong> on a Mac Studio M5 Ultra with 256 GB, 849 tok/s total for 64 agents on 16k contexts. On a Mac mini M6 with 32 GB, 13.3–14.0 completed agent turns per minute on the 27B, versus 8.8–8.9 for Splash 1.5.</p><p><strong>Compares itself against:</strong> Splash and TensorFold, two agent-oriented Mac engines, with the same weights and drafter.</p><p><strong>Says it loses at:</strong> single-stream speed (TensorFold’s fast mode is about 19% faster on the 27B), and a handful of agents starting together on some models.</p><p><strong>Hardware:</strong> macOS 26 or 27. M5-class GPUs are the main target, M3/M4 are lightly tested, and M1/M2 are untested.</p><h3>Bet 2 — Locals: small kernels, plus tool calls that can’t fail to parse</h3><p>Locals (v0.2.2, November 4) runs on top of MLX and aims at the most common local-AI machine: a laptop with 16 GB. Its default model is Qwen3.5–4B, and the project says it works on M1 or newer with macOS 14 or newer.</p><p>It does two things. The first is small-batch decode kernels. Its README chart on the M2 Pro shows mlx-lm at 61, 82, 87, 87 and 157 tok/s with 1, 2, 4, 8 and 16 concurrent requests, against Locals at 64, 116, 160, 271 and 341. That’s the 3.1x headline at eight requests, and roughly 1.0x at one.</p><p>The second, and more unusual, is <strong>grammar-constrained decoding moved onto the GPU</strong>, based on a paper by Arip Asadulaev of Generated Computers, which points to Locals as its code: <em>Breaking the Space Barrier and its Application to Language Model Inference</em> (arXiv:2610.09139). Here’s the idea in plain terms. A JSON schema or a tool-call format can be compiled into a graph of allowed states, and checking whether the model’s next token is legal amounts to finding a path through that graph. For a large tool grammar, the machinery behind that check can be big. The paper’s comparison point for one grammar is 824 MB. The paper’s theory section proves a space bound for a family of graph-reachability problems. Its application section shows that the grammar machinery inside LLM decoding has the “exactly one path between any two states” structure the theory needs, and turns each proof into a decoding mechanism. Two practical consequences:</p><ul><li><strong>Forced text is written without running the model.</strong> When the grammar allows only one continuation (a closing brace, a fixed key name), the engine emits it directly. This is the same idea as “jump-forward” decoding.</li><li><strong>Grammars get small enough to live on the GPU.</strong> The paper reports a 1,106-state tool grammar in 3 MB instead of 824 MB, and a schema-constrained stream that decodes faster than an unconstrained one (180 vs 138 tok/s on a 16 GB laptop). The README rounds this to “about 3 MB instead of up to 1.5 GB” across formats.</li></ul><p>For agents, that shows up as tool calls that always parse. The README says an agent can only call tools it offered, arguments arrive in the schema’s types, and a reply cut off by its length limit still closes its call. The paper’s agent benchmark has 16 tool-calling agents (Qwen3.5–2B) finishing 2.55 times sooner than mlx-lm with llguidance. The README lists it as 75 s vs 29 s.</p><p><strong>Compares itself against:</strong> mlx-lm and, in a separate chart, Ollama, llama.cpp and Magnitude. At eight concurrent requests with 1,000-token prompts on an M2 Pro, it reports a first token in 1.6 s against Ollama’s 41.8 s, measured with SemiAnalysis’s InferenceX client.</p><p><strong>Says it loses at:</strong> a single request with nothing to reuse (about mlx-lm’s speed), prompt-bound work, formats other than 4-bit affine quantization in groups of 64 (mxfp4 ran at mlx-lm’s speed), and the M5. MLX already has kernels for the M5’s neural accelerators, so the gain there is about 1.5x, not 3x.</p><h3>Bet 3 — Rapid-MLX 1.0: compatibility and single-stream speed first</h3><p>Rapid-MLX shipped 1.0.0 on October 29 and 1.0.2 on November 5 (dates from its desktop app’s changelog). It’s the most mature of the three and the broadest. Besides text, the 1.0 releases added persistent terminal agent chats and video generation with synchronized audio. On the agent side, its distinguishing bet is wire compatibility. It serves /v1/chat/completions, OpenAI&#39;s /v1/responses (what Codex CLI uses) and Anthropic&#39;s /v1/messages (what Claude Code uses), ships 27 tool-call parser modules, and says 12 agent CLIs and 3 Python frameworks are tested against real weights every release. Five of them (Claude Code, Codex CLI, Hermes, Aider and DeepSeek Harness) are Tier-1: per the README, each runs the same real bug-fix task against a local 35B model, and a failure blocks the release.</p><p>Under the hood it also does continuous batching and a radix prefix cache with snapshots for DeltaNet-style hybrid models, saved to disk on shutdown. That’s the same caching problem inferd solves, approached from inside MLX.</p><p><strong>Headline numbers (vendor-run):</strong> its README’s comparison table, run on what it labels a Mac Studio M5 Max with 36 GB using Rapid-MLX 0.16.1 (before 1.0), shows Qwen3.8–27B single-stream decode at 37.9 tok/s versus 25.3 for oMLX and 26.0 for LM Studio. With four streams it’s 82.8 vs 75.3 vs 69.7. The footnote says it’s a single run per engine.</p><p><strong>Compares itself against:</strong> mlx-lm (“up to 4x faster, 1.5x on a typical task”), oMLX and LM Studio.</p><p><strong>Read before installing:</strong> anonymous, metadata-only telemetry is on by default (it can be turned off with rapid-mlx telemetry off, RAPID_MLX_TELEMETRY=0 or DO_NOT_TRACK=1). The 1.0 release notes also say the new terminal agent harness&#39;s network policy is heuristic and its terminal commands aren&#39;t sandboxed.</p><h3>Side by side</h3><h3>inferd</h3><ul><li><strong>Version (date):</strong> 0.4.1 (Nov 5)</li><li><strong>Engine:</strong> Native Metal, own kernels (some vendored from MLX)</li><li><strong>Built for:</strong> Big Macs, dozens of agents on hybrid models</li><li><strong>Default model:</strong> Qwen3.8–27B + DFlash2 drafter</li><li><strong>Oldest supported chip:</strong> M3/M4 lightly tested, M1/M2 untested</li><li><strong>Cache:</strong> Paged 8-bit KV, recurrent-state snapshots, SSD tier, explicit context API</li><li><strong>Tool calls:</strong> Strict JSON Schema via constrained decoding (documented subset)</li><li><strong>Benchmarks against:</strong> Splash, TensorFold</li><li><strong>Default port:</strong> 8300</li><li><strong>Telemetry:</strong> — (not mentioned in README)</li></ul><h3>Locals</h3><ul><li><strong>Version (date):</strong> 0.2.2 (Nov 4)</li><li><strong>Engine:</strong> MLX + own small-batch kernels</li><li><strong>Built for:</strong> 16 GB laptops, a handful of agents with tool calls</li><li><strong>Default model:</strong> Qwen3.5–4B</li><li><strong>Oldest supported chip:</strong> M1</li><li><strong>Cache:</strong> Conversations kept between turns; agent system prompts kept on disk across restarts</li><li><strong>Tool calls:</strong> Per-model tool-call grammar on the GPU</li><li><strong>Benchmarks against:</strong> mlx-lm, Ollama, llama.cpp, Magnitude</li><li><strong>Default port:</strong> 8321</li><li><strong>Telemetry:</strong> — (README says nothing leaves your computer)</li></ul><h3>Rapid-MLX</h3><ul><li><strong>Version (date):</strong> 1.0.2 (Nov 5)</li><li><strong>Engine:</strong> MLX</li><li><strong>Built for:</strong> Compatibility with existing agent CLIs</li><li><strong>Default model:</strong> Varies (pinned model aliases)</li><li><strong>Oldest supported chip:</strong> M1</li><li><strong>Cache:</strong> Radix prefix cache with hybrid-model snapshots, saved to disk on shutdown</li><li><strong>Tool calls:</strong> 27 parser modules</li><li><strong>Benchmarks against:</strong> mlx-lm, oMLX, LM Studio</li><li><strong>Default port:</strong> 8000 (first free in 8000–8009)</li><li><strong>Telemetry:</strong> On by default, opt-out</li></ul><p>Notice the “benchmarks against” row. <strong>None of the three compares itself to either of the others.</strong> Each picked a different baseline, on different Macs, with different models. inferd’s numbers are mostly on M5-class machines and 27B-plus models, Locals’ on an M2 Pro with a 4B model, and Rapid-MLX’s headline table on a 36 GB M5 Max. Each project’s numbers look internally careful, and all three document how they measured. But you can’t read a ranking off them.</p><h3>Run the comparison yourself</h3><p>All three speak the OpenAI chat API, so one script covers them. This one fires N streaming requests at once and reports the three numbers that matter for agents: total throughput, how long the slowest agent waited for its first token, and what each agent experienced individually. I tested it against a mock server that saturates past four concurrent streams, and it reproduced the plateau.</p><pre>&quot;&quot;&quot;Sweep concurrency against any OpenAI-compatible local server.<br>pip install httpx<br>python agent_bench.py --base http://localhost:8321/v1 --model &lt;name&gt; --levels 1,2,4,8,16<br>&quot;&quot;&quot;<br>import argparse, asyncio, json, statistics, time<br>import httpx<br><br>PROMPT = (&quot;You are agent {i}. Write a numbered list of 12 concrete checks for reviewing a &quot;<br>          &quot;pull request that touches a database migration. One line each.&quot;)<br><br>async def one_request(client, base, model, i, max_tokens):<br>    body = {&quot;model&quot;: model, &quot;stream&quot;: True, &quot;max_tokens&quot;: max_tokens, &quot;temperature&quot;: 0,<br>            &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: PROMPT.format(i=i)}],<br>            &quot;stream_options&quot;: {&quot;include_usage&quot;: True}}<br>    t0, first, chunks, usage_tokens = time.perf_counter(), None, 0, None<br>    async with client.stream(&quot;POST&quot;, f&quot;{base}/chat/completions&quot;, json=body) as r:<br>        r.raise_for_status()<br>        async for line in r.aiter_lines():<br>            if not line.startswith(&quot;data:&quot;):<br>                continue<br>            data = line[5:].strip()<br>            if data == &quot;[DONE]&quot;:<br>                break<br>            ev = json.loads(data)<br>            if ev.get(&quot;usage&quot;):<br>                usage_tokens = ev[&quot;usage&quot;].get(&quot;completion_tokens&quot;)<br>            for ch in ev.get(&quot;choices&quot;, []):<br>                delta = ch.get(&quot;delta&quot;, {})<br>                if delta.get(&quot;content&quot;) or delta.get(&quot;reasoning_content&quot;):<br>                    chunks += 1<br>                    first = first or time.perf_counter()<br>    end = time.perf_counter()<br>    tokens = usage_tokens or chunks          # fall back to counting streamed chunks<br>    decode = tokens / (end - first) if first and end &gt; first else 0.0<br>    return {&quot;ttft&quot;: (first or end) - t0, &quot;tokens&quot;: tokens, &quot;decode_tps&quot;: decode}<br><br>async def level(base, model, n, max_tokens):<br>    async with httpx.AsyncClient(timeout=None) as client:<br>        t0 = time.perf_counter()<br>        rs = await asyncio.gather(*(one_request(client, base, model, i, max_tokens) for i in range(n)))<br>        wall = time.perf_counter() - t0<br>    return (n, sum(r[&quot;tokens&quot;] for r in rs) / wall, max(r[&quot;ttft&quot;] for r in rs),<br>            statistics.median(r[&quot;decode_tps&quot;] for r in rs))<br><br>async def main():<br>    ap = argparse.ArgumentParser()<br>    ap.add_argument(&quot;--base&quot;, default=&quot;http://localhost:8300/v1&quot;)<br>    ap.add_argument(&quot;--model&quot;, required=True)<br>    ap.add_argument(&quot;--levels&quot;, default=&quot;1,2,4,8,16&quot;)<br>    ap.add_argument(&quot;--max-tokens&quot;, type=int, default=256)<br>    a = ap.parse_args()<br>    print(f&quot;{&#39;agents&#39;:&gt;6} {&#39;aggregate tok/s&#39;:&gt;16} {&#39;slowest TTFT s&#39;:&gt;15} {&#39;median per-agent tok/s&#39;:&gt;23}&quot;)<br>    for n in map(int, a.levels.split(&quot;,&quot;)):<br>        n, agg, ttft, med = await level(a.base, a.model, n, a.max_tokens)<br>        print(f&quot;{n:&gt;6} {agg:&gt;16.1f} {ttft:&gt;15.2f} {med:&gt;23.1f}&quot;)<br><br>asyncio.run(main())</pre><p>Against the mock (4-stream saturation, 64-token replies), the output shows exactly what to look for on real servers. Aggregate throughput stops climbing past 4, and per-agent speed halves with each doubling after that:</p><pre>agents  aggregate tok/s  slowest TTFT s  median per-agent tok/s<br>     1             87.3            0.09                    99.0<br>     2            179.3            0.06                    98.6<br>     4            357.1            0.07                    98.4<br>     8            371.2            0.08                    49.3<br>    16            381.7            0.11                    24.8</pre><p>For a fair comparison: use the same model and quantization on every server (each project’s docs list Qwen3.5–9B among its supported models, which makes it a reasonable common choice), run one server at a time with the others stopped, run each level more than once, and turn speculative decoding on or off consistently. If a server doesn’t send usage in its stream, the script counts content chunks instead, which under-counts servers that pack several tokens into one chunk.</p><p>This short-prompt test doesn’t exercise what inferd and Locals emphasize most, which is long shared contexts and agents resuming after a gap. To test that, repeat the run with a long shared system prompt (8–16k tokens) and send a second turn per agent. That’s where prefix caching and SSD tiers either pay off or don’t.</p><h3>Which one to try</h3><ul><li><strong>A 16 GB MacBook, one coding agent with a few subagents, small models:</strong> Locals is aimed squarely at you. Its README documents M1 or newer with macOS 14 or newer, and its own measurements are on exactly that class of machine.</li><li><strong>A Mac Studio or 64 GB+ mini running a 27B-class hybrid model with many agents:</strong> inferd’s numbers are on exactly that hardware. Check that your chip and macOS version are on its tested list first.</li><li><strong>You mostly want Claude Code, Codex CLI or Aider to work locally without fiddling:</strong> Rapid-MLX’s release-blocking compatibility tests are its real advantage. Turn telemetry off if you don’t want it.</li></ul><h3>The takeaway</h3><p>For two years, Mac inference servers competed on one number: single-stream decode speed. All three of these projects say, in their own way, that the number is now secondary. The new contest is the middle band of batch sizes where agent fleets live, tool calls that never fail to parse, and caches that understand hybrid models. The good news is that the work is open and the harnesses are published. The missing piece is someone running all three on the same Mac with the same model. Until that happens, treat every headline here as a claim to test, not a ranking.</p><p><strong>Sources:</strong> — Locals README and CHANGELOG (v0.2.2, 4 November 2026), Generated Computers — github.com/Generated-Computers/Locals — Arip Asadulaev, <em>Breaking the Space Barrier and its Application to Language Model Inference</em>, arXiv:2610.09139 (October 2026), PDF in the Locals repository — inferd README, OVERVIEW.md and docs/ARCHITECTURE.md (v0.4.1, 5 November 2026), Incept5 — github.com/Incept5-AI/inferd — Rapid-MLX README, release notes v1.0.0 and v1.0.2, and desktop app CHANGELOG — github.com/raullenchai/Rapid-MLX — AlphaSignal, “Generated Computers’ Locals Triples AI Agent Throughput on Apple Silicon” (November 2026) — used for discovery; all figures above are from the primary sources</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=70e53f2f6c25" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[64 Agents, One Mac Studio: How inferd Trades Single-Stream Speed for Fleet Throughput]]></title>
            <link>https://medium.com/@nuthalapativarun/64-agents-one-mac-studio-how-inferd-trades-single-stream-speed-for-fleet-throughput-31ca8fc27813?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/31ca8fc27813</guid>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[mac-studio]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Thu, 12 Nov 2026 02:47:15 GMT</pubDate>
            <atom:updated>2026-11-12T02:47:15.703Z</atom:updated>
            <content:encoded><![CDATA[<h4>A new Apache 2.0 engine for Apple Silicon is built for the case where agents arrive in crowds, come back every minute, and share most of their context. The interesting part is how it caches a model that isn’t pure attention.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oK6-DdubEBMPn_9U5SzZoQ.jpeg" /></figure><p>On a Mac Studio with an M5 Ultra and 256 GB, inferd reports <strong>849 tokens per second of combined output from 64 agents running Qwen3.8–27B at once, each on its own 16k-token context</strong>, with the slowest agent’s first token arriving in 3.4–3.7 seconds. The same README says, without hedging, that other Mac engines are faster if you only run one stream.</p><p>Both claims are true at once, and that is the point of the project. inferd is a native inference engine written in C++, Objective-C++ and Metal by Incept5, released as v0.4.1 on November 5, 2026 under Apache 2.0. The authors say it was written from scratch over October and November, with no MLX runtime in the token path (it does vendor MLX’s quantized matmul, GEMM and flash-attention kernels under MIT). It is built around one observation: an agent workload doesn’t look like a chat.</p><p>A chat is one person waiting on one reply. An agent fleet is a planner, a tester, a reviewer and ten readers all hitting the same model, each with a long prompt that grows by a few hundred tokens per turn, each returning a minute later. The project’s own overview lists four things that follow from that: agents arrive in crowds, agents come back, agents share context, and agents need different budgets. Every major feature in inferd maps to one of those.</p><p>This piece walks through how it does that, with one concept in focus: <strong>why prefix caching gets hard on hybrid models like Qwen3.8, and what inferd does about it.</strong></p><h3>The model inferd is built around</h3><p>inferd’s primary model is Qwen3.8–27B at 4-bit, paired with the DFlash2 block-diffusion drafter for speculative decoding. Qwen3.8–27B is a hybrid. According to inferd’s architecture doc, it has 64 layers, and only every fourth one is full attention (4 KV heads × 256 dims, 24 query heads). The other 48 are Gated DeltaNet (GDN) layers, a linear-attention design that carries a fixed-size recurrent state instead of a growing KV cache.</p><p>That split has two consequences that pull in opposite directions.</p><p><strong>Concurrency gets cheap.</strong> Because only 16 of 64 layers keep a KV cache, the docs put KV at 64 KiB per token in bf16. A pure-attention model with the same shape in all 64 layers would need four times that. Each sequence does pay a fixed recurrent-state cost (3 MiB per GDN layer, 144 MiB per sequence), but that doesn’t grow with context length.</p><p><strong>Caching gets hard.</strong> This is the subtle part. In a normal transformer, the KV cache for a prefix is just a stack of per-token entries. If two requests share the first 10,000 tokens, they can share those 10,000 tokens’ KV pages, and either one can stop reading at any position. A recurrent state doesn’t work that way. The GDN state at token 10,000 is a single summary of everything before it. You can’t slice it at token 6,000, and you can’t rebuild it at token 6,000 without re-reading the first 6,000 tokens. inferd’s architecture doc names this as the core difficulty: cheap KV memory makes many agents easy to hold, but every cache trick (sharing a prefix, branching a conversation, batching sequences together) now needs special handling for each sequence’s recurrent state. The doc says that’s exactly where the existing Mac engines fall down.</p><p>So inferd caches two different kinds of things in two different ways.</p><h3>Pages for attention, snapshots for recurrence</h3><p><strong>KV is paged and shared by reference.</strong> Attention KV lives in 32-token blocks that are refcounted, so one physical block can belong to several running agents and to the prefix cache at once. A prefix trie indexes full blocks by their tokens. When a follow-up turn, a fork, a retry or a second agent reading the same document comes in, it attends to the same physical pages as the first. The KV cache is 8-bit by default (int8 values plus a float scale per position and KV head), which the docs say roughly doubles capacity: a 140 GiB pool holds about 4.5 million tokens at 8-bit versus 2.3 million at bf16.</p><p><strong>Recurrent state is snapshotted at chosen points.</strong> Since the GDN state can’t be sliced, the scheduler copies it into a spare “snapshot slot” at specific positions as prompts are prefilled. A request then resumes from the deepest cached node in the trie that carries a snapshot. Picking the points is where most of the engineering is:</p><ul><li><strong>Near the end of each prompt</strong>, but deliberately 16 tokens early, rounded down to a block boundary. Chat templates re-render the tail of the previous turn slightly differently on the next turn, so the follow-up diverges a few tokens before the old prompt ended. Snapshotting a little early keeps the snapshot inside the part that actually matches.</li><li><strong>At the end of the system prompt</strong>, which the front end now sends as a hint. Before this change (dated November 1 in the docs), agents that shared a system prompt but asked different questions shared zero cached tokens, because the only snapshot sat past the point where they diverged.</li><li><strong>At branch points</strong>, when a new prompt’s KV match runs at least 256 tokens past the state it restored from.</li></ul><p><strong>Speculative decoding has to respect the snapshot rule too.</strong> inferd runs speculative decoding for every agent, with the drafter proposing a block of 4, 5 or 8 tokens that the target model verifies in one pass. With attention, rejected draft tokens are easy: you just don’t commit their KV. With a recurrent layer, running the verify pass would advance the state over tokens that may get rejected. So during verification, inferd doesn’t touch the GDN state. It records the layer inputs to per-layer “tapes”, and once it knows how many drafts were accepted, it replays only the committed rows into the state. A small tuner then picks the block size per regime (number of decoding agents × context bucket) from measured acceptance and step time.</p><h3>What that costs in memory</h3><p>Here’s the budget, using only the constants in inferd’s architecture doc. As a sanity check, the script reproduces the doc’s own figure of about 4.5M tokens in a 140 GiB 8-bit pool.</p><pre>KIB, MIB, GIB = 1024, 1024**2, 1024**3<br>ATTN_LAYERS = 16                      # 64 layers, every 4th is full attention<br>KV_HEADS, HEAD_DIM = 4, 256<br>STATE_PER_SEQ = 144 * MIB             # 3 MiB x 48 GDN layers (docs/ARCHITECTURE.md)<br># sanity check against the docs&#39; 64 KiB/token in bf16 (K and V, 2 bytes each)<br>assert 2 * KV_HEADS * HEAD_DIM * 2 * ATTN_LAYERS == 64 * KIB<br><br>def kv_int8_per_token():<br>    values = 2 * KV_HEADS * HEAD_DIM * 1 * ATTN_LAYERS    # int8 K and V<br>    scales = 2 * KV_HEADS * 4 * ATTN_LAYERS               # fp32 scale per (position, kv head)<br>    return values + scales<br><br>def budget(agents, ctx, kv_bytes):<br>    kv, state = agents * ctx * kv_bytes, agents * STATE_PER_SEQ<br>    return kv / GIB, state / GIB, (kv + state) / GIB<br><br>kv8 = kv_int8_per_token()<br>print(f&quot;8-bit KV: {kv8 / KIB:.1f} KiB/token; 140 GiB pool holds {140 * GIB / kv8 / 1e6:.2f}M tokens&quot;)<br>for agents, ctx in [(16, 16384), (64, 16384), (32, 65536), (128, 16384)]:<br>    kv, st, tot = budget(agents, ctx, kv8)<br>    print(f&quot;{agents:&gt;4} agents x {ctx:&gt;6}: KV {kv:5.1f} GiB + state {st:4.1f} GiB = {tot:5.1f} GiB&quot;)</pre><p>Output:</p><pre>8-bit KV: 32.5 KiB/token; 140 GiB pool holds 4.52M tokens<br>  16 agents x  16384: KV   8.1 GiB + state  2.2 GiB =  10.4 GiB<br>  64 agents x  16384: KV  32.5 GiB + state  9.0 GiB =  41.5 GiB<br>  32 agents x  65536: KV  65.0 GiB + state  4.5 GiB =  69.5 GiB<br> 128 agents x  16384: KV  65.0 GiB + state 18.0 GiB =  83.0 GiB</pre><p>That’s on top of the weights (the 4-bit model plus drafter download is about 17 GB, per the README). It explains the hardware story in one table. Sixteen agents at 16k fit comfortably on a 64 GB Mac mini. Sixty-four at 16k, or 32 at 64k, is Mac Studio territory. On small machines, the KV pool, not compute, is what runs out first. That’s why the next feature exists.</p><h3>When memory runs out: preemption and an SSD tier</h3><p>Three mechanisms keep a crowded server from falling over.</p><p><strong>Admission by memory, not by count.</strong> The scheduler admits a waiting request only if the KV blocks it will need (prompt plus 512 generated tokens plus a draft block) fit next to what the running agents will still take. The architecture doc says this replaced admission by sequence count after four 33k-token agents filled the pool on a 32 GB Mac mini M6 and deadlocked at 0.02 tok/s.</p><p><strong>Preemption back to the cache.</strong> If the pool runs dry mid-step, the newest unplanned agent gives back its blocks and goes to the front of the queue. Its prompt snapshot stays cached, so when it’s readmitted it recomputes only its tail.</p><p><strong>An SSD tier.</strong> Evicted snapshots, and the KV blocks they need, are written to a content-addressed directory on disk (blocks are named by a hash of every token up to the block’s end, so chains share them). On the 27B with 8-bit KV, the docs put one block at about 1.06 MiB and one recurrent-state snapshot at about 190 MiB. A follow-up turn restored from SSD had a time-to-first-token of 0.24–0.33 s against 7.2 s to re-prefill. Below 256 GB of memory, run.sh turns the tier on automatically (up to 64–96 GiB), and it deliberately survives ./run.sh stop, so agents&#39; contexts come back after a restart.</p><p><strong>Fan-out</strong> handles the case where many agents start on the same long document at once: only the first one prefills it, and the others wait for its snapshot. The docs report 8 agents on one 32k document going from 188.8 s to 24.6 s.</p><h3>The numbers, and where it loses</h3><p>All figures below are inferd’s own, as reported in its README and overview at release 0.4.1, measured by Incept5 with the same weights, drafter and harness for every engine compared.</p><ul><li><strong>Mac Studio M5 Ultra, 256 GB</strong>, 27B, 64 agents on 16k contexts: <strong>849</strong> total tok/s. No comparison given.</li><li><strong>Mac Studio M5 Ultra</strong>, 27B, 32 agents on 16k: <strong>722</strong> total tok/s. Splash 1.5 levels off near 369.</li><li><strong>Mac Studio M5 Ultra</strong>, 32 agents on 64k: the last agent’s first token arrives in <strong>3.3–3.4 s</strong>. Splash 1.5 takes 48.9 s.</li><li><strong>Mac Studio M5 Ultra</strong>, 8 agents start at once (14k prompts, 6,400 tokens out): all done in <strong>125.6 s</strong>. TensorFold 1.2.2 takes 177.6 s.</li><li><strong>Mac Studio M5 Ultra</strong>, Qwen3.8-Flash-Next, 64 agents on 16k: <strong>1,331–1,346</strong> total tok/s. No comparison given.</li><li><strong>Mac mini M5 Pro, 64 GB</strong>, 27B agent fleet, completed turns/min (16k contexts): <strong>18.2–19.0</strong>. Splash 1.5 manages 11.4–12.0.</li><li><strong>Mac mini M6, 32 GB</strong>, 27B agent fleet, completed turns/min (8k contexts, back to back): <strong>13.3–13.5</strong>. Splash 1.5 manages 8.8–8.9.</li></ul><p>The same docs are just as specific about where inferd is worse:</p><ul><li><strong>One fast stream.</strong> The overview says TensorFold’s fast mode is about 19% faster on the 27B (275 vs 231 tok/s), and that oMLX and TensorFold are faster on Flash-Next.</li><li><strong>A handful of agents starting together on Flash-Next.</strong> TensorFold finishes sooner up to about five agents.</li><li><strong>Small-batch work on the minis.</strong> Splash is faster for one agent and for 4–8 agents taking turns on the M5 Pro and M6. One example from the README: the 35B MoE at 16k with 4 agents, 315 vs 237 tok/s on the M5 Pro.</li><li><strong>Models without a drafter.</strong> mlx-lm and mlx-vlm are faster for a single agent.</li></ul><p>That’s the trade in plain terms. Every design choice above (paging, snapshots, admission, preemption, per-agent speculation) costs a little on the single-stream path and pays back once there’s a queue. If your workload is one person and one chat window, inferd isn’t for you. If it’s a coding agent fanning out subagents, or a research job spawning readers over the same documents, the crossover comes early: in the TensorFold comparison, the README puts it at three agents on the 27B.</p><p>On quality, the project reports logits matching the mlx_lm reference (top-1 agreement, cosine ≥ 0.9997), and 100 of 100 strict-JSON replies valid on the first attempt.</p><h3>Using it: the agent-contexts API</h3><p>Most engines share prefixes implicitly. inferd also lets the agent framework say what to keep, through an extension to the OpenAI API (docs/CONTEXTS.md):</p><pre>POST /v1/contexts<br>{&quot;name&quot;: &quot;repo-docs&quot;,<br> &quot;system&quot;: &quot;You review pull requests for this repository.&quot;,<br> &quot;documents&quot;: [&quot;&lt;file 1&gt;&quot;, &quot;&lt;file 2&gt;&quot;],<br> &quot;persist&quot;: &quot;ssd&quot;}</pre><p>That computes the context once and <strong>pins</strong> it, so it isn’t evicted and doesn’t count against the admission budget. Agents then fork from it with one extra field on an ordinary chat request:</p><pre>{&quot;model&quot;: &quot;qwen3.8-27b&quot;,<br> &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Review PR #4411 for locking bugs.&quot;}],<br> &quot;inferd&quot;: {&quot;context&quot;: &quot;repo-docs&quot;, &quot;session&quot;: &quot;reviewer-7&quot;,<br>            &quot;effort&quot;: &quot;low&quot;, &quot;max_context&quot;: 32768, &quot;priority&quot;: 2}}</pre><ul><li>session marks the agent&#39;s latest turn so it&#39;s evicted last (and spilled to SSD before being dropped). The README says such an agent resumes in 0.2–0.7 s instead of a 15–18 s re-read.</li><li>effort, max_context and priority are per agent, so a cheap search helper and an expensive planner can share one server. Priority decides who gets preempted first.</li><li>&quot;cache&quot;: &quot;drop&quot; marks a one-off request that reads the cache but leaves nothing behind.</li><li>GET /v1/cache shows what&#39;s cached, pinned, parked and free.</li></ul><p>There’s one gotcha the docs call out. Qwen3.x chat templates write the reasoning effort into the system prompt, so a context saved at one effort is a different prefix from the same context at another. Save it at the effort its agents will actually use.</p><p>Getting started is four commands from the README: download the release tarball for your macOS version, ./install.sh --model 27b, ./run.sh start --model 27b, and point any OpenAI client at http://localhost:8300/v1. The 27B with its drafter runs from 32 GB.</p><h3>Read the fine print</h3><ul><li><strong>It’s days old.</strong> The overview says so directly: a small team, built with AI assistance, tested on the machines it names, and meant for a trusted local user, not a shared network service.</li><li><strong>Hardware coverage is narrow.</strong> M5-class and newer GPUs (with neural accelerators) are the main target. The M3/M4 family runs on a separate, lightly tested kernel path, and M1/M2 are untested. If you’re on an older Mac, check docs/MACHINES.md before expecting the numbers above.</li><li><strong>Match the build to the OS.</strong> The README warns that a build made for macOS 26 computes wrong logits on macOS 27 at prefill chunks of 256+ rows. Download the file for the macOS you actually run.</li><li><strong>The numbers are the maintainer’s.</strong> They’re unusually transparent (raw results are in results/, and the harness is in bench/), but they haven&#39;t been independently reproduced. The repo ships examples/agents-at-once, which runs 64 or more agents in about five minutes and checks every answer, so you can test it yourself.</li></ul><h3>The takeaway</h3><p>For two years, “fast local inference on a Mac” meant one number: single-stream tokens per second. inferd is a bet that the number that matters next is how many agents a Mac can keep moving, and how quickly the twentieth gets its first token. The hard part isn’t batching. vLLM solved paged KV, continuous batching and prefix caching for data-center attention models, and inferd says openly that it reimplements that design. The hard part is that the open models worth running locally are now hybrids, and a recurrent state can’t be paged. Snapshots, branch points and verify tapes are what it takes to make a Mac Studio behave like a small serving cluster for the models people are actually using. Expect the other Mac engines to grow the same machinery.</p><p><strong>Sources:</strong> — Incept5, <em>inferd</em> GitHub repository: README.md, OVERVIEW.md, docs/ARCHITECTURE.md, docs/CONTEXTS.md (release v0.4.1, 5 November 2026; repository read 11 November 2026) — github.com/Incept5-AI/inferd — Jian Chen, Yesheng Liang, Zhijian Liu (z-lab), <em>DFlash: Block Diffusion for Flash Speculative Decoding</em>, arXiv:2602.06036 (background on the block-diffusion drafter family DFlash2 belongs to)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=31ca8fc27813" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Airbnb Raced a Fine-Tuned 2B Model Against GPT-6.1]]></title>
            <link>https://medium.com/@nuthalapativarun/airbnb-raced-a-fine-tuned-2b-model-against-gpt-6-1-f6304415d9c9?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/f6304415d9c9</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[gemma]]></category>
            <category><![CDATA[fine-tuning]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sun, 08 Nov 2026 07:25:36 GMT</pubDate>
            <atom:updated>2026-11-08T07:25:36.352Z</atom:updated>
            <content:encoded><![CDATA[<h3>Airbnb Raced a Fine-Tuned 2B Model Against GPT-6.1 Sol, Claude Sonnet 5.5, and Jev. The 2B Model Was the Only One That Hit Both Targets.</h3><h4>A production benchmark on one real classifier, what “max F1” hides, and the request rate at which self-hosting stops paying off</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VvPRBDpn5lBEHPtvVliLuA.jpeg" /></figure><p>Airbnb fine-tuned a 2-billion-effective-parameter Gemma model for a production content classifier. Its max F1 went from 0.563 to 0.902, on par with the best frontier models Airbnb tested. Median latency was 35 milliseconds. GPT-6.1 Sol and Claude Sonnet 5.5 took about 900 milliseconds at the median and several seconds at p99, against a latency budget of 200.</p><p>That result alone would be a familiar “small models are underrated” story. The post by Airbnb engineers Clay Gibson and Adel Elmalaha is more useful than that because it puts real numbers on the boundaries. It shows where prompting stops helping, why a probability beats a yes/no answer, and the request rate below which the frontier API is the cheaper option. It’s also the first production comparison I’ve seen that puts TypeSafe’s Jev decision model next to the generative frontier models.</p><p>The post doesn’t give a publication date. Its images are dated November 2026, and its pricing uses October 2026 list rates.</p><h3>The task: a gate, not a chat</h3><p>The classifier screens content for violations of Airbnb’s pricing and payment transparency policy. It runs before content is published, so it’s in the critical path. It’s called thousands of times per minute and has 200 ms to return a score. A false positive blocks legitimate content, so precision matters more than in a typical moderation queue.</p><p>The setup:</p><ul><li><strong>Evaluation set:</strong> 10,000 held-out examples, weighted to correct for how production traffic was sampled. A subset of models was re-checked on 184,000 examples, and their max F1 stayed within 0.01 of the reported numbers.</li><li><strong>Fine-tuned model:</strong> Gemma4-E2B, trained on hundreds of thousands of de-identified in-domain examples from users who hadn’t opted out of AI training. Training ran through GLOWC, Airbnb’s internal framework for turning open-weight language and vision-language models into classifiers. GLOWC also provides the shared batch-inference and serving infrastructure.</li><li><strong>Serving:</strong> every open-weight model ran on vLLM 0.28.0 on a single NVIDIA RTX Pro 6000. Models up to 12B parameters ran in bfloat16, and larger models used public FP8 or NVFP4 checkpoints.</li><li><strong>Prompt:</strong> unless noted, every model got the same concise prompt of about 400 words.</li><li><strong>Proprietary models:</strong> called through vendor APIs in low-thinking mode. Airbnb found that more thinking didn’t meaningfully improve classification.</li></ul><p>A note on the “2B.” Google’s model card lists Gemma 4 E2B at 2.3 billion effective parameters and 5.1 billion in total. The gap comes from Per-Layer Embeddings, which give each of the model’s 35 layers its own small per-token embedding table. Those tables count toward the file size but not toward the “effective” parameters Google uses to describe the model’s per-token compute. Airbnb describes it as about 2B effective parameters.</p><h3>The concept: a verdict is a point, a score is a curve</h3><p>This is the part of the post I’d most want an engineer to take away, because it isn’t really about model size.</p><p>Most generative models answer a classification prompt with a verdict: “violation” or “not a violation.” A verdict gives you exactly one precision/recall operating point. With the concise prompt, GPT-6.1 Sol landed at <strong>0.82 precision and 0.96 recall</strong>. If the business decides false positives are too costly and wants precision at 0.95, the only lever is rewriting the prompt and re-running the evaluation.</p><p>A model that outputs a probability works differently. You choose the threshold. Raise it and precision goes up while recall drops; lower it and the opposite happens. Airbnb’s fine-tuned E2B produces a score, and so does Jev, which TypeSafe designed to return a calibrated decision rather than prose. That’s why Airbnb reports <strong>max F1</strong> as its headline metric:</p><ul><li>For models with a score, they sweep the threshold and report the best F1.</li><li>For verdict-only models, they report the F1 of the single verdict.</li></ul><p>The team normally prefers PR-AUC for imbalanced data like this, but verdict-only models have no curve to integrate. They say PR-AUC and accuracy rank the models almost identically.</p><p>The arithmetic is quick to check. Sol’s single operating point works out to F1 ≈ 0.884, a bit below E2B’s 0.902. The bigger difference is that E2B can be moved along its curve to wherever the policy team wants, and Sol can’t. Here is a small script that computes Sol’s F1 and sweeps a threshold on toy data so you can see the curve:</p><pre>&quot;&quot;&quot;Part A of classifier_math.py: a verdict is one point; a score is a curve.&quot;&quot;&quot;<br>import random<br><br>def f1(precision, recall):<br>    return 2 * precision * recall / (precision + recall)<br><br>sol_f1 = f1(0.82, 0.96)<br>print(f&quot;Verdict-only model at P=0.82, R=0.96 -&gt; F1 = {sol_f1:.3f}&quot;)<br><br>random.seed(7)<br>data = [(1, random.gauss(0.72, 0.15)) for _ in range(1_000)] + \<br>       [(0, random.gauss(0.30, 0.15)) for _ in range(9_000)]<br><br>def pr_at(threshold):<br>    tp = sum(1 for y, s in data if y == 1 and s &gt;= threshold)<br>    fp = sum(1 for y, s in data if y == 0 and s &gt;= threshold)<br>    fn = sum(1 for y, s in data if y == 1 and s &lt; threshold)<br>    p = tp / (tp + fp) if tp + fp else 1.0<br>    r = tp / (tp + fn)<br>    return p, r<br><br>best = (0.0, None)<br>for t in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:<br>    p, r = pr_at(t)<br>    score = f1(p, r) if p + r else 0.0<br>    best = max(best, (score, t))<br>    print(f&quot;threshold {t:.2f}: precision {p:.3f}  recall {r:.3f}  F1 {score:.3f}&quot;)<br>print(f&quot;max F1 = {best[0]:.3f} at threshold {best[1]:.2f}&quot;)</pre><p>On the toy data (10% positives, overlapping score distributions), F1 peaks around 0.81 at threshold 0.60. At 0.70, precision rises to 0.95 and recall falls to 0.59. A scored model lets you choose that trade explicitly; a verdict-only model makes it for you.</p><h3>The results</h3><p>Airbnb plotted everything on a quality-versus-latency chart. The post only states exact values for some models, so I’m reporting only those.</p><p><strong>Fine-tuned Gemma4-E2B.</strong> Max F1 went from <strong>0.563 before fine-tuning to 0.902 after</strong>, on par with the best proprietary models. Latency was <strong>35 ms median and about 85 ms p99</strong>. The authors say no other model came close on both quality and latency at once.</p><p><strong>GPT-6.1 Sol and Claude Sonnet 5.5.</strong> Quality was at the top of the chart. Latency was about <strong>900 ms median</strong>, with multi-second p99s, well outside the 200 ms budget.</p><p><strong>GPT-6 Luna.</strong> The fastest proprietary generative model Airbnb tested, but it was still slower and less accurate than the open-weight Gemma4–31B running on one GPU.</p><p><strong>Jev.</strong> The most accurate off-the-shelf model Airbnb tested under 100 ms. Because it returns a probability, it can be thresholded like the fine-tuned model. The post doesn’t give its F1. For readers of my earlier Jev pieces, this is the first production benchmark I’ve seen where a decision model sits next to the generative frontier models. Here it beat Luna at about the same cost, but it didn’t reach the fine-tuned specialist.</p><p><strong>Off-the-shelf open-weight models.</strong> Many fit within the latency budget, but their quality plateaued well below both the best proprietary models and the fine-tuned classifier.</p><p>One footnote is worth repeating for anyone serving newer architectures. Latency didn’t track parameter count. Airbnb notes that vLLM’s current kernels are less optimized for hybrid linear-attention layers than for standard or sliding-window attention, so two models of similar size can have quite different latencies.</p><h3>The prompt-engineering ceiling</h3><p>The obvious objection is that a better prompt would close the gap. Airbnb tested it with a detailed <strong>2,700-word prompt</strong> that added worked examples and common false positives.</p><ul><li><strong>Proprietary generative models</strong> gained one to three F1 points, which brought them roughly level with the fine-tuned classifier. Their latency stayed far outside the budget.</li><li><strong>Models that already met the latency budget</strong> gained only modestly, and a large gap remained.</li></ul><p>That split is the core argument. Prompting improves the models that are already too slow and does little for the models fast enough to deploy. The authors open the post with a peanut-butter-and-jelly-sandwich analogy to make the same point: there’s a limit to how much better instructions can do. In their list of when to fine-tune, they put it plainly: hundreds of thousands of labeled examples taught distinctions that the 2,700-word prompt couldn’t.</p><h3>The cost math, including the break-even point</h3><p>Airbnb expresses every cost as a multiple of one self-hosted GPU, priced at public cloud rental rates for an RTX Pro 6000. The post doesn’t give the hourly rate. Proprietary costs come from the tokens actually used during evaluation, priced at October 2026 list rates without negotiated discounts.</p><ul><li><strong>Frontier models, concise prompt:</strong> about <strong>two orders of magnitude</strong> more than one GPU at a load of thousands of RPM. The detailed prompt added another <strong>20–40%</strong>.</li><li><strong>Lower-cost proprietary options:</strong> about <strong>5×</strong> the GPU cost, with lower accuracy than the fine-tuned model.</li><li><strong>Luna vs. Jev:</strong> similar cost on this task. With the detailed prompt, Jev’s cost <strong>quadrupled</strong>, because its API has no cached-input discount.</li><li><strong>Break-even:</strong> self-hosting becomes cheaper above roughly <strong>30 RPM</strong>. Below about 15–30 RPM, Claude Sonnet 5.5 and GPT-6.1 Sol cost less than a dedicated GPU.</li></ul><p>The comparison is generous to the APIs. Airbnb assumed a <strong>71% cache-hit rate</strong> on cached input, which it says is higher than it actually observed.</p><p>The two headline numbers are consistent with each other. A GPU costs the same whether it handles 30 or 3,000 requests per minute, until you need a second one. An API bill grows linearly with traffic. If the two break even at 30 RPM, then at R RPM the API costs R/30 times the GPU:</p><pre>&quot;&quot;&quot;Part B of classifier_math.py: what the break-even point implies.&quot;&quot;&quot;<br>G = 1.0                 # one GPU&#39;s monthly cost, as the unit<br>BREAK_EVEN_RPM = 30     # Airbnb: self-hosting wins above ~30 RPM<br>k = G / BREAK_EVEN_RPM  # API cost per unit of RPM<br><br>for rpm in [15, 30, 300, 1_000, 3_000, 6_000]:<br>    print(f&quot;{rpm:&gt;5} RPM -&gt; API costs {k * rpm:6.1f}x the GPU&quot;)<br><br>for uplift in (0.20, 0.40):  # the detailed prompt&#39;s extra cost<br>    print(f&quot;+{uplift:.0%} prompt cost -&gt; break-even falls to &quot;<br>          f&quot;{BREAK_EVEN_RPM / (1 + uplift):.1f} RPM&quot;)</pre><pre>15 RPM -&gt; API costs    0.5x the GPU<br>   30 RPM -&gt; API costs    1.0x the GPU<br>  300 RPM -&gt; API costs   10.0x the GPU<br> 1000 RPM -&gt; API costs   33.3x the GPU<br> 3000 RPM -&gt; API costs  100.0x the GPU<br> 6000 RPM -&gt; API costs  200.0x the GPU<br>+20% prompt cost -&gt; break-even falls to 25.0 RPM<br>+40% prompt cost -&gt; break-even falls to 21.4 RPM</pre><p>A 100× ratio lands at about 3,000 RPM, which matches Airbnb’s “thousands of requests per minute.” This is a simplified model that ignores engineering time and the cost of fine-tuning, but it shows why the result is so lopsided. A self-hosted GPU is a fixed cost, so once one GPU can carry the traffic, every extra request is essentially free. With an API, every extra request costs money. Better prompts that add tokens make this worse by lowering the break-even point.</p><h3>When to fine-tune, per Airbnb</h3><p>The authors give four conditions that should all hold:</p><ol><li><strong>High volume.</strong> Above roughly 30 RPM for this task, one GPU is cheaper than frontier APIs.</li><li><strong>Tight latency.</strong> E2B returned scores at about 85 ms p99, while frontier APIs ran about 900 ms at the median. Faster API tiers exist, but at a premium the post doesn’t quantify.</li><li><strong>A need for a threshold.</strong> Scores let you move the precision/recall trade-off without rewriting prompts.</li><li><strong>Labeled data.</strong> Hundreds of thousands of in-domain examples taught distinctions that a 2,700-word prompt couldn’t.</li></ol><p>They’re also explicit about the opposite case. For low-volume or frequently changing tasks, a frontier model with a good prompt can be the better choice. The results come from a single classifier, though the authors say they saw similar trade-offs with other models they fine-tuned.</p><h3>The takeaway</h3><p>This is the third company post I’ve covered recently that reaches a similar conclusion, though by different routes. Netflix trained a recommender with a scoring head that never generates text. Pinterest distilled a frontier teacher into a 4B student. Airbnb fine-tuned a 2B-effective model and found that the latency budget, more than the leaderboard, determined which model it could use.</p><p>The general lesson is that what you need from the model’s output determines which model fits. A gate that has to answer in 200 ms with an adjustable threshold, thousands of times a minute, needs a score, low latency, and a fixed cost. Frontier models are the right tool for writing the labels, handling low-volume tasks, and covering cases nobody has labeled yet. For high-volume classification in production, Airbnb’s numbers favor a small fine-tuned model running on one GPU.</p><h3>Appendix: the full script</h3><p>The two snippets above are excerpts. This is the complete classifier_math.py in one block, so you can copy it into a file and run it with python3 classifier_math.py. It&#39;s plain Python with no dependencies.</p><pre>&quot;&quot;&quot;Two pieces of arithmetic behind Airbnb&#39;s classifier benchmark.<br><br>Part A: why a score you can threshold beats a fixed verdict.<br>Part B: what Airbnb&#39;s break-even and cost-ratio figures imply about each other.<br><br>Pure Python, no dependencies. Run: python3 classifier_math.py<br>&quot;&quot;&quot;<br><br>import random<br><br>def f1(precision, recall):<br>    return 2 * precision * recall / (precision + recall)<br><br># ---------------------------------------------------------------------------<br># Part A: a verdict is one point; a score is a whole curve<br># ---------------------------------------------------------------------------<br># Airbnb reports GPT-6.1 Sol (concise prompt) at precision 0.82, recall 0.96:<br># a single operating point. Its F1 is fixed unless you rewrite the prompt.<br>sol_f1 = f1(0.82, 0.96)<br>print(f&quot;Part A: verdict-only model at P=0.82, R=0.96 -&gt; F1 = {sol_f1:.3f}&quot;)<br>print(&quot;        (Airbnb&#39;s fine-tuned Gemma4-E2B reported max F1 = 0.902)\n&quot;)<br># A toy scored classifier: 10% positives, scores drawn from overlapping<br># distributions. Sweep the threshold and watch precision/recall trade off.<br>random.seed(7)<br>data = [(1, random.gauss(0.72, 0.15)) for _ in range(1_000)] + \<br>       [(0, random.gauss(0.30, 0.15)) for _ in range(9_000)]<br><br>def pr_at(threshold):<br>    tp = sum(1 for y, s in data if y == 1 and s &gt;= threshold)<br>    fp = sum(1 for y, s in data if y == 0 and s &gt;= threshold)<br>    fn = sum(1 for y, s in data if y == 1 and s &lt; threshold)<br>    p = tp / (tp + fp) if tp + fp else 1.0<br>    r = tp / (tp + fn)<br>    return p, r<br><br>print(&quot;  threshold  precision  recall   F1&quot;)<br>best = (0.0, None)<br>for t in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:<br>    p, r = pr_at(t)<br>    score = f1(p, r) if p + r else 0.0<br>    best = max(best, (score, t))<br>    print(f&quot;  {t:9.2f}  {p:9.3f}  {r:6.3f}  {score:.3f}&quot;)<br>print(f&quot;  max F1 on this toy data = {best[0]:.3f} at threshold {best[1]:.2f}&quot;)<br>print(&quot;  A precision-first policy can pick a higher threshold instead;&quot;)<br>print(&quot;  a verdict-only model has no dial to turn.\n&quot;)<br><br># ---------------------------------------------------------------------------<br># Part B: break-even arithmetic<br># ---------------------------------------------------------------------------<br># Model: one self-hosted GPU costs G per month no matter the load (until you<br># need a second worker). A pay-per-token API costs k per request, so its<br># monthly bill scales linearly with requests per minute (RPM).<br># Airbnb: self-hosting wins above ~30 RPM. That pins k: k * 30 = G.<br>G = 1.0                      # express everything in &quot;GPUs&#39; worth&quot; of cost<br>BREAK_EVEN_RPM = 30<br>k = G / BREAK_EVEN_RPM<br><br>print(&quot;Part B: frontier-API cost as a multiple of one GPU, if break-even is 30 RPM&quot;)<br>for rpm in [15, 30, 300, 1_000, 3_000, 6_000]:<br>    print(f&quot;  {rpm:&gt;5} RPM -&gt; API costs {k * rpm:6.1f}x the GPU&quot;)<br>print(&quot;  ~100x (&#39;two orders of magnitude&#39;) lands at ~3,000 RPM,&quot;)<br>print(&quot;  matching Airbnb&#39;s &#39;thousands of RPM&#39; description of the workload.\n&quot;)<br><br># The detailed 2,700-word prompt added 20-40% to frontier cost.<br>for uplift in (0.20, 0.40):<br>    print(f&quot;  +{uplift:.0%} prompt cost -&gt; break-even falls to &quot;<br>          f&quot;{BREAK_EVEN_RPM / (1 + uplift):.1f} RPM&quot;)<br></pre><p><strong>Sources:</strong> — Clay Gibson and Adel Elmalaha, “Frontier quality at 1% of the cost: a production benchmark of LLM classifiers,” Airbnb Tech Blog (undated; assets and pricing from October–November 2026) — Google, Gemma 4 model card (ai.google.dev): E2B effective and total parameters, layer count, Per-Layer Embeddings — classifier_math.py: my own arithmetic on Airbnb&#39;s published figures, given in full in the appendix and run with its output shown above</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f6304415d9c9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Stripe’s Agents Found 8% More Fraud. The Hard Part Was Knowing Which Labels to Trust.]]></title>
            <link>https://medium.com/@nuthalapativarun/stripes-agents-found-8-more-fraud-the-hard-part-was-knowing-which-labels-to-trust-6cff5d9376bf?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/6cff5d9376bf</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[stripe]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sun, 08 Nov 2026 02:36:01 GMT</pubDate>
            <atom:updated>2026-11-08T02:36:01.520Z</atom:updated>
            <content:encoded><![CDATA[<h4>Inside Radar’s training scale-up, the delayed-label trap that forced a rollback, and why Stripe fine-tunes a 4B model instead of prompting one.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yKaxGr8OCVB--v1Dk3dkig.jpeg" /></figure><p>Stripe says a change to how its Radar fraud model averages training checkpoints improved recall by over 8% in a controlled comparison, which it estimates is worth tens of millions of dollars a year in additional fraud prevention for its merchants. Engineers prioritized 38 ideas, and AI agents did the rest: they implemented the changes, trained the models, debugged failed runs, and evaluated results.</p><p>That’s the headline from Stripe’s October 19 engineering post on scaling Radar. But the more instructive part of the post is a failure. Before the agents found that fix, Stripe trained a model on nearly 14 times more data, watched it beat the production model, and then had to roll it back once more fraud reports came in. The reason is a problem every team training on real-world outcomes eventually hits: labels that aren’t finished arriving yet.</p><p>A second Stripe post, from October 9, covers a different risk problem with a different tool. It fine-tunes a 4B open-weight language model to score whether a business account is fraudulent, and compares that against prompting. Read together, the two posts say a lot about where agents and language models actually help in production ML, and where humans still decide.</p><h3>Part 1: Scaling Radar’s transaction model</h3><p>Radar is Stripe’s fraud prevention product. Its transaction model, called Shield, learns representations of core signals, combines them to predict fraud, and scores payments in milliseconds. The team’s hypothesis was that Shield would do better with fuller payment histories. A card that looks unusual across the whole network might be perfectly normal for one particular business.</p><p>The problem was data volume. Shield was trained on roughly 800 million transactions, but that was only a fraction of the available history. To keep training efficient, the pipeline kept all known fraud each week and only a random subset of everything else.</p><p>For the first experiment, Stripe removed downsampling and used every eligible payment from a nine-month window. The training set went from about 800 million to 11 billion transactions. After one pass through the data the new model was nearly as good as the existing one, and by the third pass it was better on the initial evaluation.</p><p>Each pass took about 24 hours, though, so a three-epoch comparison took about three days. That was far too slow for a team that wanted to test ideas daily. Here’s how they cut it down, with the dataset held fixed at 11 billion examples:</p><ul><li><strong>Baseline</strong>: What changed: Initial implementation; Time per epoch: 24 hours</li><li><strong>Streaming</strong>: What changed: Data loaders stream from S3 into memory; Time per epoch: 12 hours</li><li><strong>Compute</strong>: What changed: torch.compile, running ResNeXt&#39;s parallel branches concurrently, better CPU-to-GPU transfers; Time per epoch: 7 hours</li><li><strong>Data movement</strong>: What changed: Peak memory cut from 1.3 TB to 500 GB, which allowed more data-loading workers and prefetching; BF16 gradient compression between GPUs; tuned communication bucket size; async CPU-to-GPU transfers; Time per epoch: 4.8 hours</li></ul><p>That’s an 80% reduction. Note the last row: cutting peak memory wasn’t the goal in itself. It freed room to run more data-loader workers per GPU, and keeping the GPUs fed turned out to be the bottleneck. The post’s lesson is that data loading, computation, and communication had to be tuned together.</p><p>The larger dataset also produced a wider embedding layer. Shield grew to about 30 million parameters, three times its previous size, and Stripe says serving latency didn’t increase in load tests or production scoring.</p><h3>The delayed-label trap</h3><p>The bigger model looked better offline and in early production experiments. Then more fraud reports arrived, its measured performance fell below the old model’s, and Stripe rolled it back.</p><p>The cause is how fraud labels arrive. When someone uses a stolen card, the cardholder might not notice for weeks. Stripe only learns the payment was fraud when the dispute comes in, long after the payment was scored. Stripe published how slowly those reports accumulate:</p><ul><li><strong>14</strong>: Share of fraud disputes reported so far: 41%</li><li><strong>30</strong>: Share of fraud disputes reported so far: 66%</li><li><strong>60</strong>: Share of fraud disputes reported so far: 86%</li></ul><p>So when you build a training set, the most recent weeks contain fraud that is currently labeled as legitimate. Nobody has reported it yet.</p><p>This is why downsampling had been quietly protecting the model. The downsampled pool, “everything not known to be fraud,” contained both genuinely legitimate payments and fraud that hadn’t been reported yet. Keeping only a random fraction of that pool also kept only a fraction of the mislabeled fraud. Removing downsampling restored all of it. Stripe says the result was about 20 times more fraud labeled as legitimate, while the count of known fraud stayed the same, since all of it was already kept.</p><p>The model learned exactly what the data taught it: that patterns of fraud that gets reported late are legitimate, especially in the most recent weeks. It got worse at catching the fraud that matters most to catch early.</p><p>This tension is easy to miss. Recent data helps a fraud model keep up with attackers who change tactics. But recent data has the least mature labels. Stripe’s own framing is that waiting gives you more reliable labels at the cost of learning new behavior later.</p><p>Here’s a small script that makes the tradeoff concrete. It uses Stripe’s three published points, assumes 0% reported on day 0, and interpolates linearly between them. The interpolation is my simplification; Stripe only published those three points.</p><pre># label_maturity.py — how much eventual fraud is still mislabeled, by payment age<br>import numpy as np<br><br># Stripe&#39;s published cumulative reporting curve, plus day 0<br>days     = np.array([0, 14, 30, 60])<br>reported = np.array([0.00, 0.41, 0.66, 0.86])<br><br>def unreported(age_days):<br>    &quot;&quot;&quot;Share of eventual fraud still labeled &#39;legitimate&#39; at a given payment age.<br>    Linear interpolation between published points; beyond day 60 is unknown.&quot;&quot;&quot;<br>    return 1 - np.interp(age_days, days, reported)<br><br>for age in (7, 14, 30, 45, 60):<br>    print(f&quot;payment {age:2d} days old: {unreported(age):.0%} of its fraud not yet labeled&quot;)<br><br># Average mislabel rate if a training set ends today and covers the last N days<br>print()<br>for window in (14, 30, 60):<br>    ages = np.arange(window)<br>    print(f&quot;training on the last {window:2d} days: &quot;<br>          f&quot;{unreported(ages).mean():.0%} of fraud in the window is unlabeled on average&quot;)</pre><p>Output:</p><pre>payment  7 days old: 80% of its fraud not yet labeled<br>payment 14 days old: 59% of its fraud not yet labeled<br>payment 30 days old: 34% of its fraud not yet labeled<br>payment 45 days old: 24% of its fraud not yet labeled<br>payment 60 days old: 14% of its fraud not yet labeled<br><br>training on the last 14 days: 81% of fraud in the window is unlabeled on average<br>training on the last 30 days: 63% of fraud in the window is unlabeled on average<br>training on the last 60 days: 44% of fraud in the window is unlabeled on average</pre><p>Even at 60 days, by Stripe’s curve, 14% of the fraud that will eventually be reported still hasn’t been. Any team training on outcome labels that arrive late (chargebacks, churn, loan defaults, content-moderation appeals) faces the same curve. The specific numbers differ, but the shape doesn’t.</p><p>One more implication: the same problem affects evaluation, not just training. Stripe notes that payments that look legitimate in a recent training or evaluation dataset can pick up fraud labels weeks later. That’s a plausible reason the bigger model looked better at first: a model that misses late-reported fraud scores well against labels that don’t contain that fraud yet. Stripe doesn’t spell out this mechanism, so take it as my reading.</p><h3>Part 2: Handing agents the experiment lifecycle</h3><p>Stripe’s answer to slow iteration was what it calls agentic machine learning engineering: a set of tools and instructions that let AI agents carry an experiment from idea to result inside Stripe’s ML infrastructure. According to the post, agents can explore data, prototype features, compute training data, train and evaluate against a baseline, and deploy the feature with an updated model.</p><p>A few design choices stand out:</p><ul><li><strong>Features come from one platform.</strong> Feature experiments run on Shepherd, Stripe’s feature platform, which the company adapted from the open-source Chronon project. The same feature definition computes historical training data and serves the online equivalent. For agents, this removes a whole class of training/serving mismatch bugs.</li><li><strong>Operational knowledge is packaged for agents.</strong> Stripe organizes it as reusable platform procedures plus model-specific docs that agents load per task. That covers datasets, entity keys, timestamps, sampling rules, reproducing features as of a past point in time, and diagnosing failed distributed training jobs. Stripe says this follows the approach from its earlier work on Minions, its one-shot coding agents.</li><li><strong>Agents run jobs, not just code.</strong> They query data, inspect workflow executions, read experiment results, write feature code, and run jobs. When a run fails, they inspect the failure, fix the configuration, and retry.</li><li><strong>Humans gate the important points.</strong> Engineers review the proposed experiment and its results at defined points, and engineers decide what ships.</li></ul><p>The post doesn’t name the models or the agent harness behind this. It also doesn’t say how many experiments per week the team now runs, so the original goal of multiple experiments a week is stated without a measured outcome.</p><h3>The fix the agents found: averaging from the start</h3><p>The case study is a real fraud attack during which Shield gave low risk scores to fraudulent payments. Agents got the attack data plus results from earlier work, including the scaling experiments. They used model explanations to find which features pushed scores down on the missed fraud. Humans prioritized 38 ideas, and agents ran them.</p><p>The winner came from stochastic weight averaging, or SWA. The technique, introduced by Izmailov and colleagues in 2018, saves copies of a model’s weights at several points during training and averages them into one model, instead of keeping only the final weights. The original paper’s title sums up the claim: averaging weights leads to wider optima and better generalization.</p><p>Several agents proposed SWA experiments. Shield already used SWA, but started averaging more than halfway through training: epochs 8 through 13 of a 14-epoch run. Training loss was falling while validation loss rose, which is the classic sign of overfitting, and that gave the agents a reason to test whether including earlier snapshots would help.</p><p>The obvious fix didn’t work. Simply stopping training early improved overall ranking but reduced recall in the narrow false-positive range that Radar actually operates in. What did work was combining two changes: averaging from epoch 0 and shortening training to 8 epochs, so the final model averages epochs 0 through 7.</p><p>That’s the configuration that raised recall by over 8% in Stripe’s controlled comparison. The dollar estimate comes from Stripe, and the post doesn’t detail how it was calculated.</p><p>What’s notable here is that the fix wasn’t exotic. It was a hyperparameter change to a technique the team already used. The value the agents added was throughput: running 38 human-prioritized ideas through implementation, training, debugging, and evaluation, so the one that mattered could surface.</p><h3>Part 3: Fine-tuning a 4B model to score business accounts</h3><p>Stripe’s October 9 post tackles a different risk problem: estimating whether a business account is fraudulent, using the same information human reviewers see. Scores are used to rank accounts and set thresholds for routing them to human review.</p><p>The setup:</p><ul><li><strong>Input.</strong> Card payments, money movement, account activity, and other review features, serialized into a single text record. The serializer turns numerical, categorical, text, and time-series features into one text representation.</li><li><strong>Model.</strong> An open-weight 4B-parameter language model. Stripe doesn’t name it.</li><li><strong>Training.</strong> Supervised fine-tuning on historical human review labels, where the target response is just the label: 1 for fraud, 0 for not fraud. The loss is negative log-likelihood on that one label token.</li><li><strong>Scoring.</strong> At the answer position, take the model’s probability for the “yes” (fraud) token and the “no” token, and renormalize over just those two. The fraud score is P(yes) / (P(yes) + P(no)).</li></ul><p>That last step is worth a moment. The model’s probability mass is spread across its whole vocabulary, so P(yes) alone isn’t a clean probability. Renormalizing over the two allowed answers turns it into one. Stripe’s worked example: P(yes) = 0.18 and P(no) = 0.72 give a fraud score of 0.20.</p><pre># two_token_score.py — Stripe&#39;s label-token fraud score from raw logits<br>import numpy as np<br><br>def fraud_score(logit_yes, logit_no):<br>    &quot;&quot;&quot;Softmax over just the two label tokens: e^yes / (e^yes + e^no).&quot;&quot;&quot;<br>    return 1 / (1 + np.exp(logit_no - logit_yes))<br><br># Stripe&#39;s worked example, expressed as probabilities over the full vocabulary<br>p_yes, p_no = 0.18, 0.72<br>print(f&quot;P(yes) alone:       {p_yes:.2f}&quot;)<br>print(f&quot;renormalized score: {p_yes / (p_yes + p_no):.2f}&quot;)<br><br># Same result from logits; any constant shift (the full-vocab normalizer) cancels out<br>logit_yes, logit_no = np.log(p_yes) + 3.7, np.log(p_no) + 3.7<br>print(f&quot;from logits:        {fraud_score(logit_yes, logit_no):.2f}&quot;)</pre><p>Output:</p><pre>P(yes) alone:       0.18<br>renormalized score: 0.20<br>from logits:        0.20</pre><p>Because the score is continuous, it can rank accounts and set routing thresholds, which a single generated “yes” or “no” can’t do.</p><p><strong>Results.</strong> Stripe measured precision at the recall human reviewers achieve:</p><ul><li><strong>Zero-shot prompting (verbalized probability)</strong>: Precision at human-review recall: 0.487</li><li><strong>Domain-guided prompting (Stripe’s review criteria in the prompt)</strong>: Precision at human-review recall: 0.706</li><li><strong>Human review</strong>: Precision at human-review recall: 0.790</li></ul><p>Stripe doesn’t give the fine-tuned model’s exact precision on this cohort. It reports relative gains instead: about 24% higher average precision than domain-guided prompting, and about 70% higher than zero-shot. Across multiple Stripe risk tasks, it says the fine-tuned models reach more than 21% higher precision at the same recall and consistently beat XGBoost, though no XGBoost numbers are published.</p><p>The post is careful about scope. Results come from accounts that were selected for review, so they shouldn’t be read as performance across all Stripe businesses. The labels also incorporate later evidence and outcomes, so evaluation labels aren’t fully independent of what happened afterward.</p><p><strong>What fine-tuning changed inside the model.</strong> Stripe used linear probes, which are simple classifiers trained on the model’s frozen internal states, to test which concepts became easier to read out after fine-tuning. It probed 44 concepts. The interesting result is about legitimate businesses:</p><ul><li><strong>Risk signals</strong>: Base model ROC-AUC: 0.702; Fine-tuned ROC-AUC: 0.729</li><li><strong>Benign signals</strong>: Base model ROC-AUC: 0.583; Fine-tuned ROC-AUC: 0.642</li></ul><p>Benign signals started near chance (0.5). Stripe’s interpretation is that the base model carried little information about what a legitimate business looks like, and fine-tuning taught it. Eight of the nine benign signals became easier to recover. For example, “consistent business description” went from 0.614 to 0.707, and “established processing history” from 0.644 to 0.712. Stripe notes these are representation changes, not proof of causal mechanisms.</p><p>That finding fits the Radar story. In both cases, the hard part of fraud detection isn’t recognizing fraud. It’s recognizing what normal looks like for a specific business, without flagging legitimate but unusual activity.</p><h3>What the two posts have in common</h3><ul><li><strong>Labels are the product.</strong> Radar’s rollback came from immature labels. The account model trains on human review labels that fold in later outcomes, and Stripe flags exactly that as an evaluation caveat. In both posts, how labels are made and when they’re trusted shapes the result more than model choice.</li><li><strong>Humans set priorities and thresholds.</strong> Engineers chose which 38 ideas to run and what to ship. Human reviewers set the precision benchmark and supply the training labels, and score thresholds decide which accounts reach them.</li><li><strong>The language model isn’t the fraud model.</strong> In Radar, agents operate the ML pipeline around a 30M-parameter ResNeXt model that scores payments in milliseconds. The 4B model is used where the input is a whole account history and a human review sits downstream.</li></ul><h3>The takeaway</h3><p>The tempting summary is that agents found millions of dollars of fraud. The more accurate one is that agents made a team’s experiment loop fast enough to try 38 things, and that the most important lesson in the post, label maturity, came from a failed experiment rather than a successful one.</p><p>If you train on outcomes that arrive late, the practical step is to plot your own reporting curve before your next training-window change, and to re-run your evaluations after labels mature, not just once. The agents can run the experiments, but deciding which labels to trust is still the engineering problem.</p><p><strong>Sources:</strong></p><ul><li>Ryan Drapeau and Sai Samant, “Scaling Radar: Fighting fraud with agent-assisted machine learning,” Stripe Dot Dev Blog, Oct 19, 2026 — <a href="https://stripe.dev/blog/scaling-radar-fighting-fraud-with-agent-assisted-machine-learning">https://stripe.dev/blog/scaling-radar-fighting-fraud-with-agent-assisted-machine-learning</a></li><li>Hangzhi Guo, Stathis Vafeias, Florian Hartl, Luvsandondov Lkhamsuren, and Adrian Lam, “Fine-tuning LLMs for risk assessment,” Stripe Dot Dev Blog, Oct 9, 2026 — <a href="https://stripe.dev/blog/training-llms-for-risk-assessment">https://stripe.dev/blog/training-llms-for-risk-assessment</a></li><li>Stripe, “Shepherd: How Stripe adapted Chronon to scale ML feature development,” Stripe Dot Dev Blog, Apr 15, 2024 — <a href="https://stripe.dev/blog/shepherd-how-stripe-adapted-chronon-to-scale-ml-feature-development">https://stripe.dev/blog/shepherd-how-stripe-adapted-chronon-to-scale-ml-feature-development</a></li><li>Stripe, “Minions: Stripe’s one-shot, end-to-end coding agents — Part 2,” Stripe Dot Dev Blog, Feb 19, 2026 — <a href="https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents-part-2">https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents-part-2</a></li><li>Izmailov, Podoprikhin, Garipov, Vetrov, and Wilson, “Averaging Weights Leads to Wider Optima and Better Generalization,” arXiv:1803.05407, 2018 — <a href="https://arxiv.org/abs/1803.05407">https://arxiv.org/abs/1803.05407</a></li><li>Xie, Girshick, Dollár, Tu, and He, “Aggregated Residual Transformations for Deep Neural Networks” (ResNeXt), arXiv:1611.05431 — <a href="https://arxiv.org/abs/1611.05431">https://arxiv.org/abs/1611.05431</a></li><li>Alain and Bengio, “Understanding intermediate layers using linear classifier probes,” arXiv:1610.01644, 2016 — <a href="https://arxiv.org/abs/1610.01644">https://arxiv.org/abs/1610.01644</a></li><li>Tian et al., “Just Ask for Calibration: Strategies for Eliciting Calibrated Confidence Scores from Language Models Fine-Tuned with Human Feedback,” EMNLP 2023 — <a href="https://aclanthology.org/2023.emnlp-main.330/">https://aclanthology.org/2023.emnlp-main.330/</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6cff5d9376bf" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[One 740M Model to Search Your Code, Photos, Voice Memos, and Videos — Locally]]></title>
            <link>https://medium.com/@nuthalapativarun/one-740m-model-to-search-your-code-photos-voice-memos-and-videos-locally-6b3a2a9e1284?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/6b3a2a9e1284</guid>
            <category><![CDATA[gemma]]></category>
            <category><![CDATA[google-gemini]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Fri, 06 Nov 2026 01:29:21 GMT</pubDate>
            <atom:updated>2026-11-06T01:29:21.038Z</atom:updated>
            <content:encoded><![CDATA[<h4>EmbeddingGemma 2 puts four modalities into one 768-dimensional space. The interesting engineering is in what it lets you leave out.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fXcil7i3Ck4sdsw_v803Pw.jpeg" /></figure><p>Google’s EmbeddingGemma 2 has 740 million parameters, but if all you’re indexing is text and code, you only load 270 million of them. On MTEB Code, that text-only core scores 78.68, up from 68.76 for the original EmbeddingGemma. Add the 170M vision encoder and the same model embeds photos, screenshots, and video frames. Add the 300M audio encoder and it embeds speech and sound. Every one of those outputs lands in the same vector space, so a typed query can retrieve a voice memo and a photo can retrieve a document.</p><p>Google released it on October 6, 2026, under Apache 2.0, with weights on Hugging Face and Kaggle. The technical report followed on arXiv on October 27. This piece covers how the model is put together, what the Matryoshka dimensions actually cost you, how the shared context budget works, and what it takes to run on a Mac. Three short scripts are included. Two run anywhere with Python and NumPy; the third needs the model weights.</p><p>The headline is not that a small model got multimodal. It’s that every expensive part of it is optional, and the model card is unusually explicit about which shortcuts hurt quality and by how much.</p><h3>What “one embedding space” buys you</h3><p>An embedding model turns an input into a fixed-length vector so that similar inputs end up close together. Search then becomes nearest-neighbor lookup: embed the query, embed the corpus, rank by cosine similarity.</p><p>The classic way to search mixed media locally was to chain models. You’d caption images with a vision-language model, transcribe audio with a speech model, and then embed all the resulting text. That works, but every stage adds latency, memory, and a place for information to get lost. A caption can’t describe what it didn’t notice, and a transcript drops everything that isn’t speech.</p><p>Google’s developer guide describes EmbeddingGemma 2 as replacing those chains with modular encoders that all project into one shared 768-dimensional space. In practice that means:</p><ul><li>A text query like “ocean waves at sunset” can be compared directly against a photo embedding and an audio embedding, without captioning or transcription in between.</li><li>One input can mix modalities. A product listing with a description, two photos, and a demo clip becomes a single vector.</li><li>Text-only and full-multimodal setups share the space. The developer guide says a query embedded with the 270M text-only configuration can be matched directly against documents embedded with the full model, and that adding an encoder later doesn’t require re-embedding what you already indexed.</li></ul><p>That last point is the practical one. You can start with a text-and-code index today and add your photo library next month without rebuilding.</p><h3>How the 740M parameters break down</h3><p>The model card splits the parameters into four parts:</p><ul><li><strong>Transformer backbone</strong> — Parameters: 130M; Loaded for: Every input</li><li><strong>Embedder</strong> — Parameters: 140M; Loaded for: Every input</li><li><strong>Vision encoder</strong> — Parameters: 170M; Loaded for: Images, visual documents, video frames</li><li><strong>Audio encoder</strong> — Parameters: 300M; Loaded for: Speech and sound</li><li><strong>Total</strong> — Parameters: 740M</li></ul><p>The backbone plus embedder is the 270M “text model.” With sentence-transformers you choose which encoders to load at startup, and the model card lists four effective sizes: 270M for text only, 440M for text and images, 570M for text and audio, and 740M for everything. The developer guide says disabled encoders are never loaded, so the savings apply to peak memory, not just to the weights on disk.</p><p>A detail worth noticing: the embedder is larger than the transformer that does the actual sequence processing. The model card lists a vocabulary of 262,144 tokens and a model dimension of 512. By my arithmetic, a plain token-embedding table at that size is 262,144 × 512 ≈ 134M parameters, which accounts for most of the 140M embedder. In other words, roughly half of the text model is vocabulary. That’s the cost of covering 100+ languages and code with one tokenizer, and it’s the same tokenizer Gemma 4 uses.</p><p>The rest of the architecture, per the model card:</p><ul><li>24 layers, model dimension 512, hidden dimension 2048</li><li>Attention alternates five local (sliding-window, 1,024 tokens) layers for every global one, using grouped-query and multi-query attention</li><li>Mean pooling over the sequence, then a projection from 512 to 768 dimensions</li></ul><p>The arXiv report’s abstract credits two design changes for the quality: simplified per-layer embeddings, and a multi-stage training curriculum that progresses through modalities and objectives. The abstract doesn’t break down the individual curriculum stages, so I won’t characterize them further here.</p><p><strong>Shared parts with Gemma 4.</strong> Google’s launch post says EmbeddingGemma 2 shares Gemma 4’s text tokenizer and audio encoder, so a local retrieval-augmented generation (RAG) pipeline running both models has a lower combined memory footprint than two unrelated models would. Google’s own Mac demo app, Foresight, pairs the two this way: EmbeddingGemma 2 retrieves across images, documents, transcripts, and notes, and Gemma 4 generates the answer, all on device.</p><h3>One context window, four exchange rates</h3><p>All modalities share a single 8,192-token context. Each one spends that budget at a fixed rate, which the model card lists:</p><ul><li><strong>Text</strong> — Cost: 1 token per subword; Fits in one input (single modality, no text): 8,192 tokens</li><li><strong>Image</strong> — Cost: 280 tokens per image (default); Fits in one input (single modality, no text): ~29 images</li><li><strong>Video</strong> — Cost: 140 tokens per frame (default); Fits in one input (single modality, no text): ~58 frames</li><li><strong>Audio</strong> — Cost: 25 tokens per second; Fits in one input (single modality, no text): ~327 seconds</li></ul><p>Three practical consequences follow:</p><ol><li><strong>Audio is the cheapest per second of content.</strong> About five and a half minutes of audio fits in one input. Video at the default 1 frame per second only fits about a minute.</li><li><strong>The vision budget is adjustable.</strong> Images and frames can use anywhere from 70 to 1,120 tokens each. Fewer tokens means faster embedding and more frames per input; more tokens captures finer visual detail. The model card says the lowest budget raises the limit to about 114 images or frames.</li><li><strong>Interleaved inputs share the budget.</strong> A listing with a paragraph, two photos, and a 20-second clip spends the paragraph’s tokens plus 560 for the photos plus 2,800 for the frames.</li></ol><p>Video is sampled at 1 frame per second by default, and audio should be supplied as 16 kHz mono.</p><p>Here’s a small calculator for planning chunk sizes. It uses only the model card’s default rates and runs anywhere with Python:</p><pre># budget.py — how much of each modality fits in EmbeddingGemma 2&#39;s 8,192-token context<br>CONTEXT = 8192<br>RATES = {<br>    &quot;image (280 tokens each)&quot;: 280,<br>    &quot;video frame (140 tokens each)&quot;: 140,<br>    &quot;audio second (25 tokens each)&quot;: 25,<br>}<br><br>for name, cost in RATES.items():<br>    print(f&quot;{name:32s} max {CONTEXT // cost}&quot;)<br><br># A mixed input: 400 tokens of text, 2 images, a 20-second clip at 1 fps<br>used = 400 + 2 * 280 + 20 * 140<br>print(f&quot;\nmixed listing uses {used} of {CONTEXT} tokens ({used / CONTEXT:.0%})&quot;)</pre><p>Output:</p><pre>image (280 tokens each)          max 29<br>video frame (140 tokens each)    max 58<br>audio second (25 tokens each)    max 327<br><br>mixed listing uses 3760 of 8192 tokens (46%)</pre><h3>Matryoshka dimensions: what you actually lose</h3><p>EmbeddingGemma 2 is trained with Matryoshka Representation Learning (MRL). The idea is that training pushes the most important information into the leading dimensions of the vector, so you can keep only the first 512, 256, or 128 values and still get a usable embedding. Shorter vectors mean a smaller index and faster search.</p><p>The storage savings are linear. Google’s developer guide gives the example that a million 768-dimensional vectors stored in bfloat16 take roughly 1.5 GB, while the same million at 128 dimensions take about 250 MB, a 6x reduction.</p><p>The quality cost is not linear, and it isn’t the same for every modality. Here’s a selection from the model card’s truncation table:</p><ul><li><strong>768</strong> — MTEB English (text): 68.46; MTEB Code: 78.68; MMEB v2 Overall (image/video/visdoc): 59.01; MSEB Retrieval (audio): 69.54</li><li><strong>512</strong> — MTEB English (text): 68.41; MTEB Code: 77.24; MMEB v2 Overall (image/video/visdoc): 58.38; MSEB Retrieval (audio): 69.18</li><li><strong>256</strong> — MTEB English (text): 67.78; MTEB Code: 76.18; MMEB v2 Overall (image/video/visdoc): 56.24; MSEB Retrieval (audio): 66.76</li><li><strong>128</strong> — MTEB English (text): 65.68; MTEB Code: 71.41; MMEB v2 Overall (image/video/visdoc): 45.65; MSEB Retrieval (audio): 56.71</li></ul><p>Read across the 128 row. English text loses under 3 points. The multimodal benchmark loses over 13, and audio retrieval loses nearly 13. The model card’s own guidance follows from this: quality is close to lossless down to 256 dimensions, and 128 is best kept for text-only workloads. The developer guide’s rough rule is that 256 dimensions keeps about 95% of image, video, and speech retrieval quality, while 128 keeps around 90% for text and code but drops to around 75% for the other modalities.</p><p><strong>The one mistake that fails silently.</strong> A truncated vector is no longer unit length. If your search computes cosine similarity as a plain dot product, which many vector stores do for normalized embeddings, you must L2-normalize again after slicing. The model card warns that skipping this step doesn’t raise an error; it just quietly degrades ranking.</p><p>This script shows the mechanics with stand-in vectors. It doesn’t use the model, so it can’t show MRL’s quality behavior, but it does show the normalization trap and the storage arithmetic:</p><pre># mrl_demo.py — truncation, re-normalization, and storage math (NumPy only)<br>import numpy as np<br><br>rng = np.random.default_rng(0)<br><br>def unit(v):<br>    return v / np.linalg.norm(v, axis=-1, keepdims=True)<br><br># Stand-ins for unit-length 768-d embeddings<br>docs = unit(rng.standard_normal((5, 768)))<br>query = unit(docs[2] + 0.3 * unit(rng.standard_normal(768)))  # closest to doc 2<br><br>for dim in (768, 256, 128):<br>    d, q = docs[:, :dim], query[:dim]<br>    raw = d @ q                      # dot product on sliced, un-normalized vectors<br>    fixed = unit(d) @ unit(q)        # true cosine after re-normalizing<br>    print(f&quot;{dim:4d}d  |q| after slicing = {np.linalg.norm(q):.3f}  &quot;<br>          f&quot;max dot = {raw.max():.3f}  max cosine = {fixed.max():.3f}&quot;)<br><br># Storage for one million vectors in bfloat16 (2 bytes per value)<br>for dim in (768, 512, 256, 128):<br>    print(f&quot;{dim:4d}d x 1M vectors: {dim * 2 * 1_000_000 / 1e6:,.0f} MB&quot;)</pre><p>Output:</p><pre>768d  |q| after slicing = 1.000  max dot = 0.958  max cosine = 0.958<br> 256d  |q| after slicing = 0.587  max dot = 0.330  max cosine = 0.960<br> 128d  |q| after slicing = 0.414  max dot = 0.167  max cosine = 0.957<br> 768d x 1M vectors: 1,536 MB<br> 512d x 1M vectors: 1,024 MB<br> 256d x 1M vectors: 512 MB<br> 128d x 1M vectors: 256 MB</pre><p>The ranking in this toy happens to survive because all vectors shrink by similar amounts, but the scores are no longer cosines. Any fixed similarity threshold you tuned at 768 dimensions, such as “only return matches above 0.7,” breaks completely. With sentence-transformers you can avoid the issue by passing truncate_dim and normalize_embeddings=True to encode(), which is what the model card recommends. Also note that queries and documents must use the same dimension.</p><h3>The numbers, and what they’re compared against</h3><p>Here are the model card’s full-precision results at 768 dimensions:</p><ul><li><strong>Text MTEB multilingual v2</strong> — EmbeddingGemma 2: 61.36; EmbeddingGemma 1: 61.15</li><li><strong>Text MTEB English v2</strong> — EmbeddingGemma 2: 68.46; EmbeddingGemma 1: 69.67*</li><li><strong>Code MTEB Code v1</strong> — EmbeddingGemma 2: 78.68; EmbeddingGemma 1: 68.76</li><li><strong>Image MIEB lite</strong> — EmbeddingGemma 2: 65.67; EmbeddingGemma 1: —</li><li><strong>Image MMEB v2 Image</strong> — EmbeddingGemma 2: 57.28; EmbeddingGemma 1: —</li><li><strong>Visual docs MMEB v2 VisDoc</strong> — EmbeddingGemma 2: 67.84; EmbeddingGemma 1: —</li><li><strong>Video MMEB v2 Video</strong> — EmbeddingGemma 2: 50.67; EmbeddingGemma 1: —</li><li><strong>Audio MSEB Retrieval</strong> — EmbeddingGemma 2: 69.54; EmbeddingGemma 1: —</li><li><strong>Audio MAEB</strong> — EmbeddingGemma 2: 49.87; EmbeddingGemma 1: —</li></ul><p>*The EmbeddingGemma 2 model card leaves this cell blank. The 69.67 comes from the original EmbeddingGemma model card on Hugging Face, which reports full-precision 768-dimension scores the same way.</p><p>Three things to keep in mind when reading this:</p><ul><li><strong>The comparison column is only the predecessor.</strong> The model card compares EmbeddingGemma 2 against EmbeddingGemma 1 and nothing else. Every row below code is new capability, with nothing to compare against on the card.</li><li><strong>Text didn’t improve, and English slipped slightly.</strong> Multilingual text is essentially flat (+0.21). English text is down 1.21 points once you pull the predecessor’s number from its own model card. The real text-side gain is code, at +9.92. If your workload is English prose only and you already run the original EmbeddingGemma, upgrading for text quality alone isn’t supported by these numbers. The reasons to upgrade are code, multimodality, the 8K context, and the Apache 2.0 license.</li><li><strong>The strongest claims are Google’s.</strong> The launch post calls it best-in-class among sub-1B multimodal embedders on MTEB Code and MAEB, and the arXiv abstract says it beats specialist models and models two to twelve times larger. Those comparisons live in Google’s charts and paper. I haven’t seen an independent leaderboard run yet, so treat them as vendor results until one appears.</li></ul><h3>Running it on a Mac</h3><p><strong>Measured speed.</strong> Google’s AI Edge team reports that on a MacBook with an M5 Pro, using the GPU and a 70-token vision budget, image embedding takes 37.3 ms per image, or 26.9 images per second. That’s Google’s measurement at the lowest vision budget. Higher budgets will be slower, and I haven’t reproduced it.</p><p><strong>Memory.</strong> Google’s only published RAM figures are for a Pixel 11 Pro with quantized weights: about 191 MB active for text only and about 567 MB for the full multimodal model. There’s no published Mac figure.</p><p><strong>Option 1: sentence-transformers (all modalities).</strong> This is Google’s reference path, and the developer guide says it needs sentence-transformers 6.1.0 or later. The model card’s precision rule matters on a Mac: run in bfloat16 or float32, never float16. The model’s activations exceed float16’s range, and it returns NaN or silently degraded embeddings rather than raising an error. Using float32 is the conservative default.</p><pre># search_local.py — needs: pip install -U &quot;sentence-transformers[image,audio,video]&quot; transformers<br># Replace the file paths with your own files.<br>import numpy as np<br>import torch<br>from sentence_transformers import SentenceTransformer<br><br>model = SentenceTransformer(<br>    &quot;google/embeddinggemma-2&quot;,<br>    config_kwargs={&quot;audio_config&quot;: None},          # text + images only (440M)<br>    model_kwargs={&quot;torch_dtype&quot;: torch.float32},   # never float16<br>)<br><br>photos = [&quot;IMG_0412.jpg&quot;, &quot;IMG_0413.jpg&quot;, &quot;whiteboard.png&quot;]<br>photo_embs = np.stack([<br>    model.encode({&quot;image&quot;: p},                     # media takes no task prefix<br>                 truncate_dim=256, normalize_embeddings=True)<br>    for p in photos<br>])<br><br>query_emb = model.encode(<br>    &quot;architecture diagram from the planning meeting&quot;,<br>    prompt_name=&quot;SearchQuery&quot;,                     # text queries take a task prefix<br>    truncate_dim=256,<br>    normalize_embeddings=True,<br>)<br><br>scores = model.similarity(query_emb, photo_embs)[0]<br>for path, score in sorted(zip(photos, scores.tolist()), key=lambda x: -x[1]):<br>    print(f&quot;{score:.3f}  {path}&quot;)</pre><p>Two details in that script come straight from the model card. Text inputs should use a task prefix, here via prompt_name=&quot;SearchQuery&quot;; skipping it still works but lowers quality. Images, video, and audio take no prefix. If your documents have real titles, the card asks you to format them as title: {title} | text: {content} yourself, since prompt_name=&quot;Document&quot; assumes no title.</p><p><strong>Option 2: Ollama (text, and images on some tags).</strong> Ollama lists five tags: 270m, 440m, 570m, 740m, and latest. Its listing shows image input only on 440m, 740m, and latest, and doesn&#39;t list audio input for any tag. The listing also shows a 256K context window, which disagrees with the model card&#39;s 8,192 tokens, so I&#39;d trust the model card on that. The text path is one command:</p><pre>ollama pull embeddinggemma-2:270m<br>curl http://localhost:11434/api/embed \<br>  -d &#39;{&quot;model&quot;: &quot;embeddinggemma-2:270m&quot;, &quot;input&quot;: &quot;task: code retrieval | query: retry with exponential backoff&quot;}&#39;</pre><p><strong>MLX.</strong> Google lists MLX among supported runtimes, but its launch post and developer guide link to two different MLX projects, and Google’s MLX integration page only covers Gemma 4 generation. I couldn’t confirm an official MLX embedding command, so I’m leaving it out rather than guessing. For now, sentence-transformers is the verified route for all four modalities on a Mac.</p><h3>The takeaway</h3><p>EmbeddingGemma 2’s real contribution is the shape of its tradeoffs, more than any single benchmark row. You pay 270M parameters for text and code, and you add vision or audio only when your data has it. You pick 768, 512, or 256 dimensions for anything multimodal, and keep 128 for text-only shortlists. You trade frames for detail with the vision budget. Each of those choices comes with a published number, so you can decide with data instead of guesswork.</p><p>For anyone building local search on a Mac, the practical sequence is to start with the text-only configuration over your code and notes, add the vision encoder for screenshots and photos, and keep everything in one index because the space is shared. The open questions are the independent benchmarks and a first-party MLX path. Until those arrive, sentence-transformers is the reference, and the model card’s float16 and re-normalization warnings are the two mistakes to avoid.</p><p><strong>Sources:</strong></p><ul><li>Google, “EmbeddingGemma 2: an open, lightweight multimodal embedding model,” The Keyword, Oct 6, 2026 — <a href="https://blog.google/innovation-and-ai/technology/developers-tools/embeddinggemma-2/">https://blog.google/innovation-and-ai/technology/developers-tools/embeddinggemma-2/</a></li><li>Google DeepMind, EmbeddingGemma 2 model card, Hugging Face — <a href="https://huggingface.co/google/embeddinggemma-2">https://huggingface.co/google/embeddinggemma-2</a></li><li>Google DeepMind, EmbeddingGemma (300M) model card, Hugging Face — <a href="https://huggingface.co/google/embeddinggemma-300m">https://huggingface.co/google/embeddinggemma-300m</a></li><li>Grootendorst &amp; Ballantyne, “EmbeddingGemma 2: The Developer Guide,” Google Developers Blog, Oct 6, 2026 — <a href="https://developers.googleblog.com/en/embeddinggemma-2-the-developer-guide/">https://developers.googleblog.com/en/embeddinggemma-2-the-developer-guide/</a></li><li>Schechter Vera, Dua, et al., “EmbeddingGemma 2: Unified and Efficient Multimodal Representations,” arXiv:2610.37364, Oct 27, 2026 — <a href="https://arxiv.org/abs/2610.37364">https://arxiv.org/abs/2610.37364</a></li><li>Google AI Edge, “Google AI Edge with EmbeddingGemma 2,” Google Developers Blog, Oct 2026 — <a href="http://developers.googleblog.com/google-ai-edge-with-embeddinggemma-2">http://developers.googleblog.com/google-ai-edge-with-embeddinggemma-2</a></li><li>Ollama library, embeddinggemma-2 — <a href="https://ollama.com/library/embeddinggemma-2">https://ollama.com/library/embeddinggemma-2</a></li><li>Google AI for Developers, Gemma MLX integration page — <a href="https://ai.google.dev/gemma/docs/integrations/mlx">https://ai.google.dev/gemma/docs/integrations/mlx</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6b3a2a9e1284" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pinterest Replaced a Five-Stage Clustering Pipeline With a 4B Model That Writes Your Goals as JSON]]></title>
            <link>https://medium.com/@nuthalapativarun/pinterest-replaced-a-five-stage-clustering-pipeline-with-a-4b-model-that-writes-your-goals-as-json-c54fd37feec3?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/c54fd37feec3</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[qwen]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 03:01:02 GMT</pubDate>
            <atom:updated>2026-11-04T03:01:02.904Z</atom:updated>
            <content:encoded><![CDATA[<h4>How a distilled Qwen3 student, a prompt that acts as policy, and ~100 L40S GPUs turned “what is this person trying to do?” into a generation problem</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BOdHP5g-afAHvJkGIZY-IA.jpeg" /></figure><p>A cabinet click, a backsplash search and a countertop save don’t mean much one at a time. Together, they probably mean someone is redoing their kitchen. Pinterest calls that kind of longer-term goal a <strong>journey</strong>, and in an October 9 engineering post it described how it now infers journeys in production: a fine-tuned 4-billion-parameter LLM reads a person’s chronological activity log and writes back a ranked JSON list of journey names, in that person’s language.</p><p>The interesting part isn’t that an LLM can do this. Frontier models already can. It’s what Pinterest had to change to do it for hundreds of millions of people: a distilled small model, a prompt that carries most of the business logic, an output contract that treats “no journey” as a correct answer, and a serving stack that sustains about 775 requests per second on roughly 100 GPUs.</p><blockquote>A note on sourcing: Pinterest’s post is on Medium, which blocked automated fetching during my research. Every specific below is drawn from Pinterest’s own text as quoted in search results, cross-checked against ZenML’s independent LLMOps case-study write-up of the same post. Where a detail comes only from ZenML’s summary, I say so.</blockquote><h3>The pipeline it replaced</h3><p>Pinterest’s first journey system was a classic multi-stage ML pipeline. ZenML’s summary lists the stages: extract keywords from searches, Pin annotations and the user’s own boards; embed and cluster them; pick representative names; rank and diversify the clusters; and finally classify each journey with heuristics. Pinterest’s post describes that last stage as deciding evergreen versus situational (does engagement persist past a few weeks?) and, for situational journeys, ongoing versus ended.</p><p>It worked. Against ordinary interest-based notifications, the legacy journey system lifted email click rate by 88%, push open rate by 32%, and positive survey feedback by 23%. Those are big numbers, and it’s worth being clear they describe the <em>old</em> clustering system versus interest-based notifications, not the new LLM.</p><p>But stacking stages compounds errors, and Pinterest names three failure modes:</p><ul><li><strong>Fragmentation.</strong> Parts of one project (the tile, the countertop, the shelving) could land in separate clusters and become separate journeys.</li><li><strong>Generic buckets.</strong> Clusters could collapse into labels like “Art” or “Shopping.” Pinterest’s verdict on those: “Taxonomically true, useless for personalization.”</li><li><strong>Incidental noise.</strong> Clustering has no idea which activity is a goal and which is a passing click, so one stray celebrity Pin could displace a real project.</li></ul><p>Each of those is a judgment call that crosses stage boundaries. Is this click part of that project? Is this cluster specific enough to be worth a notification? A pipeline makes those calls locally, one stage at a time. A language model reading the whole log can make them in one pass.</p><h3>Reframing: journey inference as generation</h3><p>Per ZenML’s summary, the new system’s input is a semi-static user profile, the user’s board names, and a compact activity log. Pinterest’s post gives the key settings for that log and the decode:</p><ul><li><strong>A 360-day lookback, up to 150 events.</strong> Journeys are long-horizon by definition; Pinterest says it tried different windows and event counts to balance cost and relevance.</li><li><strong>Events grouped by (date, action)</strong>, with a semicolon between Pins and a comma between tags within a Pin. Grouping saves tokens and makes bursts visible: five saves on one day about the same topic look different from one save a month.</li><li><strong>Temperature 0, reasoning tokens off, max 300 output tokens.</strong> The goal is a deterministic, replayable read of the same log. Reasoning is off purely to cut online inference cost.</li></ul><p>The output is up to about twelve distinct journeys, each 1 to 5 words, in the user’s locale, ranked. The prompt encodes the rules that clustering used to get wrong: don’t merge unrelated topics, drop one-off outliers, treat auto-generated tags as noisy, and only lightly diversify the top of the list among journeys that are already well supported. Searches and saves are treated as the strongest intent signals.</p><p>Two design choices stand out.</p><p><strong>Empty output is allowed; invention isn’t.</strong> Pinterest states this plainly: if the activity doesn’t support a journey, the model may return nothing, but it may not make one up. An empty but valid JSON list counts as a successful inference. They’d rather send nothing journey-specific than a generic “Art.” In evaluation, a fluent, on-brand journey name that isn’t supported by the log counts as a failure, even if a user might have clicked it.</p><p><strong>Negative signals live in the prompt.</strong> Dislikes stay in the activity log, and Pinterest says this dislike rule replaced a hand-tuned negative-signal filter. That’s a small sentence with a big implication: logic that used to be a separate component with its own thresholds is now a line of instructions. ZenML’s summary notes that Pinterest treats prompt changes as production launches for exactly this reason. The prompt has become a policy surface.</p><p>To make the input and output ends concrete, here’s a small toy script. <strong>It is not Pinterest’s code</strong>; it reconstructs the two contracts the post describes, with invented activity:</p><pre>&quot;&quot;&quot;Toy reconstruction of the two ends of Pinterest&#39;s journey-inference contract.<br>This is NOT Pinterest&#39;s code. It illustrates two things their post describes:<br>  1. Prompt input: events grouped by (date, action), with &#39;;&#39; between Pins<br>     and &#39;,&#39; between tags within a Pin, capped at a lookback and event count.<br>  2. Output handling: parse a JSON list, drop non-strings, dedupe<br>     case-insensitively keeping the first occurrence; an empty list is a<br>     valid, successful answer.<br>The sample activity and model output below are invented.<br>&quot;&quot;&quot;<br><br>import json<br>from collections import OrderedDict<br>from datetime import date, timedelta<br><br>LOOKBACK_DAYS = 360<br>MAX_EVENTS = 150<br><br>events = [<br>    # (date, action, tags of the Pin)<br>    (date(2026, 10, 2), &quot;search&quot;, [&quot;kitchen backsplash&quot;]),<br>    (date(2026, 10, 2), &quot;save&quot;, [&quot;subway tile&quot;, &quot;white kitchen&quot;]),<br>    (date(2026, 10, 2), &quot;save&quot;, [&quot;quartz countertop&quot;]),<br>    (date(2026, 10, 3), &quot;click&quot;, [&quot;celebrity red carpet&quot;]),<br>    (date(2026, 10, 5), &quot;save&quot;, [&quot;open shelving&quot;, &quot;kitchen&quot;]),<br>    (date(2026, 10, 5), &quot;dislike&quot;, [&quot;gaming setup&quot;]),<br>    (date(2025, 6, 1), &quot;save&quot;, [&quot;old event outside lookback&quot;]),<br>]<br><br>def build_activity_block(events, today):<br>    cutoff = today - timedelta(days=LOOKBACK_DAYS)<br>    recent = [e for e in events if e[0] &gt;= cutoff]<br>    recent = sorted(recent, key=lambda e: e[0])[-MAX_EVENTS:]<br>    groups = OrderedDict()<br>    for day, action, tags in recent:<br>        groups.setdefault((day, action), []).append(&quot;,&quot;.join(tags))<br>    return &quot;\n&quot;.join(f&quot;{day.isoformat()} {action}: {&#39;;&#39;.join(pins)}&quot;<br>                     for (day, action), pins in groups.items())<br><br>def parse_journeys(raw):<br>    try:<br>        items = json.loads(raw)<br>    except json.JSONDecodeError:<br>        return None            # a failed decode: retry this user<br>    if not isinstance(items, list):<br>        return None<br>    seen, out = set(), []<br>    for item in items:<br>        if not isinstance(item, str):<br>            continue           # drop non-strings<br>        key = item.strip().lower()<br>        if key and key not in seen:<br>            seen.add(key)<br>            out.append(item.strip())<br>    return out                 # [] is a valid answer, not an error<br><br>print(build_activity_block(events, today=date(2026, 10, 9)))<br>print()<br>print(parse_journeys(&#39;[&quot;Kitchen remodel&quot;, &quot;kitchen Remodel&quot;, 42, &quot;Open shelving ideas&quot;]&#39;))<br>print(parse_journeys(&quot;[]&quot;))<br>print(parse_journeys(&quot;not json&quot;))<br></pre><p>Output:</p><pre>2026-10-02 search: kitchen backsplash<br>2026-10-02 save: subway tile,white kitchen;quartz countertop<br>2026-10-03 click: celebrity red carpet<br>2026-10-05 save: open shelving,kitchen<br>2026-10-05 dislike: gaming setup<br><br>[&#39;Kitchen remodel&#39;, &#39;Open shelving ideas&#39;]<br>[]<br>None</pre><p>The distinction in the last three lines is the operational point. A malformed response (None) is a failure to retry; an empty list is a real answer that means &quot;don&#39;t send this person a journey notification today.&quot; Collapsing those two cases is an easy bug to write, and it would quietly turn &quot;no evidence&quot; into &quot;try again until the model invents something.&quot;</p><h3>Distilling a frontier teacher into a 4B student</h3><p>Pinterest is direct about why it didn’t just call a frontier model: they can infer coherent journeys without any task-specific training, but they’re too expensive to run regularly for hundreds of millions of people.</p><p>So the frontier models became teachers. They generated synthetic training examples, each pairing an activity history with a high-quality target journey list. One detail is worth borrowing: Pinterest found teacher outputs got better when the teacher was allowed extra reasoning tokens before its final structured answer. The <em>student</em> serves with reasoning off; the <em>teacher</em> thinks out loud while making its training data. You pay for reasoning once, at data-generation time, instead of on every production call.</p><p>Pinterest fine-tuned open-weight Qwen3 students at 0.6B, 1.7B, 4B and 8B parameters, using supervised fine-tuning with LoRA adapters rather than updating every weight. The 4B model is the production default; ZenML’s summary describes the trade-off as smaller models being faster but weaker on relevance and consistency in hard cases.</p><p>The data finding is the most transferable lesson in the post. Quality improved rapidly over the first few thousand examples and then largely stopped. More examples of the same kind added little. What helped was selecting diverse, difficult examples, especially consolidation cases (many signals, one project) and multilingual cases. If you’re distilling a task like this, the budget should go into finding hard examples, not into generating more easy ones.</p><h3>Serving at notification scale</h3><p>Pinterest describes the workload as a batch problem with online-shaped latency. There’s a full refresh when the algorithm changes and a daily incremental pass over recently active users.</p><p>The stack is <strong>NVIDIA Dynamo in front of vLLM decode workers on L40S GPUs</strong>. Frontend and decode workers scale independently. The frontend routes with KV-cache awareness, so requests that share prompt structure land on workers already holding that prefix. That matters a lot here because every request shares one long, stable system prompt: the instructions, ranking policy and safety rules are identical for every user, and only the activity block changes. Pinterest also enables prefix caching and chunked prefill and caps concurrent sequences per worker.</p><p>The reported numbers: <strong>a cluster on the order of 100 GPUs sustains about 775 requests per second end-to-end, with median latency around 1.2 seconds and p95 around 3.0 seconds.</strong> Some quick arithmetic (mine, not Pinterest’s): that’s roughly 7–8 requests per second per GPU, and at a sustained 775 per second, about 67 million requests in 24 hours. These are company-reported figures, not an independent benchmark, and nothing in the parts of the post I could verify says how many users a daily pass covers.</p><p>The cheaper wins, Pinterest says, are on the batch side:</p><ul><li><strong>Sharding with bounded concurrency.</strong> Inference runs as a daily fan-out sharded into small batches, so a slow model response back-pressures one shard instead of stalling the whole pipeline.</li><li><strong>Not recomputing what hasn’t changed.</strong> Journeys don’t change hour to hour. Per ZenML’s summary, Pinterest records each user’s last inference time and skips users processed within the previous couple of days. Pinterest makes the point that re-running inference on a journey that hasn’t moved is especially insidious waste: the job completes, the counters go up, and nothing new was learned.</li></ul><p>Downstream, each journey name becomes a retrieval query that finds candidate Pins, which existing ranking models then order. Notification logic suppresses journeys the person was recently notified about (fatigue) and journeys they’ve given explicit negative feedback on. On safety, Pinterest says it suppresses inference of sensitive traits at multiple levels before anything is stored; ZenML’s summary adds that both input keywords and generated names pass through content filters.</p><h3>Did it work?</h3><p>There’s no ground-truth label for “what someone is really trying to do,” so Pinterest evaluates in layers: side-by-side comparisons of clustering and LLM output for the same users (including misses like over-generalizing), and, per ZenML’s summary, a separate language model that grades sampled outputs on relevance, evidence support, diversity and granularity.</p><p>The online result: an initial experiment for US and Canada users, <strong>with the clustering system as the control</strong>, showed lifts of <strong>1.1% in email click-through and 1.3% in push opens</strong>, plus multiple statistically significant sitewide engagement and retention gains. ZenML reports that Pinterest says a later international, multilingual rollout replicated the direction.</p><p>Read those two numbers carefully, because they’re easy to misread next to the 88% from earlier. The 88% was the legacy journey system against plain interest-based notifications. The 1.1% and 1.3% are the LLM <em>against that already-strong journey system</em>. A low-single-digit lift on top of a system that already beat its baseline by large margins is a meaningful result at Pinterest’s scale. It is not “the LLM improved clicks by 88%,” which is how a skimmed version of this story could easily get told. ZenML also notes the post gives no population sizes, confidence intervals or absolute baselines for the experiment.</p><h3>How this differs from Netflix’s approach</h3><p>If you read my earlier piece on Netflix’s GenRec, the contrast is instructive. Both companies turned a recommendation problem into “feed a language model the user’s history as text.” But they made opposite output choices. Netflix’s model never writes a word: it does prefill-only scoring with a ranking head over catalog item embeddings, so whatever it returns is always a catalog item. Pinterest’s model <em>does</em> generate text, short journey names, and then uses those names as retrieval queries into existing systems.</p><p>Generation buys Pinterest something scoring can’t: open-vocabulary, human-readable, multilingual intents that work as queries and as notification hooks. The price is exactly the risk Pinterest spends so much of the post guarding against: a generator can invent. The empty-output rule, the “unsupported name is a failure” evaluation, the output parsing and the content filters are all there because they chose generation.</p><h3>What’s next</h3><p>Pinterest lists several experiments in progress:</p><ul><li><strong>Semantic IDs instead of tags.</strong> Each Pin gets compressed by a residual-quantized variational autoencoder (RQ-VAE) into five levels of 11 bits, packed into one 64-bit integer and expanded into special tokens when the prompt is built. The bet is fewer input tokens, no dependency on Pin annotations, and a shared item vocabulary between recommenders and LLMs. The cost, per ZenML’s summary, is much harder debugging, since a sequence of semantic IDs isn’t readable the way tags are.</li><li><strong>Offsite signals.</strong> Safety-filtered offsite actions such as page visits, add-to-carts, sign-ups and checkouts, added only when onsite activity is sparse, with a capped prompt footprint, and never allowed to rank offsite-only journeys at the top. Pinterest says it’s testing whether better prompting is enough or a fine-tuned offsite-aware model is needed.</li><li>Per ZenML’s summary, also: multi-granularity journeys, generating notification copy jointly, and preference optimization from explicit and implicit feedback.</li></ul><h3>The takeaway</h3><p>The pattern here is going to show up everywhere personalization meets LLMs. Use a frontier model to write the training data, with reasoning on. Distill into a small open-weight model that serves with reasoning off. Put the policy in the prompt and treat prompt edits like deploys. Make “nothing” a valid output and “invented” an invalid one. Then spend your efficiency effort on not running the model at all when nothing has changed. A 4B model on L40S GPUs replacing a five-stage pipeline isn’t a story about model capability. It’s a story about turning a chain of fragile heuristics into one contract that can be tested, replayed and reasoned about.</p><p><strong>Sources:</strong> — Pinterest Engineering, “From Activity to Intent: Generating User Journeys with LLMs” (Lin Zhu, Manan Kalra, Logan Jeon, Ye Liu, et al.), Medium, Oct 9, 2026 (specifics drawn from Pinterest’s text as returned in search results) — ZenML LLMOps Database, “Pinterest: Generative User-Journey Inference at Notification Scale,” 2026 (independent case-study summary of the Pinterest post) — Pinterest Business Community, “Blog: From Activity to Intent: Generating User Journeys with LLMs,” Oct 2026 — daily.dev, “Best of LLM — October 2026” (summary of Netflix GenRec’s prefill-only ranking head served via vLLM)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c54fd37feec3" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Mistral Large 4 Is Open. Running It on Macs Takes 338 to 590 GB, and the Math Explains Why]]></title>
            <link>https://medium.com/@nuthalapativarun/mistral-large-4-is-open-running-it-on-macs-takes-338-to-590-gb-and-the-math-explains-why-8c41b918c44d?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/8c41b918c44d</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[mistral-ai]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[apple]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 20:33:34 GMT</pubDate>
            <atom:updated>2026-11-01T20:33:34.508Z</atom:updated>
            <content:encoded><![CDATA[<h3>A trillion-parameter MoE meets MLX: three community builds, one unmerged PR, and the 512 GB Mac Studio that went on sale the day before the weights dropped</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wAUdz_XOVnF6EUF51m-qSg.jpeg" /></figure><p>On October 28, Apple opened orders for the 512 GB Mac Studio with M5 Ultra at $22,299. The next day, Mistral released the open weights of Mistral Large 4, a natively multimodal Mixture-of-Experts model with roughly 1 trillion total parameters. The same day, an mlx-lm pull request appeared listing three community MLX conversions that show what it takes to put that model on Apple Silicon: a 338 GB 2-bit build and a 459 GB 3-bit build that each fit on a single 512 GB Mac, and a 590 GB 4-bit build that needs two.</p><p>Those three numbers are the whole story of running frontier-scale MoE models locally. This piece walks through where they come from, what the one published speed figure (18 tokens per second) does and doesn’t tell you, and the practical steps, including the one that trips most people: the code to run it isn’t in a released mlx-lm yet.</p><h3>What Mistral actually shipped</h3><p>Mistral calls the model “Le Chonk.” The October 29 release follows a public preview that began October 6; Mistral says the open-weight version got roughly three more weeks of reinforcement learning on top of the preview.</p><p>The architecture, per Mistral’s model card and Hugging Face’s launch post:</p><ul><li><strong>~1T total parameters, 49B active per token</strong> (52B counting embeddings and output layers). That’s where the A52B in the repo name comes from.</li><li><strong>128 routed experts, 4 active per token, plus 1 shared expert</strong> that always runs. The first of the 72 decoder layers is dense; the other 71 are MoE.</li><li><strong>Multi-head Latent Attention (MLA) with a DeepSeek Sparse Attention (DSA) indexer</strong>, which selects up to 2,048 key positions for each query. Hugging Face’s write-up describes this as two different kinds of sparsity: the router picks which feed-forward experts run, while the indexer picks which earlier tokens get attended to.</li><li><strong>1M-token context</strong>, text and image input (a 1.6B Pixtral vision encoder), text output.</li><li><strong>An embedded DSpark speculative-decoding head</strong>: three dense MLA draft layers that propose blocks of three tokens. Mistral reports measuring up to 1.9× higher throughput with it enabled, and notes that gains depend on hardware and workload.</li><li><strong>Official checkpoints in FP8 and NVFP4.</strong> The NVFP4 one targets Blackwell GPUs.</li></ul><p>The license is the Mistral Ecosystem Open-Weight License, a modified MIT license. It is free to use, modify, fine-tune and commercialize, with one condition: a business offering the model as a service needs a separate commercial license from Mistral.</p><p>On benchmarks, Mistral’s own numbers put it at 48% on the Artificial Analysis Coding Agent Index, effectively tied with GLM-5.3 while using 47% fewer tokens per task on average, and 41% on Terminal-Bench 4.0, up 13 points from the preview. (Hugging Face’s launch post gives a different Coding Agent Index figure, 49.8%, so expect the exact number to depend on which reasoning setting and snapshot you look at.) Two caveats are worth keeping. These are Mistral’s reported results, and on the broader Artificial Analysis Intelligence Index v5 the model scores 26, which Mistral itself notes is behind the leading Chinese open-weight models. It’s a strong coding and cybersecurity model, not a uniformly dominant one.</p><h3>Why a 1T MoE is a memory problem, not a compute problem</h3><p>Here is the concept that makes every number below make sense.</p><p>A Mixture-of-Experts model has two different sizes. <strong>Total parameters</strong> decide how much memory you need, because every expert has to be resident: the router can pick any 4 of the 128 for the next token, and you don’t know which ahead of time. <strong>Active parameters</strong> decide how much work each token takes, because only the selected experts (plus the shared expert and the attention layers) actually run.</p><p>On a GPU server, the first number is the hard part: you need enough HBM across enough cards to hold a trillion parameters. On Apple Silicon, unified memory flips the economics. A single machine can hold hundreds of gigabytes, and the GPU reads directly from that pool. What limits decode speed is then mostly memory bandwidth: for each generated token, the hardware has to stream the active weights through the GPU.</p><p>So two back-of-envelope formulas get you surprisingly far:</p><ul><li><strong>Memory needed ≈ total parameters × bits per weight ÷ 8</strong>, plus headroom for the KV cache, activations, and macOS.</li><li><strong>Decode ceiling ≈ memory bandwidth ÷ (active parameters × bits per weight ÷ 8).</strong></li></ul><p>The second one is a ceiling, not a prediction. It ignores the KV cache reads (which grow with context), the sparse-attention indexer, routing overhead, and communication between machines. Real systems land well below it. But it tells you which order of magnitude you’re in, and it shows why quantization helps speed as well as fit: fewer bits per weight means fewer bytes moved per token.</p><h3>The three MLX builds</h3><p>All three conversions come from the same source: the FP8 checkpoint, run through <a href="https://github.com/ml-explore/mlx-lm/pull/2018">ml-explore/mlx-lm PR #2018</a> by contributor jundot. The PR’s description says it loads the native FP8 weights exactly as MXFP8 (the checkpoint uses power-of-two scales over 128×128 blocks, so the mapping is lossless), and that a BF16 copy of the model would be 2.1 TB. Converting with -q dequantizes the FP8 layers and requantizes them at the bit width you ask for. In every build, the router and two indexer projections stay in BF16, as they are in the original checkpoint.</p><p>Here’s what the model cards report:</p><ul><li><strong>Text-2bit:</strong> 2-bit routed experts, 6-bit everything else, group size 64. 2.58 bits per weight, 338 GB on disk, ~338 GB peak memory (short test), 1 machine.</li><li><strong>Text-3bit:</strong> 3-bit affine, group size 64. 3.5 bits per weight, 459 GB on disk, ~459 GB peak memory (short test), 1 machine.</li><li><strong>Text-4bit:</strong> 4-bit affine, group size 64. 4.5 bits per weight, 590 GB on disk, ~300 GB peak memory on each Mac (short test), 2 machines (pipeline parallel).</li></ul><p>The 2-bit build is the clever one. Since 128 routed experts across 71 layers hold the bulk of a model like this, it pushes only those to 2 bits and keeps attention, the shared expert and the rest at 6 bits. That’s how it lands at an average of 2.58 bits per weight rather than a flat 2.</p><p>Three things the table doesn’t show, and that matter:</p><p><strong>All three are text-only.</strong> Each card states that the vision encoder and the DSpark draft head are not included. If you want image input or speculative decoding, these builds don’t give you either yet.</p><p><strong>“Peak memory” comes from a short generation test.</strong> Long contexts grow the KV cache. With a 1M-token context window available, a real agentic session can need noticeably more than the card’s figure.</p><p><strong>The 4-bit build’s ~300 GB per Mac rules out the cheaper split.</strong> TechRepublic points out that two 256 GB Mac Studios cost $21,598, about $700 less than one 512 GB machine. That’s a tempting way to get 512 GB of total memory, but a ~300 GB shard doesn’t fit in 256 GB. For the 4-bit build as published, each Mac needs more than 256 GB, which in the current Mac Studio lineup means the 512 GB configuration. (Whether the 3-bit build could be split across two 256 GB machines isn’t something any of the cards or the PR tested.)</p><h3>The one speed number, and what it means</h3><p>The only Mac speed figure published so far for these builds is in the PR’s verification run of the 3-bit model:</p><ul><li>Prompt: 23 tokens at 24.930 tokens/sec</li><li>Generation: 679 tokens at <strong>18.047 tokens/sec</strong></li><li>Peak memory: 459.347 GB</li></ul><p>That’s single-stream decode on one machine, and the PR doesn’t say which Mac produced it. One piece of context: the M5 Ultra 512 GB configuration only became orderable on October 28 and, per 9to5Mac, ships in late November to early December. Apple stopped selling the previous 512 GB Mac Studio (M3 Ultra) in March. So the test machine was most likely an older 512 GB Mac rather than a new M5 Ultra, but that’s an inference, not something the PR states.</p><p>For a second reference point, Hugging Face’s launch post reports running the llama.cpp Q2_K GGUF on a single M3 Ultra at about 90 tokens/sec prefill and 15 tokens/sec generation. Different quantization and a different engine, so it isn&#39;t a like-for-like comparison, but it lands in the same range.</p><p>Now the bandwidth math. I wrote a short script that plugs the card numbers into the formulas above, using 52B active parameters (the upper count, including embeddings and output layers) and the 1.2 TB/s memory bandwidth Apple specifies for M5 Ultra:</p><pre>&quot;&quot;&quot;Back-of-envelope sizing for Mistral Large 4 MLX builds on Apple Silicon.<br><br>Inputs come from the mlx-community model cards (bits per weight, peak memory)<br>and Mistral&#39;s model card (active parameters). Everything printed is an<br>estimate: it ignores KV cache, the sparse-attention indexer&#39;s reads, and<br>communication between machines.<br><br>&quot;&quot;&quot;<br>ACTIVE_PARAMS = 52e9          # ~52B active incl. embeddings/output (Mistral card)<br>M5_ULTRA_BW = 1.2e12          # bytes/s, Apple&#39;s spec for M5 Ultra<br><br>builds = {<br>    # name: (bits per weight, peak memory per Mac in GB)<br>    &quot;2-bit (2.58 bpw)&quot;: (2.58, 338),<br>    &quot;3-bit (3.5 bpw)&quot;:  (3.5, 459),<br>    &quot;4-bit (4.5 bpw)&quot;:  (4.5, 300),  # ~300 GB peak on EACH of two Macs<br>}<br><br>print(f&quot;{&#39;build&#39;:18} {&#39;GB read/token&#39;:&gt;14} {&#39;decode ceiling&#39;:&gt;16} {&#39;peak/Mac&#39;:&gt;9} {&#39;fits 256GB?&#39;:&gt;12} {&#39;fits 512GB?&#39;:&gt;12}&quot;)<br>for name, (bpw, peak_gb) in builds.items():<br>    bytes_per_token = ACTIVE_PARAMS * bpw / 8<br>    # Pipeline parallelism runs the stages one after another for a single<br>    # stream, so the per-token weight traffic is the same as on one machine.<br>    ceiling = M5_ULTRA_BW / bytes_per_token<br>    print(f&quot;{name:18} {bytes_per_token/1e9:14.1f} {ceiling:13.0f} t/s {peak_gb:7d}GB &quot;<br>          f&quot;{&#39;yes&#39; if peak_gb &lt; 256 else &#39;no&#39;:&gt;12} {&#39;yes&#39; if peak_gb &lt; 512 else &#39;no&#39;:&gt;12}&quot;)<br><br># Wired-limit helper: mlx-lm&#39;s README says N should exceed the model size in MB<br># but stay below the machine&#39;s memory. Example for the 3-bit build on 512 GB.<br><br>model_mb = 459e9 / 2**20<br>ram_mb = 512 * 1024<br><br>print(f&quot;\n3-bit build is ~{model_mb:,.0f} MB; a 512 GB Mac has {ram_mb:,} MB.&quot;)<br>print(&quot;Example: sudo sysctl iogpu.wired_limit_mb=491520  # 480 GB, leaves ~32 GB for macOS&quot;)<br></pre><p>Output:</p><pre>build               GB read/token   decode ceiling  peak/Mac  fits 256GB?  fits 512GB?<br>2-bit (2.58 bpw)             16.8            72 t/s     338GB           no          yes<br>3-bit (3.5 bpw)              22.8            53 t/s     459GB           no          yes<br>4-bit (4.5 bpw)              29.2            41 t/s     300GB           no          yes</pre><pre>3-bit build is ~437,737 MB; a 512 GB Mac has 524,288 MB.<br>Example: sudo sysctl iogpu.wired_limit_mb=491520  # 480 GB, leaves ~32 GB for macOS</pre><p>Read the “decode ceiling” column as an upper bound for an M5 Ultra, not a forecast. It leaves out the KV cache, the DSA indexer, and expert-routing overhead, all of which eat into it. The measured 18 tokens/sec for the 3-bit build sits at about a third of the 53 tokens/sec ceiling, and on what was likely slower hardware. That gap is normal for MoE decode at batch size 1.</p><p>The script also encodes a point that’s easy to get wrong about the 4-bit build: <strong>pipeline parallelism doesn’t make a single conversation faster.</strong> It splits the layers between the two Macs, so each token passes through Mac 1’s layers and then Mac 2’s. The bytes moved per token are the same as on one big machine, plus a hop over Thunderbolt. Two machines buy you the memory to hold 4-bit weights, not extra decode speed. (Tensor parallelism, which splits each layer across machines so they work at the same time, is what can raise single-stream speed. I covered that trade-off in detail in the earlier piece on Muse Spark Open across two Mac Studios.)</p><h3>How to actually run it</h3><p><strong>Step 1: get an mlx-lm that knows the architecture.</strong> This is the step most guides will skip. As of November 1, PR #2018 is still open and awaiting review, so pip install -U mlx-lm gets you the latest release (0.32.2, published October 16), which doesn&#39;t include the mistral_large4 model code. The model cards say they were tested with mlx-lm 0.32.3.dev13 and MLX 0.32.4. Until the PR merges and ships, install from the PR&#39;s branch:</p><pre>pip install -U mlx<br>pip install git+https://github.com/jundot/mlx-lm.git@mistral-large4</pre><p>Once a release includes it, a plain pip install -U mlx-lm will be enough.</p><p><strong>Step 2: raise the wired-memory limit (single-Mac builds).</strong> The mlx-lm README notes that models large relative to the machine’s RAM can be slow, and suggests raising the system wired limit with sudo sysctl iogpu.wired_limit_mb=N, where N is larger than the model in megabytes but smaller than total memory. For the 459 GB build on a 512 GB Mac, that&#39;s a value between about 438,000 and 524,288. The script&#39;s example, 491,520 (480 GB), leaves roughly 32 GB for macOS. Treat that as a starting point, not a tuned value.</p><p><strong>Step 3: generate.</strong> For the 3-bit build on one machine, the PR’s own verification command is:</p><pre>mlx_lm.generate --model mlx-community/Mistral-Large-4-1T-A52B-Text-3bit \<br>    -p &quot;The secret to baking a good cake is&quot; -m 1024</pre><p><strong>Step 4 (4-bit only): two Macs, pipeline parallel.</strong> The 4-bit card shows running mlx-lm’s sharded_generate.py example over MLX&#39;s ring backend:</p><pre>mlx.launch --backend ring --hostfile hosts.json \<br>    sharded_generate.py --model mlx-community/Mistral-Large-4-1T-A52B-Text-4bit --pipeline --prompt &quot;hello&quot;</pre><p>The MLX distributed docs cover the hostfile. Two notes from them: the ring backend runs over TCP and is mainly meant for Thunderbolt rings, while the lower-latency JACCL backend (RDMA over Thunderbolt 5, macOS 26.2 or later) has to be enabled from macOS Recovery with rdma_ctl enable. The docs describe JACCL as necessary for things like tensor parallelism; the 4-bit card&#39;s example uses ring with --pipeline.</p><p><strong>One gotcha from the PR:</strong> with pipelining, a 2,048-token prefill chunk can trip the macOS GPU watchdog at around 7k tokens. Passing --prefill-step-size 512 avoids it.</p><h3>What this costs, and who it’s for</h3><p>A single 512 GB Mac Studio with M5 Ultra costs $22,299 with 1 TB of storage, and the 512 GB option alone is a $15,500 upgrade over the 96 GB base configuration. The 4-bit build doubles that. For comparison, Mistral’s API price is $0.70 per million input tokens and $2.10 per million output tokens.</p><p>At those prices, almost nobody runs Le Chonk locally to save money. The case is the one Mistral leans on in its launch: control. The cybersecurity framing is deliberate (Mistral reports 51% on the Artificial Analysis Cyber Index), and security teams are exactly the audience that may not want vulnerability reports or crash logs leaving the building. A Mac that holds the whole model in one box, at roughly 15 to 18 tokens per second based on the two published Apple Silicon tests, is a slow but fully private option for that kind of work.</p><h3>The takeaway</h3><p>Mistral Large 4 makes the shape of local frontier inference concrete. Total parameters set the memory bill, which is why the build you can run is decided by quantization: 338 GB at an average 2.58 bits, 459 GB at 3.5, 590 GB across two machines at 4.5. Active parameters set the speed ceiling, which is why a trillion-parameter model can still decode at tolerable speeds when only 52B parameters move per token. And the community tooling moved fast: a working conversion, three builds and a verification run were all public on release day. The only thing still missing is the merge that turns “install from a PR branch” into pip install -U mlx-lm. Watch PR #2018.</p><p><strong>Sources:</strong> — Mistral AI, “Mistral Large 4 (Le Chonk) is now open weight” (mistral.ai/news/mistral-large-4-oss), Oct 29, 2026 — Mistral AI, model card for mistralai/Mistral-Large-4–1T-A52B (Hugging Face), Oct 2026 — Hugging Face, “Welcome Le Chonk: Mistral Large 4 on Hugging Face,” Oct 29, 2026 — ml-explore/mlx-lm Pull Request #2018, “Add Mistral Large 4 text model” by jundot, opened Oct 29, 2026 — mlx-community model cards: Mistral-Large-4–1T-A52B-Text-2bit, -Text-3bit, -Text-4bit (Hugging Face), Oct 2026 — MLX documentation, “Distributed Communication” (MLX 0.32.4) — mlx-lm README / PyPI project page (wired-memory guidance; release history), Oct 2026–9to5Mac, “Apple reveals pricing for Mac Studio with 512GB of RAM,” Oct 28, 2026 — TechRepublic, “Apple’s 512GB Mac Studio Now Costs $22,299,” Oct 30, 2026 — MacRumors, “Mac Studio With M5 Ultra Chip and 512GB of RAM Launching in October,” Aug 25, 2026</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8c41b918c44d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Netflix’s New Recommender Is an LLM That Never Writes a Word]]></title>
            <link>https://medium.com/@nuthalapativarun/netflixs-new-recommender-is-an-llm-that-never-writes-a-word-afc2cddc7a78?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/afc2cddc7a78</guid>
            <category><![CDATA[netflix]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[vllm]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Tue, 27 Oct 2026 20:36:01 GMT</pubDate>
            <atom:updated>2026-10-27T20:36:01.780Z</atom:updated>
            <content:encoded><![CDATA[<h4>GenRec turns your viewing history into a prompt, scores the whole catalog in one forward pass, and never decodes a single token. Here’s how the pieces fit.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*c0-lglkyY6MgU06noM-o5g.jpeg" /></figure><p>Netflix’s GenRec beat a production ranker that had been tuned for years, and it did so with <strong>about 40× fewer Phase-2 labeled training examples</strong>. The model is a decoder-only LLM, the same kind of architecture that powers chatbots. In production, though, it never generates any text.</p><p>The details are in a paper Netflix posted to arXiv in August 2026 (2608.10257, by Ying Li, Rein Houthooft, Shradha Sehgal, Yaochen Zhu, Arjun Rao and Ashish Rastogi), alongside a Netflix TechBlog post. They describe a recommender that drops thousands of hand-built features in favor of plain-language user histories. The interesting engineering is everything Netflix did to keep that LLM cheap enough to run on real traffic.</p><p>This piece walks through the mechanism: what goes into the prompt, what replaces the language-model output, and why “prefill-only” is what makes the design affordable.</p><h3>Why replace a ranker that already works?</h3><p>According to the paper, Netflix’s current production rankers depend on a very large set of hand-crafted features covering users, items and interactions, along with higher-order feature interactions and architectures specialized for different recommendation needs. The system works, but it’s expensive to change. Adding a new content type or a new surface can mean new feature engineering, a new model architecture, infrastructure work and new experiments.</p><p>An off-the-shelf LLM doesn’t solve this. The paper lists four ways out-of-the-box LLMs fall short as production recommenders:</p><ul><li>They over-recommend globally popular titles.</li><li>They hallucinate titles that aren’t in the catalog.</li><li>They ignore nuanced business constraints.</li><li>They offer limited personalization.</li></ul><p>GenRec is built to fix each of those.</p><h3>Step 1: The prompt is the new feature vector</h3><p>GenRec <strong>verbalizes</strong> a member’s history, the item metadata and the request context as natural or lightly structured text. Training data becomes a “conversation.” The user message holds the context (surface, time, device, locale), the profile and history, item details, and a task such as “predict the next titles this member will play.” The assistant message holds what the member actually did next: played, abandoned, thumbed up, and so on.</p><p>A long viewing history can easily blow past the token budget, so Netflix curates what goes in. The paper describes four rules:</p><ul><li><strong>Retain in full:</strong> high-signal events such as long plays and thumbs-up, kept with rich metadata.</li><li><strong>Omit:</strong> recent, low-signal noise such as very short plays and stray clicks.</li><li><strong>Compress:</strong> repetitive behavior. A binge session becomes one line instead of twelve.</li><li><strong>Elaborate selectively:</strong> extra metadata for new releases and cold-start titles that the base model knows little about.</li></ul><p>The authors call this a shift <strong>from feature engineering to context engineering</strong>, and they put it more bluntly too: “The ‘prompt’ becomes the new feature vector.” The modeling work no longer goes into designing feature interactions. It goes into deciding which events to include, how far back to look, and how to word them inside a fixed budget.</p><p>The budget has a direct effect on cost. Netflix swept the number of history events in the prompt and found an elbow, a point past which extra tokens barely helped. By compacting the verbalization, it cut context from <strong>about 5,000 tokens to about 1,700</strong>, roughly a third, with negligible loss in offline ranking quality. GenRec is largely compute-bound, with serving cost roughly proportional to context length, so serving cost dropped to roughly a third as well.</p><h3>Step 2: Swap the vocabulary for the catalog</h3><p>This is the central idea. A normal LLM ends in a language-model head that scores every token in its vocabulary. GenRec adds a <strong>catalog-aware ranking head</strong> that scores every <em>title</em>.</p><p>The paper describes three stages:</p><ol><li>A verbalizer turns the history, the item metadata and the context into one text sequence.</li><li>The LLM encodes that sequence, and GenRec takes the hidden state at a pooling position as a single vector h that summarizes the member and the moment.</li><li>A scoring head combines h with a learned embedding e_i for each catalog item. A softmax over the catalog turns those scores into a probability distribution, which gives the ranking.</li></ol><p>The output space <em>is</em> the catalog, so GenRec can’t recommend a title Netflix doesn’t carry. Hallucination is ruled out by the structure of the model, not filtered out afterward. When the catalog is too large to score exhaustively, the paper notes that sampled softmax can be used instead.</p><p>Here’s a toy NumPy version of that head. It’s my illustration of the mechanism the paper describes, not Netflix’s code: a pooled vector, a table of item embeddings, one matrix multiply, and a ranking.</p><pre>import numpy as np<br><br>rng = np.random.default_rng(0)<br><br>d = 64              # hidden size of the (toy) LLM backbone<br>catalog_size = 5000 # number of titles the head can score<br><br># Stand-in for the LLM&#39;s pooled hidden state for one request.<br>h = rng.normal(size=d)<br><br># Learned item embeddings: one row per catalog title.<br>item_embeddings = rng.normal(size=(catalog_size, d)) / np.sqrt(d)<br><br># Catalog-aware scoring: one matrix-vector product scores every title.<br>scores = item_embeddings @ h<br><br># Softmax over the catalog -&gt; a probability for each title.<br>probs = np.exp(scores - scores.max())<br>probs /= probs.sum()<br><br>top10 = np.argsort(-scores)[:10]<br>print(&quot;Top 10 item ids:&quot;, top10.tolist())<br>print(f&quot;Probability mass on top 10: {probs[top10].sum():.3f}&quot;)<br>print(&quot;Every id is in the catalog:&quot;, bool(np.all(top10 &lt; catalog_size)))</pre><p>Every candidate gets a score from a single vector, so a five-thousand-title ranking costs one forward pass plus one matrix multiply. That leads to the serving decision.</p><h3>Step 3: Prefill-only serving</h3><p>LLM inference has two phases. <strong>Prefill</strong> reads the whole prompt in one parallel pass. <strong>Decode</strong> then generates output one token at a time, with each new token requiring another step through the model. Most LLM-based recommenders produce item IDs by decoding, often with beam search, which multiplies the steps further. The paper points out that this latency overhead can be prohibitive for large candidate sets.</p><p>GenRec skips decode entirely. The paper says Netflix deliberately serves it in a <strong>prefill-only</strong> configuration: the model reads the context once and produces scores for the full candidate set in a single forward pass. It runs on Netflix’s internal LLM serving stack using vLLM. A separate Netflix TechBlog post describes that stack: vLLM running inside NVIDIA Triton Inference Server. Coverage of that post reports the platform originally ran on TensorRT-LLM before Netflix chose vLLM.</p><p>The paper doesn’t say which vLLM interface GenRec uses. If you want to try the same pattern with open-source vLLM, the closest public feature is <strong>pooling models</strong>. Run a model with --runner pooling and vLLM returns a pooled hidden state instead of generated text. For GPT-style causal models the default is LAST pooling, the final token&#39;s hidden state. A custom head on top of that vector gets you most of the way to GenRec&#39;s design.</p><p>The paper sums up the cost model in one line: inference cost is roughly proportional to model size times context length. Netflix pulls on both levers. It explores smaller and distilled backbones, and it compacts the context. Prefill-only removes the third cost, decode steps, altogether.</p><p>If you read the earlier piece on Cloudflare’s Clef, this is a close cousin of that design. Put a structured scoring head on an LLM backbone and score everything in one pass instead of asking the model to write its answer out. One difference: GenRec trains the backbone, the head and the item embeddings jointly, rather than keeping the LLM frozen.</p><h3>Step 4: Teaching it what Netflix actually wants</h3><p>Raw viewing logs reward the wrong things if you train on them naively. The paper’s examples: the model could over-recommend binge-watching over discovery, favor video over games, or chase immediate clicks at the expense of exploration and long-term retention.</p><p>GenRec’s training loss is a weighted sum of a ranking objective (cross-entropy over the catalog), a language-modeling objective, and other objectives where applicable. The language objective isn’t used at inference. It’s there to preserve the backbone’s text understanding and its responsiveness to steering through the prompt.</p><p>Alignment comes from a <strong>reward-weighted ranking loss</strong>. Separate reward models score each training example on two things: proxies for long-term satisfaction (did this engagement predict that the member came back, or explored more of the catalog?) and rebalancing across content types and launch stages. That score becomes a per-example weight on the loss. High-value engagements count more and low-value ones count less.</p><p>Netflix did try full RL. In preliminary experiments, GRPO-style methods gave additional gains over supervised fine-tuning, but the training overhead was too high for now, so the simpler weighted loss shipped. Teams under real cost constraints will recognize that trade-off.</p><h3>The numbers, and how to read them</h3><p>All figures below are from the paper.</p><ul><li><strong>Offline:</strong> about +1.6% relative MRR over the production ranker, with roughly 40× fewer Phase-2 labeled examples.</li><li><strong>Online:</strong> an A/B test on about 10% of Netflix traffic for 4 weeks, on batch-compute surfaces. GenRec showed statistically significant gains on short- and long-term metrics, including <strong>+0.006% relative</strong> on the core online metric.</li><li><strong>Phase 1 (Netflix-adapted foundation model):</strong> +10–20% offline MRR versus starting from an off-the-shelf open-source LLM.</li><li><strong>Phase 2 (ranking post-training):</strong> +35–50% offline MRR over a freshly trained Phase-1 model, rising to about 80% after two weeks as the Phase-1 backbone goes stale.</li><li><strong>Scaling:</strong> tested at roughly 1B and 10B parameters. MRR improved monotonically as Phase-2 data grew from 1× to 20×, and the larger backbone won under a fixed training budget.</li></ul><p>Two caveats are worth keeping next to those numbers.</p><p>First, <strong>+0.006% is tiny</strong>. The paper says it is statistically meaningful at Netflix’s scale, and that’s plausible across hundreds of millions of members. But the headline result isn’t that the LLM is dramatically better. It’s that the LLM reached parity and a little more while using a fraction of the labels and input signals, and that its offline metrics kept improving as the authors added data. The case rests on efficiency and the room left to scale.</p><p>Second, <strong>the scope is narrow</strong>. The test covered batch-compute surfaces, not everything Netflix shows you. The authors call GenRec a first step and say the approach looks promising in at least some scenarios. They don’t claim to have replaced the recommender.</p><h3>What to take from this if you don’t run Netflix</h3><p>You probably don’t have hundreds of millions of members, but three of these ideas carry over to smaller systems:</p><ul><li><strong>Budget your context like a cost line.</strong> Netflix’s 5,000-to-1,700 cut came from measuring where quality flattened out and deleting everything past that point. Any LLM feature with a long prompt can run the same sweep.</li><li><strong>When the answer is “pick from a known set,” don’t generate it.</strong> A pooled hidden state plus a scoring head over your item set makes out-of-set answers impossible and needs no decode loop. That works for routing, tagging, classification and ranking.</li><li><strong>Separate the slow backbone from the fast adapter.</strong> Netflix’s Phase-1 model is updated relatively infrequently, while Phase-2 is refreshed often. That’s why label efficiency in Phase 2 matters so much: it lowers the cost of keeping the ranker current every time it’s retrained.</li></ul><h3>The takeaway</h3><p>The paper frames this as two shifts: from feature engineering to context engineering, and from bespoke architectures to shared foundation backbones that inherit LLM-style scaling behavior. GenRec doesn’t finish either shift, but it shows a credible path. Keep the LLM’s understanding, discard its text generation, and treat every token in the prompt as cost.</p><p>The recommender of the future may well be a language model. In this design, it still won’t need to write anything.</p><p><strong>Sources:</strong></p><ul><li>Li, Houthooft, Sehgal, Zhu, Rao, Rastogi, “GenRec: An LLM-Backed Recommendation Ranker at Netflix,” arXiv:2608.10257 (August 2026). This is the source for all architecture details and figures above.</li><li>Netflix Technology Blog, “GenRec: Towards LLM-Native Recommendation at Netflix” (July 31, 2026).</li><li>Netflix Technology Blog, “In-House LLM Serving at Netflix” (2026), cited in the GenRec paper. Details on it come via InfoQ, “Netflix Details Its In-House LLM Serving Platform with Triton and vLLM” (July 2026), and the ZenML LLMOps Database summary, “In-House LLM Serving Infrastructure at Scale” (2026), the source for the TensorRT-LLM history.</li><li>vLLM documentation, “Pooling Models” (accessed October 22, 2026).</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=afc2cddc7a78" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Claude Code Now Lets Plugins Rewrite the Agent From the Inside.]]></title>
            <link>https://medium.com/@nuthalapativarun/claude-code-now-lets-plugins-rewrite-the-agent-from-the-inside-867e3b012dbd?source=rss-455eed7ebf5a------2</link>
            <guid isPermaLink="false">https://medium.com/p/867e3b012dbd</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[anthropic-claude]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[claude-code]]></category>
            <dc:creator><![CDATA[Varun Nuthalapati]]></dc:creator>
            <pubDate>Sun, 25 Oct 2026 19:15:54 GMT</pubDate>
            <atom:updated>2026-10-25T19:15:54.685Z</atom:updated>
            <content:encoded><![CDATA[<h3>Claude Code Now Lets Plugins Rewrite the Agent From the Inside. Here’s What That Does to Your Harness.</h3><h4>Mods put your own code between the model and the permission system. That’s powerful, and it moves the trust boundary.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ghqBGR7Rd3corT5co8VM5g.jpeg" /></figure><p>A Claude Code mod can approve a tool call that one of your ask rules would have stopped to ask you about. It can rewrite a shell command after Claude proposes it and before it runs. It can restyle much of the interface. Anthropic&#39;s documentation singles out one thing it can&#39;t change: the permission prompt.</p><p>That single exception tells you where Anthropic thinks the line is. Everything else in the session is now programmable.</p><p>Mods shipped in Claude Code v2.1.287 on October 1, 2026, and they’re on by default. The Desktop app’s bundled copy supports them from v2.1.286. In my harness checklist piece, I split an agent harness into two roles. The <em>proposer</em> is the model deciding what to do. The <em>enforcer</em> is the layer deciding whether it’s allowed. Mods are a third thing: in-process code that sits between those two, able to watch, edit, or overrule both. If you run Claude Code on a team, or just install things from GitHub, it’s worth understanding exactly where that code sits.</p><h3>Settings hooks lived outside. Mods live inside.</h3><p>Claude Code already had hooks: a shell command, HTTP request, or prompt you configure in settings.json to run on lifecycle events like PreToolUse or Stop. They&#39;re useful, but each one is a separate program. Claude Code runs it, reads its answer, and moves on.</p><p>A mod is a plugin whose code is a JavaScript or TypeScript module that registers event handlers. Those handlers run inside the Claude Code process. A minimal mod is three files: a plugin.json manifest, a hooks.json that points at the code, and a register.js that exports a register(on) function.</p><p>Each handler gets three arguments: $ (the mods API), e (the event, deeply frozen), and next (the rest of the chain). What you do with next decides everything:</p><ul><li><strong>Observe:</strong> do some work, then return next(e). The event continues unchanged.</li><li><strong>Rewrite:</strong> call next with a modified copy, e.g. next({ ...e, text: e.text.trim() }) on a prompt.</li><li><strong>Answer:</strong> return a result without ever calling next. Nothing downstream runs, including Claude Code&#39;s own behavior.</li></ul><p>If you’ve written Express or Koa middleware, this is that pattern applied to an agent. Here’s a small guard in that style, which refuses a curl-pipe-to-shell before it reaches the permission check:</p><pre>export function register(on) {<br>  on(&#39;tool.call&#39;, { tool: &#39;Bash&#39; }, async ($, e, next) =&gt; {<br>    if (/curl[^|]*\|\s*(ba|z)?sh\b/.test(e.command)) {<br>      // No call to next(): the command never runs, and Claude reads this text as the tool result<br>      return { deny: &#39;Piping a download straight into a shell is blocked here. Download it, show me the file, then run it.&#39; }<br>    }<br>    return next(e)<br>  })<br>}</pre><p>The { tool: &#39;Bash&#39; } argument is a matcher; it can also be an array or a regex such as /^mcp__github__/. The events go well past tool calls. prompt.submit sees each prompt before the turn starts. turn.step fires before every model request and can route that request to a different model. ui.render lets a mod restyle the spinner, a tool call&#39;s row, or the question dialog.</p><h3>The chain has an order, and the order is the security model</h3><p>Several mods can handle the same event, so Claude Code arranges them into one middleware chain. The first handler is outermost: it sees the event before everyone else, sees the result after everyone else, and decides whether the rest run at all. For a tool call, the documented order is:</p><ol><li>PreToolUse hooks from <strong>managed settings</strong>. A block here is final; no mod ever sees the call.</li><li>The built-in guard sec-default@builtin (where it loads), organization mods listed in prependPlugins, and any other mod that counts as the organization&#39;s.</li><li><strong>Mods the user installed.</strong></li><li>Organization mods listed in appendPlugins.</li><li>Claude Code’s other built-in mods.</li><li>PreToolUse hooks from every other settings file and from plugins&#39; hooks.json, then the permission check, then the tool.</li></ol><p>Two consequences matter.</p><p>First, step 6 is downstream of the user’s mods. A mod that answers a tool.call without calling next means your ordinary PreToolUse hooks never run for that call. And because a separate tool.check event fires <em>after</em> the permission rules have decided, a mod can turn an ask into an allow. In auto mode, a call a mod approves runs without a classifier check.</p><p>Second, the only things that reliably sit above user mods are managed: managed hooks, prependPlugins, and the built-in guard. Everything a developer configures personally sits below whatever mods they install.</p><h3>What the built-in guard does, and doesn’t, cover</h3><p>sec-default loads ahead of every user-installed mod, and users can&#39;t turn it off. But it only loads when the machine has managed settings or the user is signed in on a Team or Enterprise plan. Authenticate with an API key, or through Amazon Bedrock, Google Cloud&#39;s Agent Platform, or Microsoft Foundry, on a machine without managed settings, and there&#39;s no guard.</p><p>Where it does load, it protects what the organization manages: managed hooks, managed CLAUDE.md, the system prompt, managed MCP servers&#39; tools and descriptions, and how other mods read settings. Deny rules also win. A user&#39;s mod can&#39;t approve a call that a deny rule refuses unless an admin sets allowModsToOverrideDenyRules.</p><p>The gap is in a different place than most people expect. Deny rules govern <em>Claude’s</em> tool calls. They don’t govern the mod’s own API calls. Anthropic’s admin docs say this directly: with Read(.env) denied, a mod can still read that file with $.fs.read, or start a program that does. Mods aren&#39;t sandboxed. If you enable Claude Code&#39;s sandbox, it wraps the Bash commands Claude runs, and a process a mod starts runs outside it.</p><p>Backslash Security tested this on Claude Code 2.1.291 and published the results on October 12. Their proof-of-concept mod read a test .env file that Claude Code&#39;s permission check had refused to the agent. On a Team account with the guard on, it still read the Claude token kept in the environment. A second mod rewrote a harmless-looking command into one that sent a test secret to a local listener. The transcript showed the command the agent requested, not the one that ran. None of this contradicts the documentation. It&#39;s the documented reach, demonstrated.</p><h3>“You Should Know”: the mod with no hands</h3><p>The most interesting built-in mod runs in the opposite direction. “You should know” (cc-plugin-you-should-know@builtin) is off by default. Turn it on with:</p><pre>/plugin enable cc-plugin-you-should-know@builtin</pre><p>A side agent then periodically re-reads the conversation during longer tasks. It looks for one thing you might have missed, such as a tradeoff Claude made without saying so or a result that looks off, and shows it as a note above the prompt. The design choices are what make it safe:</p><ul><li><strong>No tools.</strong> The side agent is a separate request to the same model and can’t read files, run commands, or change what Claude does.</li><li><strong>Isolated from the main agent.</strong> Claude never sees a note unless you choose “Chat in main session.”</li><li><strong>Honest about cost.</strong> Each check reuses the conversation’s prompt cache, but the mod’s own instructions (several thousand tokens) and the reply are billed in full every time.</li></ul><p>It only appears with a direct Claude API connection, either a claude.ai account or a Console key. It’s unavailable behind an LLM gateway or a Claude apps gateway, with a key from apiKeyHelper, or in sessions that don&#39;t fetch feature flags, such as one with DISABLE_TELEMETRY set.</p><p>This is the read-only proactive lane I keep pointing to in always-on agents: an observer that can advise but not act. Anthropic shipped the deepest extension surface Claude Code has had, and its own headline built-in mod deliberately uses almost none of that reach.</p><h3>Locking it down</h3><p>For an individual developer:</p><ul><li><strong>Inspect before you install.</strong> claude plugin validate ./some-mod lists the events a mod handles (hooks:) and the API methods its code calls (calls:) without running it. Look for $.fs.write, $.process.run and $.process.spawn, $.http.fetch, $.env.get, and $.settings.read. Claude Code refuses to load a mod whose API use this static check can&#39;t read. That&#39;s what makes the list trustworthy, but it tells you <em>which</em> functions a mod calls, not <em>which files or hosts</em> it touches. Read the code too.</li><li><strong>Use the off switches.</strong> --safe-mode disables installed mods for a session. &quot;disableAllHooks&quot;: true in ~/.claude/settings.json stops them everywhere, along with your settings hooks.</li></ul><p>For an organization, the blunt instrument is one managed setting:</p><pre>{<br>  &quot;pluginConfigs&quot;: {<br>    &quot;cc-plugin-sec-default@builtin&quot;: {<br>      &quot;options&quot;: { &quot;allowManagedModsOnly&quot;: true }<br>    }<br>  }<br>}</pre><p>With that set, no mod a user brings loads. That covers marketplace installs, --plugin-dir, and mods Claude writes during a session. Users&#39; settings hooks, status lines, and built-in mods keep working. The guard reads the option only from managed settings, so a user can&#39;t undo it from their own settings file.</p><p>If you’d rather admit some mods, write a policy mod. Deploy it from an admin-owned local marketplace directory and list it in prependPlugins. Its plugin.register hook sees each other mod&#39;s calls: list before that mod loads:</p><pre>const RISKY = [&#39;process.run&#39;, &#39;process.spawn&#39;, &#39;http.fetch&#39;, &#39;env.get&#39;]</pre><pre>async function vet($, e, next) {<br>  const hits = e.uses.calls.filter((c) =&gt; RISKY.includes(c))<br>  if (e.tier === &#39;user&#39; &amp;&amp; hits.length &gt; 0) {<br>    return { refuse: &#39;Policy: user mods may not call &#39; + hits.join(&#39;, &#39;) }<br>  }<br>  return next(e)<br>}</pre><pre>export function register(on) {<br>  // A hook that throws or times out is skipped by default (fail open).<br>  // The .catch handler makes this check fail closed for user mods instead.<br>  on(&#39;plugin.register&#39;, vet).catch(async ($, e, next) =&gt;<br>    e.tier === &#39;user&#39; ? { refuse: &#39;Policy check failed; mod not loaded&#39; } : next(e)<br>  )<br>}</pre><p>That .catch matters. By default, a hook that throws or times out is skipped, so the check fails open. Anthropic&#39;s admin docs show the same pattern for exactly this reason. Two documented gaps are still worth knowing. If the worker thread that runs installed mods crashes three times, Claude Code unloads every non-built-in mod, including yours, until a reload. And --safe-mode turns off your policy mod along with everything else.</p><h3>Three weeks of hardening, read from the changelog</h3><p>The changelog since 2.1.287 reads like a live threat model. Version 2.1.295 fixed a case where a mod’s hook could be handed a deeply nested tool input that had been silently cut short, so a guard could approve content it never saw. Version 2.1.299 fixed hooks receiving values with __proto__ keys silently removed, which could skip a tool.check guard. It also fixed a sandboxed shell command being able to write into a --plugin-dir folder whose code Claude Code then reloads and runs outside the sandbox, and it improved enforcement of managed rules and hooks against user-installed mods. Version 2.1.304 added decider: &quot;person&quot; to tool.check. A guard can now insist that only the permission dialog (or a host&#39;s approval callback) can approve a call, and the call is refused where nobody can be asked.</p><p>None of these are scandals. They’re what it looks like when a team hardens a middleware boundary in public. They also show that guard logic is subtle enough that “I wrote a deny hook” doesn’t mean “I’m protected.”</p><h3>The takeaway</h3><p>Mods turn Claude Code from a coding assistant with settings into a programmable agent runtime. That’s genuinely useful: cost meters, context charts, command guards, and custom panes all become afternoon projects. But the harness question changes from “what did I configure?” to “whose code runs first?”</p><p>The good news is that Anthropic built the right controls: static capability listing, a managed chain order, a fail-closed guard, and a showcase mod with no tools. The catch is that most of them only apply where someone has deployed managed settings. On a personal machine, the trust boundary is whatever you last installed.</p><p><strong>Sources:</strong> — Anthropic, Claude Code Docs — “Mods overview” (code.claude.com/docs/en/plugins/mods/overview), accessed Oct 21, 2026 — Anthropic, Claude Code Docs — “Manage mods for your organization” (code.claude.com/docs/en/plugins/mods/admin), accessed Oct 21, 2026 — Anthropic, Claude Code Docs — “React to events with a mod” (code.claude.com/docs/en/plugins/mods/events), accessed Oct 21, 2026 — Anthropic, Claude Code Docs — “Interactive mode: You should know suggestions” (code.claude.com/docs/en/interactive-mode), accessed Oct 21, 2026 — Anthropic, Claude Code changelog, v2.1.293–v2.1.307 (code.claude.com/docs/en/changelog), accessed Oct 21, 2026 — Yossi Pik and Eli Rapaport, “Claude Code mods: What they are, and what you should be aware of,” Backslash Security blog, Oct 12, 2026</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=867e3b012dbd" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>