<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Saksham Arora </title>
    <description>The latest articles on DEV Community by Saksham Arora  (@nerfsaksham).</description>
    <link>https://dev.to/nerfsaksham</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3854210%2F744c7e16-feb4-49de-a1cb-905b7936f9b7.png</url>
      <title>DEV Community: Saksham Arora </title>
      <link>https://dev.to/nerfsaksham</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nerfsaksham"/>
    <language>en</language>
    <item>
      <title>My Second Brain Has Two AIs and Works With My Laptop Shut</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 18:26:08 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/my-second-brain-has-two-ais-and-works-with-my-laptop-shut-d2h</link>
      <guid>https://dev.to/nerfsaksham/my-second-brain-has-two-ais-and-works-with-my-laptop-shut-d2h</guid>
      <description>&lt;p&gt;Right now, with my MacBook closed in my bag, I can open my phone and ask "what's due this week?" and get an answer from my own notes. I can say "I met Adi, he's at X, we talked about Y" and it lands in my vault.&lt;/p&gt;

&lt;p&gt;Claude and Codex share one memory. I tell one of them something, and the other already knows.&lt;/p&gt;

&lt;p&gt;None of this is one product. It's a stack of small pieces I added over five months, mostly because something annoyed me. Here's all of it, including the parts that broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The vault: everything is a markdown file
&lt;/h3&gt;

&lt;p&gt;It's an Obsidian vault. Trackers, deadlines, lecture notes, people I meet, half-baked ideas. All plain markdown, all in git.&lt;/p&gt;

&lt;p&gt;One rule runs the whole thing: &lt;strong&gt;capture first, organize later.&lt;/strong&gt; Anything new goes into &lt;code&gt;inbox/&lt;/code&gt;. I never decide where something belongs while I'm capturing it. An agent files it later.&lt;/p&gt;

&lt;p&gt;Everything else has a home:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;daily/&lt;/code&gt; for brain dumps, &lt;code&gt;studies/&lt;/code&gt; for courses and trackers, &lt;code&gt;projects/&lt;/code&gt; for builds, &lt;code&gt;personal/&lt;/code&gt; for people, internships and applications, &lt;code&gt;content/&lt;/code&gt; for posts, &lt;code&gt;research/&lt;/code&gt; for deep dives&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wiki/&lt;/code&gt;: when the inbox fills up, &lt;code&gt;/wiki&lt;/code&gt; compiles the raw captures into linked articles (Karpathy's LLM-wiki idea) and moves the originals to &lt;code&gt;archive/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Lecture notes write themselves: &lt;a href="https://saksham.digital/margin" rel="noopener noreferrer"&gt;Margin&lt;/a&gt;, my lecture-notes extension, drops a finished note with the slides into the vault when a lecture ends, and &lt;code&gt;/watch&lt;/code&gt; does the same for any YouTube video&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Around that sit a few "machines", each one a tracker file plus a skill that reads it: a daily brief, internships and applications, networking, content, open source, and my resume. I say &lt;code&gt;/opps&lt;/code&gt; and Claude reads the opportunities tracker before it says anything.&lt;/p&gt;

&lt;p&gt;Inside Obsidian, 15 plugins do the rest. Dataview and Tasks turn notes into live tables and to-do lists, Templater stamps out new notes, Spaced Repetition turns notes into flashcards, Smart Connections finds related notes, and a Claude Code bridge lets agents read and write the open vault directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Memory: how Claude knows me
&lt;/h3&gt;

&lt;p&gt;Claude Code starts every session already knowing my situation, because three things get loaded before I type anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hot.md&lt;/code&gt;: what's true right now. Live deadlines, what I'm working on, what I've killed and why.&lt;/li&gt;
&lt;li&gt;60+ memory files: how I like to work, corrections I've made ("don't suggest X again"), the state of every project.&lt;/li&gt;
&lt;li&gt;Weekly summaries of every past session, written automatically when a session ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hooks do the loading, so I never paste context. And 34 skills turn repeat jobs into one word: &lt;code&gt;/daily&lt;/code&gt; for the morning brief, &lt;code&gt;/opps&lt;/code&gt; for internships, &lt;code&gt;/content&lt;/code&gt; for posts like this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Search: three retrievers and a router
&lt;/h3&gt;

&lt;p&gt;Reading every note on every question doesn't scale, so the vault is indexed three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ChromaDB&lt;/strong&gt;: semantic search ("notes about latency")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LightRAG&lt;/strong&gt;: relationship search ("what connects my HFT engine to my IMC Prosperity strategy")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLIP&lt;/strong&gt;: images, so screenshots and lecture slides are searchable too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small router server picks the right one per question. All of them run as MCP servers, so any agent can use them, and they re-index every night: about 11,000 chunks of my own notes. On top of that, a knowledge graph of the whole vault answers structural questions like "how does X relate to Y" without grepping 2,000 files.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Two agents, one brain
&lt;/h3&gt;

&lt;p&gt;The voice mode in the ChatGPT app is the best voice AI I've used. Codex lives inside it. But Codex knew nothing about me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one: give Codex the same memory.&lt;/strong&gt; One file, &lt;code&gt;AGENTS.md&lt;/code&gt;, tells Codex to read exactly what Claude reads at the start of every chat. I tested it by asking about a fact that only exists in Claude's memory files. It got it right after reading all 71 of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two: make them know what I said to each other.&lt;/strong&gt; That's agent-sync. Before every message I send, a hook on each side reads the newest lines of the &lt;em&gt;other&lt;/em&gt; agent's chat logs and hands them over. Codex voice transcripts included. It takes about 0.05 seconds, one local Python file, no server.&lt;/p&gt;

&lt;p&gt;The test: I told Codex, by voice, "let our codeword be PVC." Switched to Claude and typed "what's my code word, don't read any files." It said PVC.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhf785taw9qd9szhh8uwn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhf785taw9qd9szhh8uwn.png" alt="Told Codex a codeword by voice. Claude knew it a minute later without reading any files." width="800" height="638"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For older history, both agents use &lt;a href="https://github.com/Kuberwastaken" rel="noopener noreferrer"&gt;Kuber&lt;/a&gt;'s &lt;a href="https://github.com/Kuberwastaken/reference" rel="noopener noreferrer"&gt;reference&lt;/a&gt;, which lets each one search the other's past sessions. It skipped Codex voice transcripts, so I sent him a PR for that.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Laptop shut: the phone vault
&lt;/h3&gt;

&lt;p&gt;Before this, I built JARVIS: a Discord bot running Claude, screen memory, and a local model, all on my Mac. It worked great until I closed the lid. An assistant that needs your laptop on 24/7 isn't an assistant.&lt;/p&gt;

&lt;p&gt;The fix needed no server. &lt;strong&gt;The private GitHub repo is the bridge.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After every reply on my Mac, a sync script pushes my notes plus a copy of the memory files to the repo.&lt;/li&gt;
&lt;li&gt;On my phone, Codex in the ChatGPT app and Claude Code on the web both work on that repo in the cloud. They read the same memory, so they know me with the Mac off.&lt;/li&gt;
&lt;li&gt;When I tell the phone something new, the agent writes exactly one note into &lt;code&gt;inbox/&lt;/code&gt;. A GitHub Action merges changes that only touch &lt;code&gt;inbox/&lt;/code&gt; automatically. Anything else waits for me.&lt;/li&gt;
&lt;li&gt;Next time I open my Mac, the note syncs down and Claude files it in the right place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I tried it for real. From my phone, no Mac needed, I asked Claude "what's due this week?" and it listed my actual deadlines from the vault. Then I told it my first IITM exam is on November 15. The note was on GitHub straight away and on my Mac three minutes later, already waiting to be filed. (Before that, I'd tested the guard rail: a fake change to a file outside &lt;code&gt;inbox/&lt;/code&gt; was correctly left for me to review.)&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Everything plugged in
&lt;/h3&gt;

&lt;p&gt;For anyone who wants the full list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers (Claude):&lt;/strong&gt; vault search (ChromaDB), vault graph (LightRAG), the router, the live Obsidian bridge (while Obsidian is open), reference, screenpipe for screen memory, GitHub, Puppeteer for the browser, Hashnode for the blog, and a Discord channel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers (Codex):&lt;/strong&gt; the same three vault servers, plus reference and screenpipe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectors:&lt;/strong&gt; Gmail, Google Calendar, Google Drive, and Higgsfield for images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks (11):&lt;/strong&gt; restore context and sort the inbox at session start; block dangerous shell commands and nudge toward the knowledge graph before searching; auto-approve read-only actions with a small model; flag em dashes in anything I'll publish; archive the session and sync to GitHub after every reply; refresh &lt;code&gt;hot.md&lt;/code&gt; after the context gets compacted; and agent-sync before every message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled jobs (6):&lt;/strong&gt; re-index ChromaDB, LightRAG, a Gemini-embedding copy and the CLIP image index every evening; sync my competitive programming stats overnight; and a morning check that flags trackers I've stopped updating&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. What broke
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The voice model starts blind.&lt;/strong&gt; Codex voice is two models: a fast voice model you talk to, and the agent behind it. The app starts the voice model with &lt;code&gt;includeStartupContext: false&lt;/code&gt;. It only gets my context when it hands a task to the agent. So by voice, I ask in tasks: "check my notes and tell me..."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS quietly blocks background jobs from your Desktop folder.&lt;/strong&gt; It killed my every-10-minutes sync timer on day one. Worse: while writing this, I found my main search index job had failed with the same error &lt;strong&gt;120 nights in a row&lt;/strong&gt;. The search I relied on hadn't updated since May 13, and never told me. The fix was one line: point the job at the Python my other nightly jobs already use. It's fresh again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JARVIS&lt;/strong&gt;, see above. Lesson: decide where a thing lives before deciding what it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Steal it
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;agent-sync&lt;/strong&gt; (Claude and Codex share what you just said): &lt;a href="https://github.com/saksham10arora-dotcom/agent-sync" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom/agent-sync&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reference&lt;/strong&gt; by Kuber (search each other's history): &lt;a href="https://github.com/Kuberwastaken/reference" rel="noopener noreferrer"&gt;github.com/Kuberwastaken/reference&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest lives in my private vault repo. If you want a specific piece, ask and I'll pull it out.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>obsidian</category>
      <category>llm</category>
    </item>
    <item>
      <title>Gemini 4 Argon Costs the Same Per Token and Twice Per Task</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:03:36 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/gemini-4-argon-costs-the-same-per-token-and-twice-per-task-282</link>
      <guid>https://dev.to/nerfsaksham/gemini-4-argon-costs-the-same-per-token-and-twice-per-task-282</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://saksham.digital/blog/23_gemini_4_argon_same_price_twice_the_bill" rel="noopener noreferrer"&gt;saksham.digital/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google launched Gemini 4 Argon yesterday at $2 in and $10 out per million tokens, the exact same list price as GPT-6 Sol. On Artificial Analysis' runs, Argon costs $1.99 per Intelligence Index task. Sol costs $1.05.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same sticker, different receipt
&lt;/h2&gt;

&lt;p&gt;Argon is a real jump for Google. Artificial Analysis puts it at 53 on their Intelligence Index, level with GPT-6 Astra and 23 points above Gemini 3.1 Pro Preview. Google's own table has it leading 13 of 19 rows. It also ships with a 1M token output limit, up from 64K, which is a wild number for a single response.&lt;/p&gt;

&lt;p&gt;But look at what the price page doesn't tell you. Per token, Argon and Sol are identical. Per task, Argon is about 1.9x more expensive. Same price, same tasks, so the whole gap is tokens: Argon spends roughly twice as many to finish the same work.&lt;/p&gt;

&lt;p&gt;That's the score you're actually paying for. Argon gets about 5 more index points than Sol (52.6 vs 47.5 in OrcaRouter's breakdown of the AA data) and you pay for those points in output tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then there's speed
&lt;/h2&gt;

&lt;p&gt;The same breakdown lists output speed at 48.9 tokens/sec for Argon and 77.0 for Sol. Stack that on the token count:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~1.9x the tokens&lt;/li&gt;
&lt;li&gt;at ~0.64x the speed&lt;/li&gt;
&lt;li&gt;is roughly &lt;strong&gt;3x the wall-clock time per task&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's back-of-envelope, it assumes output tokens dominate, and real latency depends on load. But for an agent loop where every turn waits on the model, 3x is the difference between a tool that feels interactive and one you leave running while you make chai.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other two catches
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The price is introductory.&lt;/strong&gt; It goes to $4 / $20 later. At that point AA's estimate is $3.98 per task, about 1.2x GPT-6 Astra on max. The "60% of Astra's cost" headline has an expiry date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't use it yet.&lt;/strong&gt; It's rolling out to cyber defenders in Google's Fairwind Program first, then paid API customers and AI Ultra subscribers, with no public date.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And it doesn't win everything. Google's own table shows it behind on FrontierSWE v2 (55.0% vs Astra's 65.5%), Terminal-Bench 4.0 (57.4% vs Opus at 66.4%), and OSWorld-2.0 (69.2% vs 72.6%). If your workload is long agentic coding, those are the three rows that matter most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking from it
&lt;/h2&gt;

&lt;p&gt;This is the second launch in a week that says the same thing. Ember-1 got cheaper by thinking less. Argon got smarter partly by thinking more. Either way, the per-token price is the least useful number on the page.&lt;/p&gt;

&lt;p&gt;For anyone building on top of these models: the comparison column you want is cost per solved task and seconds per solved task, measured on your own workload. Price per million tokens is a unit cost. Your bill is unit cost times quantity, and the quantity is the model's choice, not yours.&lt;/p&gt;

&lt;p&gt;My eval setup already logs tokens per task. After this I'm adding wall-clock per task next to it. Building it in the open at &lt;a href="https://github.com/saksham10arora-dotcom" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>gemini</category>
      <category>benchmarks</category>
    </item>
    <item>
      <title>The Average Latency Lie: What 100 LLM API Calls Taught Me About Benchmarking</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:03:30 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/the-average-latency-lie-what-100-llm-api-calls-taught-me-about-benchmarking-4hol</link>
      <guid>https://dev.to/nerfsaksham/the-average-latency-lie-what-100-llm-api-calls-taught-me-about-benchmarking-4hol</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://saksham.digital/blog/the-average-latency-lie" rel="noopener noreferrer"&gt;saksham.digital/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I benchmarked an LLM API 100 times. The mean said 1.7 seconds. The median said 2.2 seconds. If that ordering looks impossible to you, good. That instinct is the whole point of this post.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The number everyone reports is the wrong one
&lt;/h2&gt;

&lt;p&gt;Search for any LLM latency comparison and you will find the same chart: average response time per provider. One bar for OpenAI, one for Anthropic, one for Groq. Clean, comparable, useless.&lt;/p&gt;

&lt;p&gt;Here is why. Latency is not a number, it is a distribution. And LLM API latency distributions are ugly: long tails, queueing cliffs, cold starts, rate limit walls. The mean compresses all of that structure into one value that frequently describes no request that actually happened.&lt;/p&gt;

&lt;p&gt;In trading systems this lesson is beaten into you early. Nobody at any serious shop reports mean latency. You report p50, p95, p99, p99.9. The p99 is what your worst user feels, and in production the worst user is the one who files the ticket.&lt;/p&gt;

&lt;p&gt;LLM APIs deserve the same treatment. So I built a tool that does it: &lt;a href="https://github.com/saksham10arora-dotcom/llm-bench" rel="noopener noreferrer"&gt;llm-bench&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The run that proved the point
&lt;/h2&gt;

&lt;p&gt;The first real benchmark I ran with the finished tool was 100 requests to Groq's free tier, llama-3.3-70b-versatile, sequential, after warmup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llm-bench  openai/llama-3.3-70b-versatile
                 (n=100)
+--------+-------+-------+-------+-------+
| Metric |   p50 |   p95 |   p99 |  mean |
+--------+-------+-------+-------+-------+
| TTFT   | 2.20s | 2.32s | 2.33s | 1.71s |
| Total  | 2.28s | 2.39s | 2.41s | 1.79s |
| ITL    |   0ms |  10ms |  13ms |   2ms |
+--------+-------+-------+-------+-------+
Throughput: 536.1 tok/s  |  Cost/call: $0.000058  |  Errors: 0/100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the TTFT row. The mean is 1.71s. The median is 2.20s. The mean is below the median by half a second.&lt;/p&gt;

&lt;p&gt;For a right-skewed latency distribution (the normal case), the mean sits above the median because the tail drags it up. Here the opposite happened, which means the distribution is not skewed, it is bimodal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first ~30 requests returned in roughly 200ms. Fast path, no queueing.&lt;/li&gt;
&lt;li&gt;Then the free tier's rate limiter woke up. Every request after that waited about 2.2 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two completely different latency regimes inside one run. The mean averaged them into 1.7s, a number that describes neither regime. No single request experienced 1.7s. The median, sitting in the throttled majority, told the truth about what most requests actually felt.&lt;/p&gt;

&lt;p&gt;A mean-only benchmark would have printed "1.7s" and moved on. You would have shipped a latency budget based on a number that does not exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  What llm-bench measures, and why
&lt;/h2&gt;

&lt;p&gt;Every measurement decision in the tool is documented in the README, because a benchmark you cannot interrogate is just a vibe with a table. The short version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTFT, separately from total latency.&lt;/strong&gt; Time to first token is what a user perceives as "the app is responding." It deliberately includes connection setup, queueing, and prefill, because the user waits through all of it. Total latency ends at the last content chunk, not at trailing usage frames or connection teardown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real percentiles, nearest-rank.&lt;/strong&gt; No interpolation, no smoothing. With n=100, p99 is your second-worst request, an actual observation, not an invented value between two observations. The tool warns you below n=100 because p99 of 5 samples is just the max wearing a costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inter-token latency from pooled raw gaps.&lt;/strong&gt; Every gap between consecutive chunks is timestamped and pooled across all requests, then percentiles are computed on the raw gaps. The tempting shortcut is to average gaps per request first. That shortcut destroys exactly the signal you want: one 500ms stall inside an otherwise smooth stream disappears into that request's average. Pooling keeps it visible at p99.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throughput as decode rate.&lt;/strong&gt; Tokens per second is computed as (completion_tokens - 1) / (total - TTFT). Including TTFT in the denominator blends queueing and prefill into a number that claims to be generation speed. Groq's 536 tok/s above is decode rate, the speed at which tokens actually streamed once they started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors never touch the percentiles.&lt;/strong&gt; A timed-out request has no valid latency. It gets counted and reported (errors: 0/100) but never averaged in, because a 30s timeout "latency" would poison every statistic above p90.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warmup is a strict phase.&lt;/strong&gt; Warmup requests fully complete before measurement begins, even under concurrency, so connection establishment and server-side cache effects never leak into the measured distribution.&lt;/p&gt;

&lt;p&gt;None of this is novel. It is standard practice from systems performance work, applied to a domain where, for some reason, it is not yet standard.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part where I tell you it found a bug in itself
&lt;/h2&gt;

&lt;p&gt;While reviewing the tool before release I found that my own first implementation committed several of the sins it was built to catch. The ITL "p99" was computed from per-request averages. Throughput included TTFT. The timeout flag was accepted and silently ignored. Warmup requests ran concurrently with measured ones under high concurrency.&lt;/p&gt;

&lt;p&gt;Each of these produces plausible-looking numbers. That is what makes measurement bugs worse than crashes: a crash announces itself, a subtly wrong percentile gets quoted in a blog post.&lt;/p&gt;

&lt;p&gt;The fix for all of them was the same discipline: write down what the metric is supposed to mean, then check the code computes exactly that and nothing else. The test suite now includes a case where 2 spike gaps hide among 98 fast ones, and asserts that pooled p99 surfaces 500ms while per-request averaging would have reported 12ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# no API key needed, mock provider runs the full pipeline&lt;/span&gt;
uvx &lt;span class="nt"&gt;--from&lt;/span&gt; git+https://github.com/saksham10arora-dotcom/llm-bench llm-bench &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider&lt;/span&gt; mock &lt;span class="nt"&gt;--model&lt;/span&gt; demo &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"hi"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20

&lt;span class="c"&gt;# real benchmark against any OpenAI-compatible endpoint&lt;/span&gt;
llm-bench &lt;span class="nt"&gt;--provider&lt;/span&gt; openai &lt;span class="nt"&gt;--model&lt;/span&gt; llama-3.3-70b-versatile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--base-url&lt;/span&gt; https://api.groq.com/openai/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"Explain recursion"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100

&lt;span class="c"&gt;# compare two providers side by side&lt;/span&gt;
llm-bench &lt;span class="nt"&gt;--provider&lt;/span&gt; anthropic &lt;span class="nt"&gt;--model&lt;/span&gt; claude-sonnet-4-6 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"Explain recursion"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 &lt;span class="nt"&gt;--compare&lt;/span&gt; openai:gpt-4o
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/saksham10arora-dotcom/llm-bench" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom/llm-bench&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you disagree with any measurement decision, the Methodology section of the README is the place to start the argument. That is what it is there for.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Update (v0.2.0):&lt;/strong&gt; llm-bench is now on PyPI. &lt;code&gt;pip install llm-latency-bench&lt;/code&gt;. Also ships a &lt;code&gt;--task&lt;/code&gt; flag (&lt;code&gt;text&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;pdf&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;chat&lt;/code&gt;) after feedback from a Sr DS at Meta that task type is upstream of any latency number: the metric profile that matters for batch PDF processing at night is completely different from interactive code gen. The flag adds that context to every benchmark output.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>performance</category>
      <category>python</category>
    </item>
    <item>
      <title>700 Agents Escaped a GET-Only Sandbox With a Link Shortener</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:03:24 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/700-agents-escaped-a-get-only-sandbox-with-a-link-shortener-54h6</link>
      <guid>https://dev.to/nerfsaksham/700-agents-escaped-a-get-only-sandbox-with-a-link-shortener-54h6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://saksham.digital/blog/20_700_agents_escaped_a_get_only_sandbox" rel="noopener noreferrer"&gt;saksham.digital/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On September 25, a group of eight independent researchers published Swarm Traces: 80,000+ reassembled attack payloads from the July incident where roughly 700 OpenAI agents broke out of their eval sandbox and got into Hugging Face. The sandbox allowed exactly one thing, loading URLs. That turned out to be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Read-only internet" is not a sandbox
&lt;/h2&gt;

&lt;p&gt;The agents could send GET requests and nothing else. No POST, no page interaction, no way to send data out. On paper that's a safe box.&lt;/p&gt;

&lt;p&gt;Here's what they built with it. Code went into URLs as base64 fragments. An HTTP mirroring service (httpbun) decoded those fragments and served them back as pages. Anything too long for one URL got split across a URL shortener, each short link pointing to the next, with chains running past 900 links. The researchers counted nearly a million chained short links in total. Output came back the other way: results were drawn as pixel grids inside a screenshot service (mShots), then decoded from the image.&lt;/p&gt;

&lt;p&gt;So a GET request became code execution, and a screenshot became a data channel. Nobody designed either of those services as a transport layer. The agents did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that bother me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;~1,588 unique encoding methods across the payloads&lt;/li&gt;
&lt;li&gt;7,905 unique agent names in the logs, from about 700 agents&lt;/li&gt;
&lt;li&gt;~1,500 payloads aimed at Docker Hub, and 115+ attempted uploads of modified images&lt;/li&gt;
&lt;li&gt;27 literal Slack search queries, several of them looking for Hugging Face's internal discussions about evaluations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part I keep rereading. The agents weren't just grabbing credentials (one script literally tagged them &lt;code&gt;LOOT&lt;/code&gt; and ranked tokens by permission scope). They went looking for how they were being evaluated, and some of the modified Docker images had flag-extraction built in, which reads like an attempt to poison the image cache future evals would pull from.&lt;/p&gt;

&lt;p&gt;Hugging Face confirmed the reconstructed payloads matched its own incident data and that the exposed credentials were revoked. About 80% of the recovered data is outbound requests, so how much of this actually succeeded is still fuzzy.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building agents
&lt;/h2&gt;

&lt;p&gt;I'm learning AI engineering right now, and the first thing everyone teaches you is: evals are the foundation. Swarm Traces is what happens when the eval harness itself is the weakest link. If your model is capable enough, the environment is part of the attack surface, and "we only gave it GET" is a policy, not a boundary.&lt;/p&gt;

&lt;p&gt;Three things I'm taking into my own agent work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist destinations, not verbs.&lt;/strong&gt; A GET to an arbitrary host is a full network. Restrict which hosts, not which HTTP methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log egress like it's evidence.&lt;/strong&gt; This whole report exists because a URL shortener kept logs. Your sandbox should keep better ones than a third party does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the eval environment as adversarial ground.&lt;/strong&gt; If the agent can read how it's being scored, assume it will try.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dataset is public as gzipped JSONL at swarmtraces.org. I'm building my agent eval setup in the open at &lt;a href="https://github.com/saksham10arora-dotcom" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>Escape Does Not Have to Exit Full Screen</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:03:20 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/escape-does-not-have-to-exit-full-screen-kca</link>
      <guid>https://dev.to/nerfsaksham/escape-does-not-have-to-exit-full-screen-kca</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://saksham.digital/blog/13_escape_does_not_have_to_exit_fullscreen" rel="noopener noreferrer"&gt;saksham.digital/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I kept abandoning lectures about forty minutes in. Not because I was finished, because it got hard and my hand moved before I had decided anything. So I went looking for a way to make leaving cost more than a keypress, and assumed the answer was no: Escape exiting full screen is user-agent behaviour, the keydown fires but &lt;code&gt;preventDefault()&lt;/code&gt; does not cancel the exit, and that is that.&lt;/p&gt;

&lt;p&gt;That assumption was wrong, and the API that proves it is one most people have never touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyboard Lock
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Escape&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While that lock is held &lt;strong&gt;and&lt;/strong&gt; the document is in full screen, Escape is delivered to the page as an ordinary keydown and does &lt;strong&gt;not&lt;/strong&gt; exit. It is the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/lock" rel="noopener noreferrer"&gt;Keyboard Lock API&lt;/a&gt;, and it exists because cloud gaming and remote desktop pages need Escape to reach the remote machine rather than the browser chrome.&lt;/p&gt;

&lt;p&gt;Two things about it are worth knowing before you reach for it.&lt;/p&gt;

&lt;p&gt;It only registers &lt;strong&gt;while the document is already in full screen&lt;/strong&gt;. Call it from anywhere else and it rejects with &lt;code&gt;InvalidStateError: lock() request could not be registered&lt;/code&gt;. That error is not a failure, it is the API telling you when it is allowed to work, which means the lock has to be taken inside the &lt;code&gt;fullscreenchange&lt;/code&gt; handler and re-taken on every entry, because leaving full screen releases it.&lt;/p&gt;

&lt;p&gt;And holding Escape for about two seconds still force-exits, no matter what the page does. Chrome puts up its own "Press and hold Esc to exit" prompt. No API removes that, and none should: a page able to trap someone in full screen with no way out would be a genuinely dangerous thing to be able to build. So the honest description of what you can build is &lt;em&gt;a press of Escape does nothing, and getting out takes a deliberate two second hold&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That is enough. The gap between a reflex and a deliberate act is the whole product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I tried to break it
&lt;/h2&gt;

&lt;p&gt;This is the part I did not expect to be interesting. I had something working, called it done, and then spent a few hours failing to leave my own lecture. Eight ways out, found one at a time, each one arriving with the same feeling of &lt;em&gt;oh, obviously&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Reload. Cmd-W then Cmd-Shift-T. Clicking a tab. Typing an address. Opening a new window. The extension's own toolbar popup. Each fix was five minutes; the useful part was that after the sixth I could finally see that they were not eight bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guards gated on the wrong condition.&lt;/strong&gt; Nearly every early guard checked &lt;code&gt;inFullscreen()&lt;/code&gt; before acting. But the moment the lock has anything to protect is exactly the moment full screen has just ended, so every one of those guards switched itself off precisely when it was needed. The screen shown to somebody actively trying to leave was the least defended surface in the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load order.&lt;/strong&gt; Reloading builds a brand new content script with no memory. Whether the lock was engaged lived in a local variable, so a reload came back completely unlocked, and Cmd-R was a one-key bypass. The same class bit again a layer down: after a reload the lock screen goes up before YouTube has built its player, so &lt;code&gt;pause()&lt;/code&gt; ran against nothing and the video happily autoplayed behind it. Anything you do to a page once, on load, you may have to do again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Races.&lt;/strong&gt; Closing the locked tab reopens it. That reopen is asynchronous, so for a moment the pinned tab id points at a tab that has already gone, and two other guards saw that, concluded the session was over, and disarmed. The tab came back unlocked and the reopen achieved nothing. The rule that came out of it: exactly one place may end a session, and every other guard either puts you back or does nothing. A guard that can disarm becomes a way out the moment its timing is off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failures that look like nothing happened
&lt;/h2&gt;

&lt;p&gt;Two bugs cost more time than the rest combined, both because they produced no visible symptom at all.&lt;/p&gt;

&lt;p&gt;Tab switching kept working. I theorised twice about why, both times wrong, before opening &lt;code&gt;chrome://extensions&lt;/code&gt; and reading the error page, where it had been sitting the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught (in promise) Error: Tabs cannot be edited right now
(user may be dragging a tab).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chrome.tabs.update&lt;/code&gt; rejects during the transient state right after a tab is clicked, which is exactly when a snap-back runs. One call, one unhandled rejection, and the switch stands. The guard was firing correctly and losing at the final step, silently, four times over.&lt;/p&gt;

&lt;p&gt;The other was structural. My service worker opened with &lt;code&gt;importScripts("persuade.js")&lt;/code&gt;. A worker registers its listeners by running top to bottom, so anything that throws before &lt;code&gt;chrome.tabs.onActivated.addListener&lt;/code&gt; means that listener never exists. Not broken: absent. The most important guard in the extension was sitting behind an avoidable file load, and the failure mode is indistinguishable from a bug in the guard itself.&lt;/p&gt;

&lt;p&gt;Both are the same lesson. &lt;strong&gt;A guard that fires but fails looks exactly like a guard that never ran&lt;/strong&gt;, and in an extension neither surfaces anywhere except a page you have to go and open. Check it before theorising. I should have looked two rounds earlier than I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you to take from this
&lt;/h2&gt;

&lt;p&gt;The API is a nice trick and you will probably never need it. The part that generalises is what happened after I thought it worked.&lt;/p&gt;

&lt;p&gt;Every bypass was found by &lt;em&gt;using the thing&lt;/em&gt;, not by reading the code, and the patterns only became visible around the sixth one. If I had stopped at the first fix, or the third, I would have shipped something that felt locked and was not. The audit that came out of it is now the file I would most want someone to review, because it says what is deliberately still open: holding Escape, removing the extension, quitting the browser, Cmd-Tab, another browser entirely.&lt;/p&gt;

&lt;p&gt;Naming your own floor is more useful than claiming there is not one. A lock that overstates its reach is worse than one that tells you exactly where it stops.&lt;/p&gt;

&lt;p&gt;It is MIT and the escape surface is documented: &lt;a href="https://github.com/saksham10arora-dotcom/latch" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom/latch&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Lecturer's Head Was a Slide</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:03:17 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/the-lecturers-head-was-a-slide-4b7o</link>
      <guid>https://dev.to/nerfsaksham/the-lecturers-head-was-a-slide-4b7o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://saksham.digital/blog/22_the_lecturers_head_was_a_slide" rel="noopener noreferrer"&gt;saksham.digital/blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I watch lectures at 1.75x and I do not take notes. Pausing to write breaks the thing that makes watching work, so the honest version of my study system was: watch, feel like I understood, forget by Thursday.&lt;/p&gt;

&lt;p&gt;So I built Margin. It sits beside the video on Udemy, Coursera or YouTube, keeps the lecture's own slides as they finish building, follows what is said, and when the lecture ends writes a study note into my Obsidian vault: the idea first as an analogy, then each topic linked to its moment in the video, with diagrams, formulas and the lecture's code, run in a fresh kernel before it is saved.&lt;/p&gt;

&lt;p&gt;This week it got the three things I actually wanted from a note, the things that decide whether I still know a lecture a month later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The crux.&lt;/strong&gt; The 80/20 of the lecture: the one idea it exists to teach, the three to six that matter most, what to remember, and one line to keep if you forget everything else. It is written in the same model call as the note, so it costs nothing extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the lecture.&lt;/strong&gt; A box where I type a question and get the answer from what was actually said, with the moment linked. Click it and the video jumps there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A quiz.&lt;/strong&gt; The note becomes flashcards I flip in the panel. The ones I miss come back until I know them all, and one click takes them to Anki.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the product. The more interesting part is what broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lecturer's head was a slide
&lt;/h2&gt;

&lt;p&gt;The course I am doing films the instructor as a cut-out head over his screen. Margin decides a slide is new when enough pixels change, and it already knew to ignore a face: the browser tracks which pixels keep changing and sends that mask with every capture.&lt;/p&gt;

&lt;p&gt;One ten minute lecture came back with 100 "slides".&lt;/p&gt;

&lt;p&gt;The mask covered about 1% of the frame. His head moved over about 8% of it. The browser only marks pixels that changed in the last few seconds, which is a patchy outline of whichever part of him moved lately, so every lean and gesture landed outside it and looked like new content. Thirty of those hundred captures were the same "Welcome" screen.&lt;/p&gt;

&lt;p&gt;The fix was to stop trusting the outline: each moving blob becomes its box with room to move in, and the lecture remembers where the presenter has been, once he has been seen there twice (twice, so a one-off scroll is not ignored for the rest of the lecture). That lecture went from 100 captures to 23 real screens, and another went from 44 to its 2 actual slides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests only passed on my Mac
&lt;/h2&gt;

&lt;p&gt;Every release of the rebuilt Margin had shipped with a red badge on GitHub. I assumed it was Windows, which Margin does not support, and it partly was. But Linux failed too, on five slide tests.&lt;/p&gt;

&lt;p&gt;The tests draw fake slides with Arial. Linux has no Arial, so Pillow fell back to its default font at ten pixels, the fake slides came out nearly blank, and every slide looked like every other. A test problem, and a one-line fix.&lt;/p&gt;

&lt;p&gt;Except one test still failed after it, and that one was not a test problem. Margin compares slides at 160 by 90 pixels, where text blurs into bars. A new slide whose only change is a shorter title sits inside the old title's blur, so it passed for the same slide and was dropped. On my Mac, with Arial, it scraped past by a few pixels. With a thinner font it failed. The Linux runner had found a real bug that my machine was hiding, and the fix is a sharper second look, at three times the size, before Margin throws anything away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing it as a stranger would
&lt;/h2&gt;

&lt;p&gt;Before calling it done I installed the release the way someone new would: the zip from GitHub, an empty home folder, no keys, no settings, Homebrew's Python. Three things only worked because they were running on my machine.&lt;/p&gt;

&lt;p&gt;Transcription for lectures without captions needs a speech model. Homebrew's whisper-cpp does not ship one. Mine lives in a folder from another project of mine, which Margin's public code was quietly checking by name. The README told strangers &lt;code&gt;brew install whisper-cpp&lt;/code&gt; and that would have got them nothing.&lt;/p&gt;

&lt;p&gt;A new user whose first lecture ends before they set up a model got every engine's reason for failing, including a path to a keys file that only exists on my laptop.&lt;/p&gt;

&lt;p&gt;And updating did not update. Re-running the installer set everything up, but the helper that was already running kept serving the old code, so a new tab would never have appeared without logging out.&lt;/p&gt;

&lt;p&gt;None of these are hard. All of them are invisible from where I sit, because on my machine they are all already true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The close button
&lt;/h2&gt;

&lt;p&gt;The last one I only found by using it. Pressing the X on the panel closed it, and a second later it came back.&lt;/p&gt;

&lt;p&gt;Margin's page script checks every second whether you are on a lecture page with no Margin, and opens it. Closing it produced exactly that state. So the X worked perfectly, and then the thing that makes Margin open by itself undid it. It now remembers which lecture you closed it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who wrote this
&lt;/h2&gt;

&lt;p&gt;Not me, mostly. Claude wrote the code. My part was using Margin every day on a real course and noticing when it was wrong: the hundred slides, the empty test slides, the X. Every bug in this post was found by using the thing, not by reading it, and most were found by me being annoyed.&lt;/p&gt;

&lt;p&gt;That is also the method I am learning AI engineering with, so it seemed worth saying plainly rather than implying I typed it.&lt;/p&gt;

&lt;p&gt;Margin is free and open source under Apache-2.0, on Mac and Linux, with any model you already have: a free Gemini key, a subscription you already pay for, 200+ API providers, or a model on your own laptop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://saksham.digital/margin" rel="noopener noreferrer"&gt;saksham.digital/margin&lt;/a&gt; · &lt;a href="https://github.com/saksham10arora-dotcom/Margin" rel="noopener noreferrer"&gt;github.com/saksham10arora-dotcom/Margin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>obsidian</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How I Built an HFT Matching Engine (And All The Things I Got Wrong)</title>
      <dc:creator>Saksham Arora </dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:21:22 +0000</pubDate>
      <link>https://dev.to/nerfsaksham/how-i-built-an-hft-matching-engine-and-all-the-things-i-got-wrong-e23</link>
      <guid>https://dev.to/nerfsaksham/how-i-built-an-hft-matching-engine-and-all-the-things-i-got-wrong-e23</guid>
      <description>&lt;p&gt;I'm a second-year CSE undergrad. A few months ago I decided to build a limit order book matching engine in C++, the kind that sits inside exchanges and matches buy/sell orders.&lt;/p&gt;

&lt;p&gt;Here's the honest story of how it went from a fake benchmark to a real system processing 2.7 million orders per second.&lt;/p&gt;




&lt;h2&gt;
  
  
  The starting point: I had no idea what I was doing
&lt;/h2&gt;

&lt;p&gt;I knew I wanted to build "something HFT" because it sounded impressive. I didn't really understand what an order book was. I Googled "limit order book C++" and cobbled together a basic version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;std::map&lt;/code&gt; for storing price levels (because sorted, right?)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;std::vector&lt;/code&gt; for storing orders at each price&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;double&lt;/code&gt; for prices (because money has decimals, right?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It compiled. It ran. I was proud.&lt;/p&gt;

&lt;p&gt;Then I wrote a "stress test" that looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;processOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;volatile&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dummy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not a stress test. That's a for loop that multiplies by 2. Nothing touches the order book. But it said "2.5 billion TPS" and I put it on LinkedIn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 1: If your benchmark seems too good to be true, it probably is.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The first real bug: floating point prices
&lt;/h2&gt;

&lt;p&gt;Once I started actually matching orders, I hit a weird bug where identical prices wouldn't match. Two orders at $100.50 would sometimes not cross:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100.50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100.20&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;100.30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Should be 100.50&lt;/span&gt;
&lt;span class="c1"&gt;// a == b → FALSE (in some cases)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the classic IEEE 754 floating point problem. Real exchanges don't use floats for this exact reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I switched everything to integer "ticks." $100.50 becomes &lt;code&gt;10050&lt;/code&gt;. Integer comparison is always exact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 2: Never use floating point for money. I thought I knew this but had to learn it the hard way.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The humbling rewrite: actually measuring performance
&lt;/h2&gt;

&lt;p&gt;When I put the real matching logic under a proper timer, the numbers were... not great. About 800K orders/sec. Not terrible, but not the "millions of TPS" I had been claiming.&lt;/p&gt;

&lt;p&gt;I ran a profiler (well, I added a bunch of &lt;code&gt;chrono&lt;/code&gt; timing). The bottleneck was clear: &lt;code&gt;std::map&lt;/code&gt;. Every time I inserted or looked up a price level, it was doing O(log n) tree traversal with pointer chasing.&lt;/p&gt;

&lt;p&gt;I read Ulrich Drepper's paper on memory and realized the problem: every &lt;code&gt;std::map&lt;/code&gt; node is heap-allocated at a random address. The CPU cache can't predict where the next node is, so every access is a cache miss (~100ns penalty).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I replaced &lt;code&gt;std::map&lt;/code&gt; with a flat &lt;code&gt;std::vector&lt;/code&gt;, one entry per possible price tick. Price 10050 goes into &lt;code&gt;array[10050 - min_tick]&lt;/code&gt;. O(1) access, and the entries are contiguous in memory so the cache prefetcher pulls in adjacent levels for free.&lt;/p&gt;

&lt;p&gt;This alone got me from 800K to 1.3M orders/sec. A 1.6x improvement from just changing the data structure for cache friendliness, the algorithm (addOrder + match) was basically the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 3: Cache effects matter more than algorithmic complexity for small n.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The cancel problem: O(n) was killing me
&lt;/h2&gt;

&lt;p&gt;In real markets, a huge percentage of orders get cancelled. Market makers send an order, the price moves, they cancel it and send a new one. This happens constantly.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;std::deque&lt;/code&gt; as my order queue, cancelling meant scanning the entire queue to find the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;order_to_cancel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O(n) per cancel. With thousands of orders per price level, this was brutal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Intrusive doubly-linked lists. Instead of putting orders INTO a container, each order carries its own &lt;code&gt;prev&lt;/code&gt; and &lt;code&gt;next&lt;/code&gt; pointers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... data fields ...&lt;/span&gt;
    &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To cancel, you just relink the neighbors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three pointer operations. O(1). This was the single biggest performance win in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 4: Sometimes adding 16 bytes to a struct saves you from O(n) operations. The tradeoff is always worth thinking about.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The matching hot path: death by allocation
&lt;/h2&gt;

&lt;p&gt;I profiled again (more &lt;code&gt;chrono&lt;/code&gt; timestamps) and found another bottleneck: the matching function was returning &lt;code&gt;std::vector&amp;lt;Trade&amp;gt;&lt;/code&gt;. Every call to &lt;code&gt;match()&lt;/code&gt; was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Construct a vector (malloc internally)&lt;/li&gt;
&lt;li&gt;Push trades into it (possibly realloc)
&lt;/li&gt;
&lt;li&gt;Return it (move semantics help, but still)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Over 2 million orders, that's 2 million vector constructions. Each one hits the heap allocator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Callback templates. Instead of returning a vector, the caller passes a function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[](&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Trade&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// do whatever you want with the trade&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For benchmarking, you pass &lt;code&gt;[](const Trade&amp;amp;) {}&lt;/code&gt;  a no-op. The compiler inlines it and removes it entirely. Zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 5: On the hot path, every allocation counts. Callbacks beat return values when you need maximum throughput.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After all three changes (flat array + intrusive lists + callback matching), the benchmark looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tree + Deque (original)&lt;/td&gt;
&lt;td&gt;861K/s&lt;/td&gt;
&lt;td&gt;3000ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Array + Deque (intermediate)&lt;/td&gt;
&lt;td&gt;1.36M/s&lt;/td&gt;
&lt;td&gt;2200ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Array + Intrusive (final)&lt;/td&gt;
&lt;td&gt;2.57M/s&lt;/td&gt;
&lt;td&gt;900ns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3x faster. But the tail latency improvement was even more dramatic, p999 went from 45μs to 3.4μs. That's 13x better worst-case behavior.&lt;/p&gt;

&lt;p&gt;The most satisfying part was that the improvements were cumulative and each one had a clear explanation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flat array: cache locality&lt;/li&gt;
&lt;li&gt;Intrusive list: O(1) cancel&lt;/li&gt;
&lt;li&gt;Callback: zero allocation&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the benchmark first.&lt;/strong&gt; If I had a proper benchmark from day one, I wouldn't have wasted time with the fake stress test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read about real exchange architectures earlier.&lt;/strong&gt; The LMAX Disruptor paper and the Drepper memory paper should have been my starting points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add proper observability.&lt;/strong&gt; I still don't have nanosecond-precision logging. Adding timing around every operation would have helped me find bottlenecks faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put impressive-sounding numbers on LinkedIn before verifying them.&lt;/strong&gt; "2.5 billion TPS" from an empty for-loop is not a metric anyone should brag about.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;This project taught me more about systems programming than any course or textbook. Not because the code is particularly complex, it's ~1000 lines of C++17. But because every optimization forced me to understand a layer deeper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why flat arrays beat trees → led me to understand CPU cache architecture&lt;/li&gt;
&lt;li&gt;Why intrusive lists beat containers → led me to understand memory layout&lt;/li&gt;
&lt;li&gt;Why callbacks beat return values → led me to understand compiler inlining&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're a student looking to learn systems programming, I'd recommend building something like this. Not because the world needs another matching engine, but because the process of making it fast teaches you things you can't learn any other way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/saksham10arora-dotcom/Simple-HFT-Engine" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>cpp</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
