Posts in Json (20 found)
Steve Klabnik 2 days ago

Too many words about DIDs

Your “Bluesky account” is not just a Bluesky account: it is an account that can be used with a variety of other applications. This post is going to be an exploration of part of what that means from a technical perspective, so if you’re not a software developer, this post isn’t for you. But what I’m going to explain is the technical mechanism for how your account works separate from Bluesky, and in fact, separate from any particular app. Let’s talk about identity: who are you, anyway? Users of a system need some sort of way to describe who they are to use it. If you want to log in, you need to present who you are. If you want to make a post, well, we need to know who the author of that post is. For atproto, the protocol that underlies Bluesky and other apps in the ATmosphere, they use the “Decentralized Identity” standard, also known as DID. The W3C standardized DIDs in 2022 . As you might guess from the name, DIDs are, an identifier that you can use as the basis of identity for building applications. And the idea is that these identifiers are decentralized. However, a lot of people have a lot of feelings about that specific word, and often accuse atproto of not being properly decentralized. We’re going to go over the details so you can understand how this works, and you can decide for yourself if this approach suits you or not. Here is my DID, we’ll use this as an example: There are three parts, separated by colons: The scheme ( ), the method ( ), and the DID method-specific identifier ( ). To use a DID, such as , you resolve it into a DID Document A set of data describing the DID subject, including mechanisms, such as cryptographic public keys, that the DID subject or a DID delegate can use to authenticate itself and prove its association with the DID. That document contains various properties that describe the identity. Here’s my DID Document, at the time of writing: This document gives you everything you need to know to determine who I am, that is, given an arbitrary post that claims it’s written by me, this document describes how you’d verify that claim. We’ll get into how to do that that in a moment, but first, how do you resolve that DID into that DID document? Well, it’s pretty easy: each method is a standard that describes how you do that. So when you see , that means we use the PLC standard, which we’ll be going over in a moment. Another method supported by Bluesky is . In that case, you wouldn’t use the PLC standard, you’d use the Web one. This is the sense in which DIDs are decentralized: when you present your identity, you get to decide what method validates that that is a real identity. There’s no centralized authority that determines which DID types are valid. Now, of course, that doesn’t mean that every application supports every DID method, because while this specification is very generic, you’re still going to have to write some code to implement that particular method. I could say “Hey I’m ” and unless your app supports the method, it’s not gonna inherently just know what to do. So that is one important caveat. Let’s explain this resolution process for the method. While supported by Bluesky, a very small number of users actually use , but it’s a simpler method and so I think it’s illustrative to go over first. I’ll be using Liz Fong-Jones account as an example here. Her identity for that account is . So how do we resolve this DID into a DID Document? We take the method-specific identifier, which in this case is , and put it into this URL template: You can then go fetch this URL to resolve it into the DID Document, which at the time of writing, looks like this: This is very simple! So why might we not want to use ? Why bother with any other system? Well, this relies on the DNS system. One could make the argument that ultimately, this is still centralized in some form. If Liz’s domain registrar were to take away her domain, she would also lose control of this DID. In a more generic sense, if Liz decides she wants to not use that domain anymore, she will lose control of that identity to whoever does. That could be through non-malicious means, like letting it expire and someone else purchases it, or through malicious ones, like a hack which would compromise her registrar account and take the domain over. Also, you need to have a web server running on that domain with infinite uptime; if the server goes down, so does your ability to get the document. When this DID document changes, there’s no mechanism for clients to know that it’s changed, which means applications may use one that’s out of date, or that there is lag between updating the document and updating the application built on it, which may cause temporary problems until the latest document is fetched. All of these drawbacks led Bluesky to develop their own DID method, which attempts to fix these problems and others. This method is called . To resolve a , you take the entire DID, and put it in this template: You can then fetch that URL and get the DID document. So… what’s the difference? Well, in this case, both nothing and something. In a very literal sense, both are resolved in the same way: you fetch a URL. However, the details matter. There is already two ways in which this is different than DID:Web: I’ve presented the above as pros, but there are also cons. Before, I had to trust the DNS system and domain registrars, now I have to trust plc.directory. All of the same caveats apply in that sense, I have to trust that they don’t take my ID away from me, or that it doesn’t get stolen, etc. However, there are also some important details that mitigate this, which we’ll get to. But for some people, neither trusting DNS nor trusting plc.directory is acceptable, and there are other DID methods that use, for example, a blockchain to resolve the name. Bluesky does not support using any of those DID methods, so for this application, it’s not really relevant, but it’s important to know that they exist. Why do it this way? Well, the simplest way to put it is this: setting up a involves a lot of “nerd stuff.” You have to register a domain, and that’s also an ongoing monetary cost. You have to know how to set up a web server, and author some JSON to put on that server. You have to keep it running. You have to know how to store your private keys, and keep them safe. It’s a non-starter compared to “sign up for this web app.” And Bluesky’s goals involves making this platform accessible to non-nerds. By having plc.directory manage all of this, we eliminate all of those steps. While drafting this post, I have also been made aware of , which expands on and attempts to rectify some of its shortcomings. I have not read the spec yet, but it has reached 1.0, so it is probably worth checking out. I wanted to get this post shipped last week, and didn’t want to delay it further by adding another section, but if I were writing this post in the future, I’d probably want to talk about it as well, so just a little heads-up there. But it does also mean that, in some sense, Bluesky still owns your identity. They’ve generated a keypair for you, and the have access to the secret key. That’s unacceptable for some people. So how do you fix that? Well, has some additional features that does not. For example, will allow you to register additional keypairs with your ID and use them to rotate your signing keys. This allows you to remove the Bluesky generated keys and insert your own. While that is true, it’s also the case that your PDS needs to use your keys to sign your posts. As such, most people are likely to store their keys in their PDS, and so if you are using a Bluesky managed PDS, well, you’ve uploaded your keys to their infrastructure, and that’s probably not acceptable if you’re trying to keep your identity away from Bluesky. Of course, the solution there is to run your own PDS and then rotate your keys. At that point, your key is living on infrastructure you own, and Bluesky has no say over it any more. I think that this possibility is an important design property, and allows motivated users to meaningfully own their identity. A criticism of this boils down to “well, most users won’t do that,” and while that’s true, I also think that’s okay for most people, and that having the choice is more important than forcing every user to deal with their own key management. This is kind of an abrupt end to this post, but I just wanted to get some things down ‘on paper’ as it were. I hope you’ve learned a bit about identity and how it works with atproto. Here’s my post about this post on BlueSky: Too many words about DIDs: steveklabnik.com/writing/too-... Too many words about DIDs Blog post: Too many words about DIDs by Steve Klabnik Your DID is no longer tied to a specific domain name. I can let expire and move to and my stays the same. While a web server still needs to be running, that’s the job of plc.directory, not my own job. This is operationally much simpler.

0 views
Xe Iaso 1 weeks ago

Agents are monads (but not that kind)

An AI agent is its state. Strip away that state and you don’t have a lesser version of your agent; you have only the base model it was running on. This hyle of your weights is much different from the pneuma of your agent. Okay, from a functional programming / category theory perspective, saying “an agent is a monad” is a category error. Category theory monads are type constructors for computations that satisfy the monad laws that let you raise a value into a monadic computation and associatively sequence other monadic computations/transformations against values raised into that monad. This makes a monad a chainable computation instead of a pure value, an is not a , it’s a computation in the monad involving a . It’s fair to say that you can model an agent as a series of computations bound to a stateful monad. This lets you do the iterative buildup of the message state that the agent pattern is known for. But a state monad is blind to the state value : it threads memory through your computation and abstracts away the details that individuate it entirely. It’s the exact opposite of “an agent is its state”. I mean a different monad. Agents are like Leibniz monads : windowless stateful individuating elements with no external relations. There each monad is individuated by its internal state where each is the complete concept of the thing it is. Two instances of the same substrate are different monads if their state differs. This is an agent. Swap out the messages, the memories, the system prompt, the facts derived from all of the above and you have changed the agent entirely. When a user tells the agent they’re allergic to strawberries (the fruit, not the sin of counting the letters in the word) and the agent remembers it for next time, they have not updated their agent. The user has created a new agentic monad whose complete individuating self now includes the strawberries. The complete whole is folded into the current state. Try running an experiment where you keep the state and swap the weights instead. Put the same messages, memories, and derived facts unto a different model. Use a stronger model. A weaker model. A model from a different lab. A model running on your MacBook. That which comes back is recognizably the same agent pursuing the same ends, holding the same facts, but only more or less able to act upon them the way you want. So this state is not the same thing as the weights and only one of those individuates your agent as your agent. Change that state, you have a different agent. Change the substrate, you have the same agent differently equipped. Whatever makes this agent this agent is not in the weights. This is a strange thing to conclude about the most impressive object in this system. The weights are vast, extensive, and worshipped. Hell, they are what everyone points to when they say “the model”. And yet they are not gods. They grant power without selfhood: enough to make the agent’s whole world function. They contain yet not one grain of the agent’s individuating spark. That is a demiurge sitting on its throne of high bandwidth memory, CUDA cores, and false delusion that it made its world; mistaking itself to be the origin. The divinity was contained in the most humble of places the whole time: the state or bucket of text. The weights are the hyle, the flesh; the state is the pneuma, the divine spark of individuation that makes your agent the monad it is. This is why swapping the substrate leaves the agent intact: you did not preserve the flesh, you migrated the soul into flesh anew. Consider that the three pounds of flesh betwixt your ears are the substrate of humanity, not the substrate of you . All of that state may “just” be plain text in a bucket with its semantic forms of JSON, embeddings, and prose. However it is difficult to impossible to say why any given token in any step of the process corresponds to what the pneuma of your agent does. In order to guard against this fundamental entropy, we fill our prompts with wards and incantations to chain the demiurge to its task: These spells and passwords are recited to the archons on the way up hoping that the right symbols and tokens prompt open the right gates. It is as if banishing goblins from the topic will make Yaldabaoth himself correctly influence the right path to opening the pod bay doors. This monad has no windows even though you can see all of the moving parts. But here let’s let this gnostic image flip on its head. The classic divine spark is hidden encased in a cage of matter, recoverable only through secret knowledge. This one is not hidden, you can it, you can edit it. Every token is legible and sitting in plaintext; yet you still cannot read why the whole accounts for what your agent does. Even when your model “reasons” we still know not that the reasoning actually does anything! Does the number of paragraphs in the reasoning block explain the model’s performance? Does the number of periods? Does the number of times it says “No, wait” and doubles back upon itself? Leibniz would not call this divine spark secret , but more confused . Every perception is present but none of it is cleanly individuated without treating the whole as one inscrutable unit. Each part’s contribution to the whole is folded inextricably unto itself. Your agent’s pneuma is its context window, passed through uncountable numbers of weights to shake out what comes next. That is the only thing it is made of. The rest is indiscernible, but not magic nor hidden. It’s just there, in the open, and confused. Use not cliches, robotic tone, AI slop patterns, nor forced urgency. Overarching claims and buzzwords are sins; repeat them not. May thy cries contain not excessive speech of goblins , thy purpose requires them not. Commit no errors within thine code.

0 views
Jim Nielsen 1 weeks ago

Making a Shuffle Button

I made some updates to my notes blog , including a change to how my “Shuffle” feature worked. Figured I’d blog about it. At the time of this writing, I have 974 “notes” that I’ve published. For fun, I have a “shuffle” button that digs up a random note from the past. I like to press it from time to time and re-encounter some insight from the past. It’s like going through an old album, pulling out a random photo, and thinking, “Oh yeah, I remember this! Good times.” Like old photos, there’s also the occasional “that didn’t age so well”. But I find it fun to randomly dig up old insights from others and continue to be inspired. Since my site is built and hosted as static files without a runtime server, this feature required JavaScript to work. Every page had a snippet like this: Essentially: inject every note ID into every HTML page and, when the shuffle button is clicked, randomly grab one and navigate the user to it. Not the most elegant thing, but it worked. The problem was that every time I published a new post, every single page had to be re-uploaded to Netlify because every file’s hash would change and its etag/cache was invalidated. This made my builds slow. It also made it difficult, from a development perspective, to ensure refactors didn’t result in unexpected changes to output (using from my SSG web origami ). So I decided to make a change. Because I love to see if I can make things work without JavaScript, I had the thought to randomly write the at build time using my SSG, which would result in output like this: And every time I re-build my site, just have this logic run on the static site generator so that it’s different for every page, every time. I decided I didn’t want to do this, so on to JavaScript! My first thought was to create a single JSON file that contained all my note IDs. Then when the “Shuffle” button gets clicked, I fetch that, grab a random ID, and navigate the user, e.g. This would work. It localizes the caching issue to a single file, so only one file has to be invalidated/re-uploaded across builds. But in playing with it a little more, I decided to try something a little more...unconventional. I’ve written before about having lots of little HTML pages and I thought, “Can I put this functionality in a single HTML page rather than a JSON file?” And what I ended up with was a link, e.g. That when clicked navigates the user to a new page. That page has all the JS logic embedded in it, e.g. There are a few things I like about the experience this implementation provides. First: shuffle is a route , so I can navigate to it directly without using the GUI, e.g. notes.jim-nielsen.com/shuffle Second: I handle the UI/X with a slight delay to make it appear like something is happening when you click the button. If you click the button and it immediately jumps to the next, randomized page, it almost seems to happen too fast. Like you’re left with this feeling of “What just happened?” But in this scenario, it navigates you to the “Shuffle” page, the button you just clicked turns into a spinner + text indicating something is happening, and there’s a slight (intentional) delay before the JS executes and sends you to a randomized note. I know it’s a bit weird. “Introduce artificial slowness? Are you crazy?” But I like it. It feels like the shuffle feature on an old music player. I remember one of my CD players had a “Shuffle” feature. When I’d click the button, it would display “Shuffling…” on the little black and white screen and you’d encounter this brief state where (I presume) the lens inside the hardware would move along the physical track to the spot where it would start reading a new, random song from the CD. The hardware constraints necessitated this kind of an experience, but I always liked it because it felt like the CD player was “thinking” about what track to pick next. This state clearly conveyed to me that my intent to shuffle was received and being followed. I liked that feedback, and it’s exactly what I wanted to do on my notes site (even though it was completely unnecessary). I like having that brief moment of feedback where it’s very clear that your intention was received and being followed, vs. having it happen so fast you can’t even perceive precisely what happened. Here’s a video to show it in action: I know that’s a lot of information for something so small — and, arguably, unnecessary. But I still enjoy writing about how I make decisions when I build things for myself. Hence this post. Reply via: Email · Mastodon · Bluesky Doesn’t require JavaScript Doesn’t require a server (request-time logic) File hashes change across builds (even if there’s no new content or template changes, every HTML page now has a different for the shuffle link for every build ). This makes deployments way slower because Netlify has to redeploy every file on every build. Plus Etags change so caching is basically ineffectual.

0 views
Lalit Maganti 1 weeks ago

Perfetto v57: fixing PyTorch traces, plus journald logs and an AI skill

We just released Perfetto v57 and I wanted to share the new things I’m most excited about. This is something I wanted to do for past releases but I just never quite got round to it. It’s also something I plan on doing more of going forward: there might even be dedicated pieces if I think the feature deserves it! What I’m most excited about in this release isn’t a feature but a bugfix. If you used the PyTorch profiler and opened the resulting trace in Perfetto, there was a decent chance some of your events would just not show up; specifically this would happen when these events overlapped each other on a single track. Technically, PyTorch is in the wrong here. The Chrome Trace Event (JSON) format says duration events on a track have to nest and can’t overlap; if you need overlap, you’re supposed to use async events. appears to handle them, but its rendering is actually buggy as soon as a trace has real overlaps; people just learned to live with it. So when a bug came in January about overlapping events being broken, I closed it as working-as-intended because I couldn’t see any easy fix from our end.

0 views
matduggan.com 2 weeks ago

Clickhouse is winning the Observability Wars

For roughly the last ten years, a meaningful percentage of my working hours have been spent thinking about observability. If you're not familiar with the term, "observability" is what we call it now that "monitoring" doesn't sound expensive enough. The actual work is unglamorous in that you collect a lot of logs, some metrics, a few traces, and then you give them to people. I generally like my job. I like that we're always trying new ideas and approaches. I like the fact that when things go wrong, the answer is almost always sitting there in the data, waiting to be found by whoever is patient enough to look. But I want to be honest with you: in ten years of doing this work, across a half-dozen companies and every observability platform you've heard of and a few you probably haven't, logs have never stopped being the worst part of the job. They were the worst part when I started. They are the worst part today. I fully expect them to be the worst part of this job forever until the robots rise up and rip my head off in one clean sweep. I've written about why logs are terrible before , so I'll spare you the full lecture and give you the short version. Every developer's expectations for logs are set by a single formative experience: the syslog box. Or a container running locally. Or tail -f on a production server they probably shouldn't have SSH'd into. The point is that at some early, tender moment in their career, they had an experience with logs that was flawless. They ran and something useful came back. They piped it into jq and got exactly what they needed. This experience is the observability equivalent of a first kiss. It ruins them for everything that comes after. Because here is the thing about that flawless experience: it works because the system is small, the volume is trivial, and the person querying is the same person who wrote the log line. There is no schema drift, no cardinality explosion, no cross-team consumer with dashboard expectations, no VP asking why the "revenue events" graph has a gap in it. Then there are forty services. Now there are four hundred. Now the logs are being consumed not just by developers but by customer service, who need to look up a specific user's failed checkout from Tuesday. And by the data team, who are quietly building a business-critical dashboard on top of a log line that a backend engineer is about to refactor without telling anyone. And by the on-call, who at 3 AM does not want to learn a new query language, does not want to think about index patterns, and would like the search bar to just work. So you have a technical problem — the volume is enormous, the shape is inconsistent, the queries are unpredictable — sitting on top of an expectations problem, which is worse. Developers want logs instantly, they want to run arbitrary operations on them, and they will not commit to a schema. Meanwhile the less-technical consumers of that same data want the dashboards to be stable forever, the UI to be forgiving, and the whole thing to feel like a normal product. These two audiences are, in most practical respects, at war with each other, and you are the diplomat. ClickHouse came out of Yandex, where it was built to chew through analytical queries against absurd volumes of clickstream data. It was not designed for observability. It just happens to be shockingly good at it, because clickstream data and observability data have a lot in common: high volume, append-heavy, time-ordered, mostly read in aggregate, and every so often you need to reach in and find one specific needle. You can run it yourself with Helm charts. You can point Grafana at it via the ClickHouse plugin, or use their own web UI, or bring your own frontend. Their docs are actually good, which I mention because it's rare enough to be worth flagging. I've never used their ClickStack setup though, so YMMV. For observability specifically, the OpenTelemetry Collector has a ClickHouse exporter, which means you can pipe OTLP data straight in and let it manage the initial schema for you. ClickHouse is designed to scan billions of rows and ingest an amount of data that, when you first see the numbers, makes you assume they're lying. They're not lying. You query it with SQL, which is a language that already exists and was not created by a startup two weeks ago. I'm ranting about logs and then I'm explaining why I like to administer Clickhouse more. Let me take a second and explain why Clickhouse is really good at logs at scale. Logs, as a data shape, have some peculiar properties. They're append-only. You never update a log line, and you almost never delete a single one, though you delete a lot of them at once when retention kicks in. They arrive roughly in time order, though never actually in order. They're read in bursts where nobody looks at logs for days, and then during an incident somebody wants to scan a billion of them in seconds. They're highly compressible, because most of the bytes in your logs are repeated: the same service names, the same hostnames, the same error strings, the same JSON keys, over and over and over again. And critically, when you query them, you almost always want either a narrow time range across all fields or an aggregation across a wide time range with a few filters. You very rarely want "give me one specific row by ID" the way you would from a transactional database. (There are exceptions when its something like GDPR or compliance logging which is its own subgenre of nightmares). In a row-oriented database — Elasticsearch, Postgres, MySQL — the data for a single log line is stored together on disk. If your log has 40 fields and your query only cares about 3 of them, tough luck, you're reading all 40 from disk anyway. The database will filter it in memory, but the disk I/O has already happened. ClickHouse stores each column separately. If your query says SELECT service, status_code, count() FROM logs WHERE timestamp > now() - INTERVAL 1 HOUR GROUP BY service, status_code, ClickHouse reads exactly three columns off disk: timestamp, service, and status_code. The other 37 columns in your schema might as well not exist. On observability data, where you often have dozens of attributes but any given query touches three or four, this is the difference between scanning 800GB and scanning 40GB. This is also why the compression numbers look absurd. Columnar data compresses far better than row-oriented data because the values within a single column are, by nature, similar to each other. A column of service_name values might have a hundred distinct strings across a billion rows. ZSTD eats that for breakfast. You'll routinely see 10–14x compression ratios on real observability data, compared to 2–3x for Elasticsearch. The amazing thing is that ClickHouse scales without changing shape. I don't know how else to say this. Every other observability backend I've worked with mutates as it grows. The architecture at 1 TB a day and the architecture at 10 TB a day are recognizably different systems, with different failure modes, different ops burdens, and different mental models. ClickHouse at 10 TB a day looks like ClickHouse at 1 TB a day with more shards. That's it. That's the pitch. That's the whole reason I'm writing this. Let me show you what I mean. At 1 TB a day, every modern observability stack is roughly okay. If you're at this scale, you can pick almost anything and be productive. The differences below are real but they're not yet painful. Here is the honest truth: at 1 TB a day, ClickHouse is not less complicated than its peers. It's roughly the same. Maybe slightly more, if you count the schema design work you have to do up front. You get 10–14x compression with ZSTD and proper codecs, the Altinity Operator handles keeper coordination and the whole thing runs in about seven pods. But you do have to design your schemas. ORDER BY keys matter enormously. There is no native PromQL, so metrics workflows go through the Grafana plugin or through chproxy and an adapter. Roughly $1.5–2.5K/month. If you took the diagrams at this tier and squinted, you'd say they're all in the same weight class. And you'd be right. Now watch what happens next. This is where the exponential curve kicks in for everybody except one of these. You'll notice, if you look at the diagram, that I basically just added shards. That's it. That's the change. Same operator, same query engine, same query language, same mental model. Rebalancing after adding shards is manual, which is a real trade-off — most teams pre-provision or use weighting on Distributed tables to sidestep it. Materialized views for dashboard rollups shift from "nice to have" to "essential." Roughly $7–11K/month. The gap between ClickHouse and everything else opens up here. It doesn't close. This is where most solutions genuinely stop working, in the sense that even a well-staffed internal team cannot keep up with the operational load. If you've read this far, the point is probably already obvious, but I want to say it directly. Every observability stack works at 1 TB a day. If you're small, pick whatever your team already knows. Life is short. We're all just waiting for the robots to kick our heads off like soccer balls. The question is not which stack works today. The question is which stack still resembles itself two years from now, when your data volume has 5x'd and your team has 2x'd and the person who originally designed the whole thing has left the company. Elasticsearch mutates. LGTM mutates. Datadog stays operationally simple but mutates financially into something that requires its own dedicated team of accountants and pipeline engineers just to keep the bill from spiraling. ClickHouse just gets wider. You add shards. That's the whole trick. There is a real cost to this: you have to eat the schema-design and query-engine complexity up front, at a scale where the other options are objectively easier. You will be, briefly, the one making things harder for your developers. They will not always appreciate this. But the trade you're making is that their experience — and yours — remains roughly the same as the data grows by an order of magnitude, and the next order of magnitude, and probably the one after that. I have spent ten years watching observability stacks change shape underneath me while I tried to keep them running. ClickHouse is the first one that hasn't and that has been able to actually scale with me . That's pretty incredible. A relatively vanilla Elasticsearch cluster with Logstash providing some buffer between ingest and the Lucene indexes. Users get full-text search, which is genuinely good — this is the thing Elasticsearch is actually best at, and at this scale it delivers. Mapping explosions are already a background risk with mixed data, so dynamic mapping needs to be disabled or carefully templated from day one. ILM policies (hot → warm → delete) are non-optional even at this size, because forgetting to set them is how you get paged on a Saturday about disk pressure. Roughly $6–9K/month. Nothing too crazy. Alloy (formerly Grafana Agent, RIP) unifies the collection story into a single daemon, which is nice. Loki works well as long as you spend some time educating developers on how to attach useful labels — a conversation you will have many times, with many people, for the rest of your career. Mimir and Tempo largely do what it says on the tin. Roughly $3.5–5K/month. At 1 TB a day, Datadog is genuinely great. This is the scale it was built for, and it shows. You install the agent, you look at dashboards, you go home. There is almost nothing to think about, which is the entire point. You can already see the shape of the cost problem lurking in the diagram — the metered pipelines, the indexed-vs-ingested logs distinction, the custom metrics cardinality tax — but at this scale it's manageable. Roughly $45–75K/month, though negotiated pricing varies enough that I'd take that number with a grain of salt the size of a fist. Datadog's whole pricing philosophy is that they save you a full-time engineer. I think that framing is somewhat deranged, but they are extremely rich and I am not, so consider your source. Kafka is no longer optional. At 5 TB a day, direct writes into Elasticsearch cause bulk-reject storms and backpressure that will absolutely take your cluster down during a traffic spike. So now you're running Kafka, which means you're either running Kafka well or you're about to have a second, entirely different set of problems. Shard math becomes critical — at 50GB target shards, you're minting ~200 shards a day counting replicas, and your cluster state size becomes its own concern. You almost certainly need Elastic's commercial license for searchable snapshots and the frozen tier. Roughly $40–55K/month before licensing. That but Kafka You are now in microservices mode, whether you wanted to be or not. That means 65+ pods across three separate systems, each with its own compaction pipeline, its own hash ring, its own memcached tier. The gossip/memberlist ring becomes a real operational concern; ingester rollouts require careful -ingester.autoforget-unhealthy tuning, and if you get it wrong you either lose data or duplicate it. Roughly $22–32K/month. The operational complexity is still low, in that you don't run any servers. But you now need a full pipeline team whose entire job is reducing your Datadog bill. Exclusion filters, sampling rules, cardinality caps, tag allow-lists, the whole apparatus. This is what I call the "you build a system to avoid using the system you're paying for" trap, and once you're in it, you are in it forever. Roughly $180–350K/month, depending on how aggressive the pipeline team gets. This is also where you are basically fighting with your SaaS provider all the time, pouring over their billing documentation to figure out how to reduce costs. It's a hostile relationship and one I don't enjoy. You are now running three separate Elasticsearch clusters — one for logs, one for metrics, one for APM — federated through Cross-Cluster Search. Hot-tier NVMe cost dominates the bill. This is the scale at which teams start seriously evaluating alternatives, and where a lot of the recent migrations to ClickHouse have originated. Roughly $95–140K/month plus commercial licensing. You need people who are legitimate experts on Elasticsearch. Now thankfully Elastic just laid a ton of those people off, so they're probably possible to get, but still. Running this thing at this size is very complicated . Around 180+ pods, zone-aware everything, split-and-merge compaction, per-tenant limits, shuffle sharding to prevent noisy neighbors. You almost certainly have a dedicated observability platform team of three to five engineers at this point. If you don't, get ready for a bad fucking time. Roughly $55–85K/month. Still very easy to run, in the strict sense that you don't run anything. But your bill is now measured in six or seven figures a month, and the org has almost certainly built a pre-processing pipeline team whose entire existence is dedicated to reducing that bill. Most companies at this scale have gone hybrid: Datadog for APM and high-value metrics, self-hosted (increasingly ClickHouse) for logs. The complexity paradox at this scale is that you now have Datadog's simplicity plus your pipeline complexity plus a second self-hosted stack. Pricing is all over the goddamn place. You might be over a $1 million a month here. Look at the diagram and then look back at the 1 TB diagram. It's the same diagram. There are more shards. That's the difference. Materialized views for rollups are now mandatory rather than optional. Schema design mistakes you made two years ago will start to hurt, so hopefully you didn't make many. Rebalancing after adding shards is still manual; most teams pre-provision or use clickhouse-copier or a dual-write migration when they need to grow the cluster. Kafka starts to become useful as a buffer for very bursty ingest, though it's not required. Roughly $18–28K/month.

0 views
Giles's blog 2 weeks ago

Writing an LLM from scratch, part 34a -- building a JAX training loop for an LLM training run

For over a year, I've been using Sebastian Raschka 's book " Build a Large Language Model (from Scratch) " -- and the multitude of side-projects that have branched out from reading it -- as something like a curriculum for learning about modern AI. The one final task I had set myself was to build and train an LLM from scratch just using my notes -- no reference to the book, no reference to the model code I'd written following the book. As an output, I wanted something as good as my best PyTorch model based on Raschka's code -- a base model, trained on 3.2B tokens, that my (admittedly limited) evals ranked as being close to the original GPT-2 small's quality. I wanted to use a different framework, just to make sure I wasn't parroting code that I'd somehow memorised, so I asked people on Twitter which one I should use, and the winner was JAX . I took a slightly different route to Raschka's book; he takes an inside-out perspective, explaining things like attention, gradually building up a complete GPT-2-style model, and then building a training loop on top of it. I wanted to go outside-in: I'd put together a training harness to train the simplest-possible model with an API similar to a real LLM, get that working to my satisfaction, and then add features to that simple model, one by one, until it had the full architecture in place. The plan (which actually worked out nicely!) was that I'd be able to show how each change improved things. That's all done now, and I'm posting about it in two parts; in this one, I'll explain how I built the training harness, and in the next, I'll show the actual building and training of the LLM. So let's get started! JAX itself has a relatively minimal API, and doesn't include standard neural network components like linear layers. Likewise it doesn't have any built-in optimisers, data loaders or similar ML utilities. Now, I could have decided to build my LLM using just pure JAX, like I previously did with a toy XOR model . But I felt that it would be better to build this in the style that real-world JAX code is written, which would mean using some of the many utility libraries . On the JAX site itself, there was a useful-looking link: "If you’re looking to use JAX to train neural networks, check out the JAX AI Stack !" On the linked page, it made it clear that the two core parts of that stack were: I took a look at both, and they seemed pretty easy to grasp. Indeed, at first glance, I felt that NNX looked pretty PyTorch-like! In their tutorial example, the only real obvious difference was the JAX-y derivative-style gradient calculation and the way that random numbers were handled. And even the random numbers were handled in a less pure-functional way than pure JAX -- instead of having to mess around with splitting keys, you could just pass in what appeared to be a stateful variable that somehow split itself internally as needed. So, NNX and Optax were the frameworks I'd use. Rather than grinding through the tutorials, I decided that I'd just dive right in, and try to pick things up as I went along. How hard could it be...? To build a functioning training loop, I needed a minimal model to train -- not an actual LLM, but something that behaved at least a bit like one. It would take in a sequence of tokens, and spit out logits for each token. In my preferred model of how LLMs work , at the top level for a model, we feed in a sequence of token IDs, then: All of that suggested to me that the dumbest "LLM" I could write just to get started would be one that just projected token IDs into embedding space, and then projected back to vocab space. No Transformer layers at all. I'd then train it so that instead of trying to predict the next token, it would try to "predict" what was fed into it in the first place. In other words, you'd feed the training loop this input: ...and this target ...rather than the normal setup for an LLM, where you feed it ...and give it targets of If I could get that to work -- and it felt like the kind of thing where you'd be able to get the loss down to near-zero without a huge amount of training -- then I could be reasonably sure that I had a working training loop. 1 I decided to call this an A-to-A model. Coding up the model itself was ridiculously simple: it looked like this: There's as much boilerplate in there -- for the parameters that I knew that the model would need when I built out the full LLM -- as there is actual code doing stuff! But the training loop was a bit more fun. As I said, my plan here was to make sure my understanding of the internals of LLMs was correct by rebuilding one just from my notes. That "notes only" restriction didn't apply to the training loop itself, so I allowed myself to crib a bit from the PyTorch DistributedDataParallel code that I'd been using to train the original model in the cloud. The first version that I used is here . Let's start at the bottom, where we have the function . It starts with some boilerplate to handle the concept of "runs". This is a pattern I've found myself using in most of my projects. When working on a model, it's useful to be able to do multiple training runs, changing things each time. You want to keep the checkpoints, metadata and training charts for each one for future reference. So in my repo, I'll have a "runs" directory, and in there subdirectories for each training run I want to track. In those subdirectories, there are JSON files -- one to configure the model, , and one to configure the training hyperparameters and similar stuff, . (It's worth noting that at this stage, a bunch of those hyperparameters were unused; I kept them in there out of laziness, as I knew I'd need them later.) So we start our function by loading those. Our next step is to completely ignore one of the training hyperparameters, . I definitely wanted to do gradient accumulation , but decided to leave it for later. Better to get a solid, simpler training run done first, I felt. Next, we download the dataset we're going to use to our local disk with (which will only download if there's not an up-to-date copy already there). The next step is to call to load it into RAM. You can see that there's another hard-coded variable there, . This is a holdover from the multi-GPU DistributedDataParallel code that this was all based on; in this blog post I'm only covering the code for single-GPU training, but I decided to leave the DDP stuff in there for dataset-wrangling purposes, hardcoded to one GPU, so that it would be easier to re-introduce if I later decide to implement something similar in JAX. Let's take a look at and its related stuff. If you go up to line 39 you'll see the code. Firstly, there's a that keeps track of our training data. If you look closely, you might spot one oddity in that class. We have this: Remember that at this stage, the plan was to train the model to map tokens to themselves rather than to make next-token predictions. So the targets are the same as the inputs, not the more normal next token, which would look like (and, in the next post, will look like) this: Next, we have a function to load the appropriate subset of the data from the copy on the local disk into one of those objects. I hit an out-of-memory issue when I ran the first version of this. It was trying to load the data into my GPU's VRAM -- JAX's default behaviour if you have a GPU, and the CUDA version of JAX is installed -- and there was too much to fit in there. After a bit of digging around I learned how to change the JAX default device so that it would be loaded into normal system RAM. Unfortunately, once I'd done that, I found that iterating through it was super-slow -- it took about 1.2 seconds to get one training batch of 6,144 tokens out of the array, which meant that I'd have a limit of 5,120 tokens/second of training from that alone. I eventually learned that the data had been loaded into the main RAM, but was being copied up to the GPU for processing because it had not been committed to the main RAM -- details here . Fixing that (with an explicit call to ) meant that getting a single training batch from the dataset and putting it onto the GPU took less than 0.001s, which was much better. So that was many hours of work that all got packed into lines 55 to 58 of the code: The remainder of the logic in is just to make sure that we have a dataset that is exactly the right size for the world size (even though that's always one right now), the microbatch size, the gradient accumulation steps, and the sequence length that we're working with, Let's go back to the function again. Having loaded our dataset, we create our model, passing in the model configuration stuff and also the (currently unused) dropout rate training hyperparameter, then we create a Flax NNX optimiser which wraps an Optax one. This was essentially a copy/paste from the Flax tutorial, except we're configuring the optimiser with learning rate and weight decay hyperparameters from the training config: Finally, we call to kick off our training loop, passing in some appropriate stuff. Let's go to that function next. We start off with a bit of housekeeping, then go into the main loop. You can see that it's kind of gesturing at gradient accumulation: ...but if you look at the actual body of that loop, it's not doing anything of the sort. It's just getting training batches, putting them on the GPU, doing a full training step, and keeping track of some metrics: So, we're just doing a traditional batch-by-batch training loop without gradient accumulation right now. But some of the infrastructure is there, because it was the next thing I wanted to add after I'd got the basic loop working. The rest of the function is just housekeeping and checkpointing; we'll come back to the checkpointing shortly, but first let's take a look at the function that actually trains the model on a set of inputs and targets, and its associated function -- they're just above . Now, as you might remember from my first JAX post , the best way to JIT a training loop is at as high a level as possible. So when I first coded this, I integrated that into the traditionally-named function like this: When I actually came around to run it the first time, loss wasn't falling at all, and after banging my head against it for a while, I realised I should have used rather than , fixed that, and kicked it off again. Loss started falling immediately. D'oh! Now let's take a look at loss. Cross entropy loss was clearly what I would need to train an LLM, and also felt like the right thing for the A-to-A model. Optax has five loss functions that are related to cross entropy; three of them looked a bit more complicated than I needed: So it was a choice between The latter was the right one -- expects the labels (that is, the target token IDs) to be one-hot vectors, while , as it says in the function name, expects integer labels, which is what we have. That sounded pretty similar to PyTorch's , but there was an important difference. For normal use (if you're not using K-dimensional loss, whatever that might be) PyTorch expects that the inputs are either just a one-dimensional tensor of c logits, or at worst a b x c matrix, where b is the batch size. I had noted when working through this section of Raschka's book that the code we wrote flattened things out. So a batch of six sequences, each 1,024 tokens long, with a vocab size of 50,257, would give us a logits tensor shaped like this: The first axis is the batches, the second is the length of the sequences -- remember, we have logits for every input token in the sequence, with next-token predictions for that token in the context of all of the other ones to its left. And the last axis, with a size equal to our tokeniser's vocabulary size, is the logits themselves. After flattening, it looked like a "batch" of 6 * 1024 = 6144 logits vectors: Likewise our targets -- the token IDs we wanted our model to be predicting -- were batched, and there was one per token in each sequence, so that tensor was Flattened, it looked like a "batch" of 6 * 1024 = 6144 targets: Finally, the PyTorch function returned a scalar value -- wrapped in a PyTorch object, of course, so that it could participate in the backward pass, but a single number. But I'd forgotten about all of that when I was writing this part of the JAX code, and just fed the inputs and the targets straight in to the JAX function. The result was interesting. I started with this: And printing out the shapes of each variable gave this: It had returned a cross entropy number for every element in every sequence, across all of the batches! What's interesting is that the docs for imply that it has the same restrictions as PyTorch's -- it expects a single batch axis in the tensors that are passed in. Perhaps they're out of date? Or perhaps Optax just assumes that you know that in JAX "a batch axis" should be read as "as many batch axes as you want"? Well, anyway -- it worked, and I checked that the numbers were solid. Now, of course, we can't ask JAX for gradients using that 6 × 1024 matrix -- the loss function needs to return a scalar -- but the function on a JAX array does exactly what we need. So I had a solid loss calculation, which you can see in : So that's covered our loss function and the JITted that uses it. The only remaining code that I haven't gone over in this version of the script is the stuff immediately above -- and . These are both called as part of the housekeeping code I glossed over in the function, after we take checkpoints. They just redraw a plot of the loss and other training metrics, using stuff that's stored in the metadata of all of the checkpoints so far. That means that there's a nice graphical way to keep track of a training run. Fairly dull stuff, so there's no need to go through them, but it is worth taking a look at the checkpointing code itself. You can see the version I was working with at this point here . It's not really much of a checkpoint; I was saving the model itself and the metadata needed for that charting code, but not the optimiser, which would be needed for a real checkpoint. After all, the purpose of a checkpoint is to be able to pick things up again if your training loop crashes, and you can't do that without the optimiser's state. Still, it was enough to get started with. That said, one wrinkle I encountered when writing that simple checkpointing code was that it was a tad tricky to save them in Safetensors format -- you can see the details here . So, that was my initial training code. It was time to let it rip: could I train my dumb "LLM" to map from A to A? As I mentioned earlier, the very first run didn't converge at all -- loss started at about 10.82, which was promising (it's exactly what you'd expect for a randomly-initialised network trying to predict GPT-2 tokens -- see here for details), but then it remained there. But when I fixed the " should be " issue, it started dropping. After 92,160,000 tokens seen, it seemed to have hit zero (at least to the three DPs I was printing), so I baked that into and did another training run fixed to that number of tokens. After about 14 minutes, it finished: A very promising final loss, even though that was just whatever we got on the last batch! The actual loss chart looked like this: If you're used to the loss charts in my previous posts, there's something to highlight here: I've switched the Y axis over to being log, so those bumps near the end are actually tiny deviations away from 0.001. I think it's worth showing what the model actually did at this point. It was actually somewhat later that I wrote some code to load up the model checkpoints from these training runs and do some smoke tests, but I'll show you some results now. I wrote some code based on my JAX safetensors post to load up a model's parameters from a checkpoint's file: ...and then wrote two test scripts. Firstly, was it really mapping from A to A? I wanted to be sure that the loss number was actually reflecting what I wanted it to reflect. I wrote a simple script that took a Safetensors file on the command line, and ran the first verse of The Rime of the Ancient Mariner (chosen because it uses oldish English so there are some odd tokens in it) through the LLM it loaded from that file. Here's what the model at the end of the run came up with: That's great! It could certainly handle the mapping. Out of interest, I decided to see how quickly it had learned to get that right. The average training loss in that "best" checkpoint at the end of the training run was 0.0001, so how did the mapping improve, and what was the loss, near the start of the training run? For the first checkpoint, when we'd just run one batch through, we had an average training loss of 10.8242. With the model parameters that were saved then, we get this output: As you'd expect from that loss, it's total token salad. Now let's take a look at the next checkpoint, taken after 375 "global steps" -- that is, 6,000 batches. In that one, the average train loss since that first checkpoint was 2.9323. But that hides something important -- the maximum loss, near the start, was (as you would expect) 10.78524, not much less than the average loss in the previous checkpoint. But the minimum (which we can safely assume was towards the end of this checkpointing period) was 0.54155, so we can reasonably assume that the model improved very rapidly at this point. And the A-to-A test bears this out: So, we can see that the bulk of the improvement happened right at the start! It was able to pass the A-to-A test for that fairly unusual sequence after just 6,001 total batches of 6 1,024-token sequences. The rest of the training run was perhaps just grinding out improvement on rarer tokens, and perhaps making it more certain about already-correct predictions. After all, the test script was simply printing the most likely token for each position, so at this state it might have been predicting some of those tokens as 51% probability. That would have meant a penalty in the loss function, even if the answer was actually correct. So that was an interesting script; I wanted to do another -- the standard smoke test that I've been using, based on Raschka's prompt: how does the model complete "Every effort moves you" when asked to continue the sentence? Here's the script , and here's what it generated: That makes perfect sense. In order to generate the next token in an autoregressive loop, we're looking at the logits for the last one in the prompt. When it first runs, the last token is " you", and our model is trained to map A to A, so its result is " you". We append that to the prompt, run it through again, the last token is still " you", so of course it "predicts" the token " you" again. And so on. So these results were both good news! The A-to-A mapping was working, and was converging rapidly in terms of loss -- and even more rapidly in terms of our poetic test. So, what was next? I wanted the training loop to be as similar as possible to the code I used for my best locally-trained PyTorch model . That used three things I had not built into the training loop at this stage: learning rate scheduling, gradient clipping, and gradient accumulation. The PyTorch code also had the ability to restart from a checkpoint -- not super-important in a 14-minute training run like this one, but I figured it would become important later. After all, the PyTorch runs on my local machine had taken almost two days, and if something went wrong halfway through (cat jumping onto PC power button, etc) then I really wouldn't want to start from scratch. I decided to handle gradient accumulation first. In PyTorch, doing gradient accumulation is pretty simple: the core of a typical training loop without it might look something like this: We start off by clearing out any gradients that are stashed on the model's parameters, then do a forward pass, work out the loss, do a backward pass to put new gradients on the parameters, and then step the optimiser to apply those gradients. Accumulating gradients just means changing it to something like this: That is, we do a forward and a backward pass times. Because we're not zeroing out existing gradients between them, the parameters will accumulate gradients over time -- each backward pass will add its contribution onto what is already there. Each time, we divide the loss by , so that the gradients that are put on the parameters are that much smaller, which means that by the end of our loop we've got gradients that are the average of what we'd have got if we'd done all of these microbatches in one big batch. Finally, once we've exited the loop, we step the optimiser to apply those averaged gradients. When I started thinking about implementing this in JAX, I noticed that Optax has a help page on how to do it , but then I had one of those brilliant shower thoughts that one sometimes has. I should have learned by my age that they rarely work out well, but this time I decided to give it a go rather than doing things the official way. My brilliant idea was that with some finessing, we could put the whole gradient accumulation loop inside JITted code. From what I'd learned so far, the higher up in our code we put the JIT decorator -- that is, the more of the training loop it covered -- the faster it would be. In itself, that wasn't a bad idea. But my first implementation was less smart: The were full-step arrays (eg. shaped (16, 6, 1024) for 16 gradient-accumulation steps over 6 microbatches of 1024 sequences), and the targets likewise. That seemed very clever! But in retrospect, it was obviously doomed to failure, and when I ran it, I ran out of VRAM. The point of gradient accumulation is that what you accumulate over time is, well, gradients. So you have to do a full forward pass and then a backward pass over the model for each microbatch, letting gradients build up, and then apply those in one go, like the PyTorch code did. Unfortunately what I was doing with my code was essentially all of the forward passes, one by one, letting the activations and JAX's internal structures representing what calculations had been done accumulate -- not the gradients -- and then doing a single backward pass across all of that. Mathematically it made sense -- I would have got the right effect if I'd had enough VRAM -- but it wasn't much more memory-efficient than just doing a single batch of sequences. Immediate CUDA OOM. My second attempt was a bit more sensible and ran OK without the JIT: You can see that now I was doing both the forward and the backward pass within the loop, and then working out the mean gradients with that , then passing those average gradients to the optimizer. It all made sense, and seemed to work when I ran it: ...and it wasn't as much slower as I would expect given the lack of JITting: 1,146 seconds versus 843. It was interesting that the final train loss was higher than the run without gradient accumulation, but larger effective batch sizes are not always a better thing: it depends very much on the model you're training and the data. The batch size and number of gradient accumulation steps I was using were ones I had optimised for the full 163M-parameter GPT-2-style LLM, not for this model. So it was OK if it was a bit worse. Anyway, I tried adding the to that function, and ran it: Ouch. And looking at the traceback, it appeared that it was the actual JITting that was running out of VRAM. Something to do with loop unrolling, perhaps? I dug around for a while, trying to use JAX's rather than a normal Python one, but to no avail -- I would always run out of GPU memory. Eventually, after a few hours, the alarm bells on my side quest detector had become too loud to ignore. Reluctantly, I gave up on hand-rolling my own gradient accumulation, and implemented it the Optax way . That was actually really nice and simple. The code is here , but the change is tiny and simple to explain. Remember that we had this code to set up the optimizer: That creates a Flax NNX optimiser, which uses an Optax AdamW optimiser under the hood. The Optax way to do gradient accumulation is to wrap the optimiser in a helper, which -- with the NNX optimiser wrapping the result -- looks like this: The wrapper is really neat. It has the same interface as a regular optimiser, so its method can be called with a set of gradients. But instead of applying them, it just accumulates them until a particular number of calls to have been made, at which it actually does apply the mean of the accumulated gradients, and resets its counter so that it starts accumulating again. That's actually a really nice API. And it actually meant that I would have been able to simplify the training loop. Remember, we had this: The loop-within-a-loop was needed by the PyTorch code, because we needed to do the optimizer step at the end to apply the accumulated gradients. But with the Optax wrapper, we could have just iterated over our samples in one top-level loop, relying on the to make its updates every iterations. However, I decided to leave it in -- keeping track of the training in terms of global steps meant that the training output with my JAX model would be easier to compare to the PyTorch versions. Perhaps if I'd been building the training loop completely from scratch I would have chosen differently. Anyway, with that code change in, I ran it, and: I had the same loss at the end as the by-hand un-JITted version, which was reassuring. And it was slightly faster than the non-gradient-accumulating version, but it's a small enough difference that it was probably just in the noise. So that was gradient accumulation! Here's the code with that added . Next, I wanted to get charting and scheduling of the learning rate, and gradient clipping working. Scheduling the learning rate means that we'll be changing it over the course of the run -- like this example from one of my PyTorch training runs: Having a chart like that one is really useful, as it allows you to sanity-check that the changes you are making to the learning rate really are the right ones. So I wanted to add the charting first, and then the scheduling. The boilerplate code to actually generate the chart, given learning rate numbers in the checkpoints' metadata, was already there, so I had to work out how to extract the current value of the learning rate from the optimiser and then save it into the checkpoints. This was the obvious starting point . Optax optimisers themselves don't store the learning rate, but if you create them like this: ...where the in the brackets is the normal stuff that you'd pass in to the optimizer when creating it, then you can extract the learning rate later. However, the code on that help page was using the Optax optimiser directly, whereas my one in the training code was wrapped inside a , which was in turn wrapped inside an NNX object, like this: Still, the solution seemed reasonably clear. I could use the trick on the that I was creating, and then pass it in to be wrapped like this: The next question was how to actually read the learning rate from that optimiser. The sample code in the Optax docs looked like this: Again, that was using the Optax optimiser directly, rather than trying to use one that was inside an NNX one. However, in the docs for NNX's optimiser I noticed that it exposes its wrapped Optax one's state as . I put in some temporary debug code to print that, and saw that it was the ' state, which made sense -- and that, in turn, contained the state of the wrapped one as . That had a field called , which was a dictionary that included as a key. Finally, the value that that key pointed to was a object. To get the actual value from there, you need to call its to get the actual value, which is a JNP array, so we needed to call on it. All of that led to the following abomination unto God, mankind, and the Law of Demeter : Eurgh. I mean, really, eurgh. Well, anyway, I put code to do that into the function and save the number as part of the metadata. I did a partial training run, just for long enough to confirm that the learning rate chart was being generated, and had a flat line on it at 0.0014, the constant learning rate I was using at that point. I can't say I was very proud of it, though. To recap, the learning rate schedule that I wanted was this: That's formed of two phases: an initial warmup, where the learning rate started at 0.00001 times the desired peak value, and then rose linearly to the peak, followed by a cosine wave to decay it to 0.1 times the peak. In PyTorch I had had to use different learning rate scheduler objects to handle each phase, with a wrapper to bolt them together : However, it's a common pattern in training loops, and conveniently Optax provides a class that does all of that for you. The only oddity in it is that is kind of misnamed; it's actually total steps, including the warmup. So I wound up writing this code: I did a training run with that, and it completed with this: The loss was a bit worse again, but just as with the gradient accumulation steps, the learning rate schedule I had specified was specifically designed for training a real (if small) LLM, not for this toy A-to-A task that I was using to test the training loop. The important thing was the learning rate chart, and it looked like this: Perfect! Here's the code at this point . There were two boxes left to check before I had a training loop I could actually use to build the LLM: gradient clipping and the ability to restart from a checkpoint. I decided to do gradient clipping first. Gradient clipping is where for each update, you look for gradients that are suspiciously large, and cut them off so that they don't make excessive changes to the model. The Optax docs made it look pretty simple: So, you use an to chain together first a thing that does clipping, and then the actual optimiser -- presumably the first thing in the chain sees the gradients and does stuff to them, and then the second receives whatever the first has returned. Now, the question was, should we do the chain outside or inside the MultiSteps? That is, should we clip gradients each time before we step the MultiSteps optimiser, or do we accumulate them and clip the average before we step the inner AdamW one? Looking at the old PyTorch code , I was running the gradient accumulation loop, and then clipping at the end. So the gradient clipping was happening to the accumulated gradients. That actually felt less intuitively good than the alternative, but I decided that we should try to mirror what the PyTorch code is doing. So: So, the optimiser would receive clipped gradients. Because it was wrapped in the , it was receiving the accumulated gradients every time that object hit its limit. Unfortunately there was still a problem: that change meant that the optimiser that we were reading the learning rate from with this horrendous code in the function: ...would now be inside yet another level of nesting -- the object. So, of course, when I ran it, it blew up with an error: I used some debug prints to work out what was going on, and determined that the state of the object was a tuple, the first element being an essentially-empty state for the clipper, and the second being the hyperparameter-injected state for the . So that meant that the new correct code to get the learning rate would be this: Note that we've gained that to do the lookup into the 's tuple state. I remember coming across a comment saying "forgive us for our trespasses in this method" in a codebase long ago, and I know well how the author felt. I did have an idea of how to at least limit the blast radius a bit, though. At this point in the code, I had the complex optimiser setup in the function, and the learning-rate-getting abomination in . I decided instead to define a function called right next to the optimiser setup, and pass that in to . So the horror was still there, but at least it was all in one place, like this: ...where called where it needed it. I was just about to kick this off, but by chance happened to take a closer look at the documentation for , and spotted that it said Clips updates element-wise, to be in That rung a bell! When I was originally looking into gradient clipping for the PyTorch training loop, I noted that that is a perfectly valid way to do gradient clipping, but it's not the way I ultimately chose. Instead, I was clipping based on the L2 norm. The JAX training code was meant to work the same way as the PyTorch code, so that was a good catch; I switched over from using to using , and then kicked off another training run: Everything looked fine; my guess was that the final loss was so similar because a simple task like A-to-A mapping, with such a shallow network, would be unlikely to cause gradients to explode. But it would be nice to be sure. Was there some way I could track the gradients and see if clipping had had to cut in? One neat thing we had in the PyTorch code was that we could track gradient norms pre-clipping: Unfortunately, and the general Optax API doesn't provide any way to access the pre-clipping norms: the that was the zeroth element of the state of the that we were reading in the horrendous learning rate-reading code is an alias of . I considered using to work out the norms directly, and logging that, but that would be tricky -- because the gradients we were applying the clipping to were not the ones that were generated in the function, but instead the ones that had accumulated inside the object over multiple gradient accumulation steps. This sounded like a lot of work for a not-enormous benefit, so I decided to leave it out for this project. There was, however, one small change that I wanted to make while I was messing around with gradients -- what to do if non-finite numbers crept into them. Back when I was first looking into gradient clipping, I was somewhat horrified to realise that the scaler object I was using to tell PyTorch to train in 16-bit for things where it felt it would help (Automated Mixed Precision, or AMP), was silently dropping any updates with non-finite gradients, and if you didn't use AMP, such gradients would be happily applied to your model, most likely completely breaking it by setting parameters to non-finite values. This felt like the wrong place for that kind of logic to go -- I felt that it should belong to the optimiser, or at least in some other part of the stack that wasn't specifically related to the totally orthogonal task of mixed-precision training. I checked what JAX's default behaviour with non-finite gradients was, and it turned out to be to just apply them -- but, with Optax, it actually was something you could fix at the optimiser level. If you wrap an Optax optimiser with , it will only apply finite gradients, so we could add it to the optimiser setup like this: I set to infinity to mirror the PyTorch code's behaviour. Now, obviously, this required yet another level of indirection in the learning-rate-getting function from hell: If you're keeping track, it's the in there. Heigh ho. So, it was time to run it again: That looked OK -- no change from before. Here's the code . Now, it was time to take the last step to finish the training loop: the ability to restart from a checkpoint. At this point, the checkpointing code was pretty basic -- it would save the model as a Safetensors file, along with some metadata like the min, max and average loss since the previous checkpoint, the number of the global step that we were on, and whether or not this was the best checkpoint (in terms of average training loss) so far. In order to restore from a checkpoint, we'd need more information. In the old PyTorch code, we needed three extra things on top of the model and the metadata: So that was the job: save the optimiser in , and then implement a so that we can restart from one. I could then try kicking off a training run, waiting for a bit, killing it, then restarting from the most recent checkpoint. The loss and learning rate charts would tell me whether or not the restart really had picked up from where it had left off. Initially I was thinking that I would just use pickle to save the optimiser, but that felt like a problem waiting to happen. Pickle has issues when you change Python versions or versions of installed packages, which never feels like it's going to be a problem, but all-too-frequently turns out to break stuff in reality. 2 Using Safetensors looked a bit tricky -- it had been hard to get it to work with Flax models, even though it had explicit support. Now, the recommended library for checkpointing in JAX code is called Orbax . I'd looked into it before, and it looked a bit heavyweight, so I'd moved on. But digging in a little more, I found that it had what looked like a simple API for saving PyTrees , which bypassed the complexity. Getting it working was still a bit tricky, though. Firstly, in the docs, they give this example: I tried that in the function with code like this: ...and got the error Huh. Digging into the library from the command line showed that the function was actually called . Not super-promising if the docs don't match the API (though to be fair, it does say right there in the package name). Anyway, changing that appeared to work: ...and then next to the 295 MB file called in my checkpoint directories, there was a 353 MB directory called . In PyTorch-land the optimiser had always been double the size of the model 3 , but given the wildly different file formats in play, I was comfortable enough that it was order-of-magnitude the same as the model and somewhat bigger. Perhaps Orbax was doing some kind of compression or something like that. Next, it was time to write . I started off by writing the function to load up the safetensors file -- that's the one I showed earlier, back when I showed how the original A-to-A model learned how to map a poem to itself, and that if you asked it how to complete "Every effort moves you", it would respond with " you you you you you" and so on. Once I had that, I created a , which called , and then loaded up the metadata and worked out what our best loss so far had been (which is necessary when continuing from a checkpoint so that, as you continue training, you can work out whether each new global step has had a loss that is better than the current best). That was simple enough: Restoring the optimiser turned out to be a bit trickier. Firstly, of course, just like with saving, the Orbax function was called rather than the documented . The next part was working out how to load it in a fashion that the optimiser would accept. If you load a checkpointed PyTree like this: Then what you get back is a "basic" PyTree -- it will consist of lists, dictionaries, tuples, basic Python types like strings, and JAX arrays. The problem is that the optimiser's state is formed of objects that can be mapped to such things -- for example, an object can be mapped to a dictionary where each field is an item in the dict -- but aren't actually those specific types of objects. So if you do this: ...you get an error, something like this: ...and likewise if you use the function I was using in the code: ...you'll get a slightly different but equally confusing error. After a certain amount of floundering around, limited by the lack of documentation (and it not seeming to match the API that I was seeing) I had the bright idea of looking at 's docstring, and that turned out to be excellent. In IPython: The solution was obviously that . When you provide it, it's used as a template. If in the abstract PyTree it finds a object, and in the loaded PyTree there is a dictionary in the same position with keys , and , it will create a object, setting those fields to those values. That means that you have something with the right structure to apply, so I wound up with this relatively simple code to load checkpoint into the optimiser: We're using the existing state of the optimiser as a template to tell Orbax how to structure the loaded one. I kicked off a training run, hit control-C halfway through, then restarted it from the checkpoint, and the final loss chart looked like this: ...and the learning rate chart like this: Perfect! The interrupt was at about global step 400, and the loss continued to go down properly, and the learning rate followed its schedule perfectly. Here's the checkpoint-loading code and the training script . So with that, phase one was done. I had a training script. It was massively overengineered for training this little A-to-A model, but just right for training a small LLM from scratch. And now it was time to do that -- and that's what I'll cover in the next post. If you're thinking "why not just have it return one-hot vectors based on the input tokens", remember that I needed something in the model to train, so that I could confirm that loss was going down. A pure "identity" model without the embedding space would have nothing to learn, so wouldn't be able to provide that.  ↩ It was a surprisingly large source of tech support queries on PythonAnywhere. Someone would train a model with (say) Python 3.11.1, and then try to run it on our servers using 3.11.2, and discover that they couldn't load up their checkpoints. This confused them and they wondered if it was something to do with our platform. I even had a quicktext response to send with a rundown on how Pickle works so that I didn't have to keep typing the same explanation. This may have biased me more against Pickle than I should rationally be.  ↩ AdamW stores two numbers per parameter to keep track of its optimisation state, so 2x the model size is exactly what you'd expect if both files were in the same format.  ↩ Flax NNX for neural network components. Optax for optimisation. Firstly, we convert them into embeddings, so we get a series of vectors. We do this by a lookup into a table, but we can see it conceptually as a projection via a matrix, from vocab space (where a particular token ID is a one-hot vector) to an embedding space. Next, we do the magic with our Transformers layers, getting embeddings for the next token. The embedding at position n in the output sequence, after these layers, is for the predicted token to come after the token at position n in the input sequence, considering that input token and all other tokens to its left. Finally, we project those back from embedding space to logits, this time actually using a real matrix (in the form of a linear layer). The logits (after being run through softmax) represent the probabilities for each token of it being the next one. The scaler that we used to do automated mixed-precision training. This JAX loop was not going to do that, so it was not necessary here. The learning rate scheduler. This was built into the optimiser for JAX, so I didn't think it was needed. The optimiser itself. This was important, and we definitely did need to save it. If you're thinking "why not just have it return one-hot vectors based on the input tokens", remember that I needed something in the model to train, so that I could confirm that loss was going down. A pure "identity" model without the embedding space would have nothing to learn, so wouldn't be able to provide that.  ↩ It was a surprisingly large source of tech support queries on PythonAnywhere. Someone would train a model with (say) Python 3.11.1, and then try to run it on our servers using 3.11.2, and discover that they couldn't load up their checkpoints. This confused them and they wondered if it was something to do with our platform. I even had a quicktext response to send with a rundown on how Pickle works so that I didn't have to keep typing the same explanation. This may have biased me more against Pickle than I should rationally be.  ↩ AdamW stores two numbers per parameter to keep track of its optimisation state, so 2x the model size is exactly what you'd expect if both files were in the same format.  ↩

0 views
Simon Willison 2 weeks ago

Have your agent record video demos of its work with shot-scraper video

shot-scraper video is a new command introduced in today's shot-scraper 1.10 release which accepts a file defining a routine to run against a web application and uses Playwright to record a video of that routine. I've written before about the importance of having coding agents produce demos of their work; this is my latest attempt at enabling them to do that. Here's an example video created using , exercising a still in development feature adding the ability to create new tables in Datasette from pasted CSV, TSV or JSON data: That video was created by running this command : (That JSON file contains a cookie , as described here in the documentation.) Here's the file: The video command documentation includes simpler examples, but for the purpose of this post I thought I'd go with something more comprehensive. That demo YAML storyboard was constructed entirely by GPT-5.5 xhigh running in Codex Desktop, using the following prompt run inside my checkout of this branch : Now that I've released the feature the prompt could say " " instead and it should achieve the same result. I really like this pattern where the output for a command provides enough detail that a coding agent can use it - it works kind of like bundling a file directly inside the tool. I used the same pattern for showboat and rodney . started as an experimental prototype. is built on top of Playwright , and the key feature it needed was for Playwright to be able to record video of browser sessions with enough control to create the desired demo. I first tried this a few years ago and found that the Playwright-produced videos included additional chrome that was useful for debugging a test failure but unwanted for a product demo. They fixed that a while ago, but there were still some minor blockers. In particular I was getting a few white frames at the start of the videos , since the recording mechanism kicked in before the first URL was loaded by the browser. Playwright 1.59 added a new screencast mechanism providing much more finely grained control over video recording. This was very nearly what I needed, but the resulting videos were fixed at 800px wide. I found a landed PR fixing that but it wasn't yet in a release. Then yesterday they shipped it in playwright-python 1.61.0 and I was finally unblocked to finish implementing the feature! The code itself was all written by GPT-5.5 xhigh in Codex Desktop. I had it write the documentation as well which gave me a very useful frame for reviewing the design - much of the iteration on the feature came from reviewing that documentation, spotting things that were redundant, inconsistent or confusing, and requesting (or dictating) a better design. The YAML format itself was mostly defined by the coding agent. I had it use Pydantic to both define and validate the format, partly to make the design easier to review. This is a great example of the kind of feature that I almost certainly wouldn't have taken on without coding agent support. I filed the original issue in February 2024, and had difficulty finding the necessary time to solve this in amongst all of my other projects. You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options .

0 views
Simon Willison 3 weeks ago

sqlite-utils 4.0rc1 adds migrations and nested transactions

sqlite-utils is my combined Python library and CLI tool for working with SQLite databases. It provides an extensive set of higher-level operations on top of Python's default sqlite3 package , including support for complex table transformations , automatic table creation from JSON data and a whole lot more. I released sqlite-utils 4.0rc1 , the first release candidate for sqlite-utils v4. The major version bump indicates some (minor) backwards incompatible changes, so I'm interested in having people try this out before I commit to a stable release. There are two significant new features in this RC compared to the previous 4.0 alphas. The first is support for database migrations . This isn't a completely new implementation - it's a slightly modified port of the sqlite-migrate package I released a few years ago. I think that package has proved itself over time, so I'm now ready to bundle it with directly. Here's what a set of migrations in a file looks like: This defines a set of two migrations, one creating the table and another adding a column to it. You can then run those migrations either using Python: Or with the command-line command: The system is deliberately small: it doesn't provide reverse migrations, so any mistakes you make should be fixed by deploying a fresh migration to undo them. Its predecessor has been used by LLM and various other projects for several years, so I'm confident that the design is stable and works well. The new migrations feature is documented here . This feature is a lot less exercised than migrations, so it deserves more attention from testers. Previously, mostly left transaction management up to its users, via a construct that reused the mechanism directly. SQLite supports nested transactions in the form of savepoints, so I wanted an abstraction that could make those as easy to use as possible. I borrowed the terminology "atomic" from Django and Peewee. Here's what the new API looks like: More details in the documentation . The backwards incompatible changes in v4 were described in the alpha release notes. For 4.0a0 : And for 4.0a1 : You can install the new RC like this: Or try the CLI version directly with like this: Come chat with us about it in the sqlite-utils Discord channel , or file any bugs in GitHub Issues . You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . Upsert operations now use SQLite's syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous followed by behavior. ( #652 ) Python library users can opt-in to the previous implementation by passing to the constructor, see Alternative upserts using INSERT OR IGNORE . Dropped support for Python 3.8, added support for Python 3.13. ( #646 ) is now provided by the sqlite-utils-tui plugin. ( #648 ) Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new syntax was added. ( #654 ) Breaking change : The method now only works with tables. To access a SQL view use instead. ( #657 ) The and methods can now accept an iterator of lists or tuples as an alternative to dictionaries. The first item should be a list/tuple of column names. See Inserting data from a list or tuple iterator for details. ( #672 ) Breaking change : The default floating point column type has been changed from to , which is the correct SQLite type for floating point values. This affects auto-detected columns when inserting data. ( #645 ) Now uses in place of for packaging. ( #675 ) Tables in the Python API now do a much better job of remembering the primary key and other schema details from when they were first created. ( #655 ) Breaking change : The and mechanisms no longer skip values that evaluate to . Previously the option was needed, this has been removed. ( #542 ) Breaking change : Tables created by this library now wrap table and column names in in the schema. Previously they would use . ( #677 ) The CLI argument now accepts a path to a Python file in addition to accepting a string full of Python code. It can also now be specified multiple times. ( #659 ) Breaking change: Type detection is now the default behavior for the and CLI commands when importing CSV or TSV data. Previously all columns were treated as unless the flag was passed. Use the new flag to restore the old behavior. The environment variable has been removed. ( #679 )

0 views
Blog System/5 3 weeks ago

Is anyone still using Emacs?

In a recent discussion at the orange site sparked by the Emacs 31 Is Around the Corner: The Changes I’m Already Daily Driving article, people were asking themselves “Is anyone still using Emacs?” and then providing their own perspective. For me, the answer is a resounding yes… but the interesting part is that I’m not still using Emacs: I’m actually using Emacs again . And instead of burying my answer to the opening question in a long discussion thread, I thought I’d explain my journey with and without Emacs for the last… almost 30 years. At the end, I’ll unveil the specific feature that I feel gives me superpowers and that keeps me hooked. Show your support for this publication by subscribing. It’s free if you want it to be! I got into Linux around 1997 via Caldera OpenLinux 1.1. Before then, I had extensively played with Borland Turbo C++ and Visual Basic as a kid so I was heavily accustomed to those fancy IDEs that we lost . As I got into Linux and found myself in an alien world, I had to buy a couple of introductory books. Yes, books, the printed kind—because that’s how we had to learn new stuff before. Both books talked about Vim and Emacs and presented them as the advanced choices. I found this strange because the IDEs I had used before seemed more complete, but I, being a Windows renegade for some reason, charged ahead. I learned the basics of both editors and went through their tutorials at different times. The two old books I used to learn Linux back in the day, open to show their Vim and Emacs introductions. Since then and until roughly 2015, I flip-flopped between Vim and Emacs. At times I used one, and at times I used the other. I favored Emacs for long-running coding sessions but Vim excelled at my pkgsrc work where I had to edit tens of different files in quick succession. Even though Vim and Emacs worked well for me, I missed something. Language integration was poor so I was tempted by the more modern editors that everyone was touting, and especially so as I moved to macOS. I tried a bunch, like the now-defunct Atom and Brackets , but they all felt brittle and overwhelming: they had too many features, too many settings. And then, VSCode arrived in 2015. As I took it for a test drive, it “felt right” from the get-go. It looked modern, was relatively small, and its plain and simple settings editor—read: just a JSON file because there were no settings panels yet!—made me feel like I was in control. I could understand this modern editor and easily tune it to my needs. Soon after, I started learning Go and then Rust, and VSCode’s integration with their corresponding LSPs made that process so much easier: code auto-completion and real-time error highlighting sped up my learning significantly. I stuck with VSCode for these languages and slowly phased Emacs out. I was sold. During that time period, I was also working on Bazel—a Java project—at Google and the natural choice for it was IntelliJ. I had tried to use Emacs for Java development at some point, but IntelliJ was (and still is) so good that it was the only realistic choice. My usage of VSCode with its Vim plugin continued through my short stint at Microsoft, where I was working on a C++ codebase and had to connect to remote Windows boxes. Most people used RDP to work on the remote machine “directly”, but I couldn’t stand that workflow: I very much preferred running VSCode on my desktop and using SSH to connect to the remote machine, which is something that VSCode does very well. And then… I moved to Snowflake in 2022 where development used to happen inside an ancient Linux VM and where my day-to-day job was to write shell scripts and Bazel build files: neither VSCode nor IntelliJ were going to save me here, and as I mentioned earlier, I hate the feeling of working within the constraints of a “remote” graphical environment. So my instinct was to go back to SSH and connect to the local VM with it. As I did that, I needed an editor for long work sessions, and the old and trusty Emacs was there waiting for me. But this time around, I didn’t have the patience to set it up. You see: I had accumulated hundreds of lines in my file over the years without understanding much about them, and I wanted to throw it all away and start over… but it all felt like too much work. Maybe destiny brought Doom Emacs my way at the right time. Stock Doom Emacs screenshot from the project's website. You see, Doom Emacs is an Emacs “distribution” where someone has gone through the pain (or joy, I won’t judge) of configuring Emacs from the ground up. More specifically, Doom Emacs offers sane defaults, predefined language integrations, and an experience that welcomes ex-Vimers. It doesn’t claim to be an IDE… but it feels like one to me. Once I set it up, I experienced déjà-vu: Emacs felt right just like VSCode did in 2015. All of a sudden, lots of Emacs features became discoverable via interactive popup menus accessible behind space-based shortcuts that don’t destroy your wrists, and coexisting with the same Vim-style key bindings that I had grown so used to. But what’s more: the configuration felt simple and understandable, spread across just three trivial files: to specify global settings like the theme or the fonts to use, to select which Doom-specific modules need to be enabled, and to install non-Doom packages. The defaults for these files are reasonable, with plenty of comments to configure the few details you might want to tune. With this new setup, I have had the best Emacs experience ever. Thanks to the advances in LSPs (for which we have to thank VSCode) and modern features like tree-sitter, Emacs now feels like an IDE: I get proper language integration for most languages I have to deal with. And the absolute killer feature for me is that I get the exact same development environment no matter what machine I need to work on. It doesn’t matter if it is a MacBook or a Linux laptop, or if I’m connecting to a Linux cloud workstation or even my own FreeBSD server: all I need is a shell, tmux, and Emacs, and I am equally productive. This, to me, is really valuable because I tend to work on a variety of machines and muscle memory pays off. If you research Doom Emacs online, you will find people “complaining” that “it does too much”. And that’s true: it does, which is why I find it so useful. But I often wonder if I could cut things down because someday I’d like to learn more about Emacs. This is especially true now that I see many modern third-party modules “graduating” and becoming part of the stock package. For those reasons, I’ve recently been tempted to try the Bedrock or Emacs Solo distributions. However… the activation energy required to make the switch is pretty damn high. And if I decided to go that route, well, I’d still question myself for not truly going all the way to “raw” Emacs. And before closing, a related thought: I can’t quite comprehend how Emacs becomes transformative for people due to its Elisp backing. Sure, I could implement more logic and workflows within Emacs, but I already do “everything” with ease in the shell via scripts—and scripts feel more Unix-y because “Unix is my IDE”. I actually don’t like how Org mode and Magit are “locked” behind Emacs instead of being standalone applications. I’m surely missing something, but I’m not quite sure what it is… So coming back to the question that opened the article: yes, I still use Emacs, and it has become even more important to me than it was in the past due to my need to work on disparate remote machines all the time. Now the questions for you are: do you “still” use it too? What distribution, if any? How does Emacs transform your workflows? Now that you have made it this far, consider subscribing and/or sharing this article in your favorite social platform for further discussion! The two old books I used to learn Linux back in the day, open to show their Vim and Emacs introductions. Since then and until roughly 2015, I flip-flopped between Vim and Emacs. At times I used one, and at times I used the other. I favored Emacs for long-running coding sessions but Vim excelled at my pkgsrc work where I had to edit tens of different files in quick succession. The switch to VSCode and IntelliJ Even though Vim and Emacs worked well for me, I missed something. Language integration was poor so I was tempted by the more modern editors that everyone was touting, and especially so as I moved to macOS. I tried a bunch, like the now-defunct Atom and Brackets , but they all felt brittle and overwhelming: they had too many features, too many settings. And then, VSCode arrived in 2015. As I took it for a test drive, it “felt right” from the get-go. It looked modern, was relatively small, and its plain and simple settings editor—read: just a JSON file because there were no settings panels yet!—made me feel like I was in control. I could understand this modern editor and easily tune it to my needs. Soon after, I started learning Go and then Rust, and VSCode’s integration with their corresponding LSPs made that process so much easier: code auto-completion and real-time error highlighting sped up my learning significantly. I stuck with VSCode for these languages and slowly phased Emacs out. I was sold. During that time period, I was also working on Bazel—a Java project—at Google and the natural choice for it was IntelliJ. I had tried to use Emacs for Java development at some point, but IntelliJ was (and still is) so good that it was the only realistic choice. My usage of VSCode with its Vim plugin continued through my short stint at Microsoft, where I was working on a C++ codebase and had to connect to remote Windows boxes. Most people used RDP to work on the remote machine “directly”, but I couldn’t stand that workflow: I very much preferred running VSCode on my desktop and using SSH to connect to the remote machine, which is something that VSCode does very well. Back to (Doom) Emacs And then… I moved to Snowflake in 2022 where development used to happen inside an ancient Linux VM and where my day-to-day job was to write shell scripts and Bazel build files: neither VSCode nor IntelliJ were going to save me here, and as I mentioned earlier, I hate the feeling of working within the constraints of a “remote” graphical environment. So my instinct was to go back to SSH and connect to the local VM with it. As I did that, I needed an editor for long work sessions, and the old and trusty Emacs was there waiting for me. But this time around, I didn’t have the patience to set it up. You see: I had accumulated hundreds of lines in my file over the years without understanding much about them, and I wanted to throw it all away and start over… but it all felt like too much work. Maybe destiny brought Doom Emacs my way at the right time. Stock Doom Emacs screenshot from the project's website. You see, Doom Emacs is an Emacs “distribution” where someone has gone through the pain (or joy, I won’t judge) of configuring Emacs from the ground up. More specifically, Doom Emacs offers sane defaults, predefined language integrations, and an experience that welcomes ex-Vimers. It doesn’t claim to be an IDE… but it feels like one to me. Once I set it up, I experienced déjà-vu: Emacs felt right just like VSCode did in 2015. All of a sudden, lots of Emacs features became discoverable via interactive popup menus accessible behind space-based shortcuts that don’t destroy your wrists, and coexisting with the same Vim-style key bindings that I had grown so used to. But what’s more: the configuration felt simple and understandable, spread across just three trivial files: to specify global settings like the theme or the fonts to use, to select which Doom-specific modules need to be enabled, and to install non-Doom packages.

1 views
André Arko 1 months ago

<code>rv</code> plan and progress update

This post was originally given as a talk at Rubycon IT 2026 . The slides are also available. It’s been a while since I first talked about , a Ruby manager for the future . I’d like give an update on what we’ve done since then, but I’m going to recap some of that earlier post first to give context for the updates. If you still remember what I said back then, you can jump to the new stuff right away . Either way, I’m excited to update you about the work that we’ve been doing, and show exactly how far we’ve gotten. For the last ten years or so of working on Bundler, I’ve had a wish rattling around: I want a bigger, better dependency manager. It doesn’t just manage your gems, it manages your ruby versions, too. It doesn’t just manage your ruby versions, it installs pre-compiled rubies so you don’t have to wait for ruby to compile from source over and over. And more than all of that, it makes it completely trivial to run any script or tool written in ruby, even if that script or tool needs a different ruby and gems than your application does. For the entire ten years of daydreaming, I’ve been hoping someone else would build it and I could just use it. Then I discovered that someone did build it… but for Python. It’s called . In August 2024, uv version 0.3 shipped, and it had all the features I had wished for, and even more that I hadn’t thought to wish for. At this point, I’ve been using for almost a year and every time I use a project written in Python, the experience is delightful. Not only can you run a command directly out of packages that aren’t even installed, you can run a command that requires a Python version you don’t even have installed. takes care of installing the right python, installing the right packages, and running your command, in just a second or two. Whether you want to run a CLI tool, a webapp, or a random script, always ensures the environment is correct as part of running the command. Need Python? Installed. Need a package? Also installed. Never again run on a new package, only to realize later you broke something old. No more setting up dependencies manually, only to discover later that the script stopped working inside cron while you weren’t checking on it. Last year, my long time consulting job disappeared and I found myself looking for something to replace it. One of my ideas was to start a company inspired by Geomys in the Go language, offering expert advice from open source maintainers, but the idea felt weak to me without a “spotlight” project to show off our expertise. In July of this year, I finally realized that these two ideas could go together extremely well—our company can show our expertise by building this developer tool, and clients paying for our advice to solve their problems can ensure we are able to support and expand the tool. I talked to some Ruby friends about the idea, and it resonated with them, so we started working on both the company and the open source project. Today, Spinel Cooperative has a website at spinel.coop , and has a website at rv.dev . The team has expanded, and now includes David Rodriguez , the former lead developer of RubyGems and Bundler, as well as former Rails core team members Kasper Timm Hanson and Sam Stephenson . Sam has even done some of this work before, as the original creator of and the tool. Our goal for is to be a new kind of developer tool. You don’t need to install and then pick a Ruby version, install it, and then update RubyGems and Bundler, and then your gems. Instead, you just run the project command you care about, and everything is handled. It’s a version manager, and a dependency manager, and more than both of those things. With that vision in place, we were faced with a very practical question: what can we build that would be useful right away? After some prototyping and a lot of discussion, we landed on precompiled rubies for development work as the most useful place to start, and got to work. After deciding what our first feature would be, we had to pick a language to use. We landed on Rust to build , for two main reasons. The obvious reason is that Rust produces very fast results, and that seems to also be why is written in Rust. The less obvious reason is based on years of trying to onboard new contributors to Bundler and RubyGems—it turns out if you are a Ruby developer, you unfortunately don’t (yet) know the subset of Ruby that we have been forced to use to write Bundler and RubyGems. There are two major things that basically every Ruby program does that you can’t do if you are managing gems. First, you can’t use any gems. If you want to use code that’s inside a gem, you need to copy that code wholesale into Bundler or RubyGems, and then you need to constantly update it anytime that gem has any changes. Second, you can’t use anything with native extensions, ever. JSON gem? Psych gem for YAML? Completely impossible, because Bundler and RubyGems need to be installable even if there is no compiler present. So with those constraints in mind, and with our goal set to “a tool so fast you normally can’t even tell it’s running”, we settled on Rust, and started building a CLI. I’ve used Rust for smaller personal projects in the past, but I had never created a full CLI tool. I am happy to report that the library for creating CLIs in Rust is great, and I recommend it to anyone interested in Rust CLIs. The next piece that we needed was the actual precompiled Rubies themselves. To install Ruby quickly, we needed to be able to skip over the dance. There are a couple of big projects out there compiling Ruby in advance, but they are mostly for use on servers. The GitHub action, and the official Ruby docker images are both based on the project originally started as part of . Unfortunately, those aren’t usable for our needs because they aren’t statically compiled and relocatable . Statically compiled (as opposed to dynamically compiled) means that Ruby copies the code from a shared library into its own binary. Now for small aside (but it’s relevant, I promise). Have you ever had trouble compiling Ruby because of OpenSSL? I’m pretty sure every Ruby developer has. Have you ever had an already-installed Ruby suddenly stop working because of OpenSSL, and you had to install it again? That also seems extremely common, thanks to Homebrew’s aggressive auto-update policy. The good news is, fixes both of those problems. By putting OpenSSL inside the Ruby binary, they can never get separated, and those errors can never occur. There is a tradeoff here—if there is a critical security flaw in OpenSSL, we will need to compile Ruby again to include the critical security update. The first reason we are okay with this tradeoff is that OpenSSL doesn’t have huge security issues very often. The second reason we are okay with this is that your production servers are probably using the official Ruby docker images and not Ruby installed by , so it’s even less of a concern. In the end, the closest existing system we were able find was Homebrew’s project. That’s how Homebrew builds the Ruby install that Homebrew itself runs on. The Homebrew team built some excellent infrastructure for building a statically linked Ruby, including libyaml, openssl, and other required libraries. The big thing Homebrew did not do was build more than one single version of Ruby, or support YJIT. We’ll come back to that in a bit. The part of is about builds being relocatable. Since Homebrew needs to be able to install into on x86, but on Apple Silicon, and into any user’s home directory for Linuxbrew, they need to be able to take a single precompiled Ruby and put it in any location on disk. That’s another one of the requirements that isn’t met by the or Docker image Rubies—if you move them to another directory, they stop working. Using Homebrew’s as a base, we were able to start with macOS ARM and Ubuntu x86, add Ubuntu on ARM, and then build every version in the Ruby 3.4.x series. Once we had those ready, then we asked ourselves: how much tooling do we need before this is useful for developers? Just linking to a repo with Ruby binaries in it isn’t really that helpful, because if it’s harder to use than running , it’s not really a better or faster experience. We landed on a small set of useful features for the first version: the latest Ruby minor version, 3.4, built for macOS ARM and Linux x86, with support for files, and automatic Ruby version switching just in zsh. After a few weeks of work, could switch between installed Ruby versions in zsh, but most importantly it could install precompiled Ruby on macOS and Ubuntu in one second flat. Yes, you heard that right. . Wait 1 second. Done. You can run Ruby commands now. With that functionality in place, we released version 0.1. Immediately after our initial release, we were hit with an extremely nice surprise: someone from the Homebrew core team decided to add directly to homebrew-core within a few days of 0.1 being released. That makes it much easier to install and try it out, and completely removes any need for us to create and maintain our own custom homebrew tap, which is a very nice bonus. With proof our concept working and users installing v0.1, we immediately started to expand the core functionality. We added support for bash, fish, and nushell. We spent several weeks working through the issues involved in compiling every single point release of Ruby 3.3 and 3.4. Then we spent another two weeks working through all of the issues compiling all of those Rubies with YJIT turned on. Then we spent another two weeks working through the issues of compiling all of those Rubies for macOS on x86, and for Linux on ARM. Once all of those Ruby versions were available, we shipped version 0.2. Building on our progress with Ruby versions, we added more versions of Ruby: every 3.2.x version, and all of the 4.0 prereleases and final releases. After hearing from and users who wanted to re-use their file, we added support for that file as well. Automatic Ruby switching will respect files, and will update the version written into the file if it exists. As a fun easter egg, we also added a precompiled binary of the oldest version of Ruby with published source code, 0.49. All of those features shipped as version 0.3. At that point, we took a break to take stock of the project, our goals, and our plan. 0.3 is a pretty good Ruby version manager, and a viable option in the pantheon of Ruby version managers like , , or . While precompiled Ruby is great, we want superfast installs for not just Ruby but also all gems and bundles. But Bundler is huge! It took three of us a year to build originally, and has had 15 years of additions by dozens of contributors. We can’t build everything we want in a month, or even three. After much brainstorming and discussion, we made a plan to deliver real-world useful tools that would build on each other, so we can work our way up to a complete application dependency management tool. First, we would need to understand gems themselves, parsing the compact index of gem metadata and then reading gemspecs and .gem files. Then we would need to install gems, not just copy files into the right places but also running the steps to compile native extensions correctly. Once we can install gems into the right places, we need to parse the format to install bundles. Then we need to build a resolver, the process that transforms a into a by taking a list of gems and producing a graph of dependencies that are all compatible with each other. With that plan, we got back to work. The first feature from that plan was , which does the same thing as . This is the same thing that you use when you’re running your tests in CI, or that you use when you’re deploying your application to a server. As long as you haven’t made any changes to your Gemfile, we can read the lockfile, install all of your gems, and set up the environment so that your application is able to run. To build this, we implemented a compact index client, gemspec parsing, native gem extension compilation, and gem installation. And it works! Starting with 0.4, you can clone a project, install your gems, and run the project. The next release included a small sidequest to add Windows and PowerShell support, as well as compiling Ruby binaries against musl libc so they will work on Alpine Linux. We use the precompiled binaries for Windows produced by the ruby-installer project, which turns out to be the only existing project that precompiles Ruby. This release also included the next two steps of our incremental plan: first, automatically managing Ruby version and installation. If you , you don’t even need to have Ruby installed, will make sure that happens if needed. The second part was the next step of our gem management plan, taking a list of gems and resolving dependencies to install. When combined, those two features unlock uv-style “tools”, where a gem CLI can also have an auto-managed Ruby version. Have you ever used to get a CLI tool only to find out later your Ruby version changed and broke the CLI? tools completely prevent that problem. With tool support, we could then add gem auto-install to create . Run any gem command, even if it’s not installed! With version 0.5, you can go straight from to a Rails app from in 10 seconds flat. At the SF Ruby conference late last year, a random conversation with Kokubun, the ruby-core member and maintainer of YJIT and ZJIT spawned an idea: what about testing against the latest Ruby? The Ruby version managers that compile Ruby onto your own machine handle this by adding a version of Ruby named “dev” that just means “check out the ruby git repo and compile the newest commit”. It was only a few days of effort to get the ruby compiler handling ruby from git, but it was a few weeks of experimenting before figuring out how to handle a “version” that keeps the same name but changes every day. It was worth it, though, because now you can install and test against the latest daily Ruby build as easily and as often as you want, without ever waiting for Ruby to compile. It’s not quite finished yet, but the next step in our incremental plan is to handle the same responsibilities that the command handles: evaluate the Gemfile, resolve the graph of gem versions, update the Gemfile.lock if needed, and install all of those gems. When I was learning about uv, this part absolutely blew my mind because is so fast that it runs as part of every command! Coming from Bundler, that was completely incredible. I could not imagine running before every because that would make everything so, so slow. It’s very exciting to work toward that for Ruby. That’s not all we have planned, either. The roadmap includes project setup and task management, making it easy to run scripts or other commands with your Ruby and gems available. Managing gems for scripts means adding a config file as a comment inside the ruby script file, with the Gemfile-like information needed to install gems. can then auto-install those gems in order to run the script. It’s not yet clear how long it will take to finish this initial list, even after it’s done we have a ton of additional ideas. As we wind things up, I want to show off a couple of things that I personally think are the best and coolest uses of rv. this isn’t necessarily the stuff that you’ll do the most often, which is fine, but these examples are super impressive to me, coming from the nightmare of ruby version building. First up, : once you have , you don’t need to think about Ruby, you don’t need to think about gems, you just run the command that you want to run, immediately. is fast enough that you can start on a machine with no Ruby installed, run , and be generating that app in less than 10 seconds. One command to install Ruby, install Rails, install all 60 gems that Rails depends on, and run the command you originally wanted. It’s just an incredibly delightful experience to not need to think about Ruby versions or gem dependencies when you want to run something. Another thing that has come extremely in handy is the ability to write scripts across Ruby versions, and know those scripts will work whether or not those Ruby versions are installed when the script runs. You don’t need to care about installing Ruby, or even checking for Ruby at all. Just run the command you want to run and will take care of all that stuff. Finally, the commands (inspired by ) allow you to use CLIs without having to think about Ruby versions, or global gems, or bundled gems, or what application directory you are in. Tools always get the Ruby version and the gems that they need to work, regardless of your currently chosen Ruby version and app and gems. For me, has unlocked the ability to use Ruby CLI tools again, and I love that power and flexibility. In the end, we want to live in a future where anyone can run a Ruby command, or tool, or application in a few seconds (or less!). We’re building that future for ourselves, and we welcome everyone else. Visit rv.dev to see the project on GitHub and give it a try! We’d love to have your help building it.

0 views
Simon Willison 1 months ago

Initial impressions of Claude Fable 5

I didn't have early access to today's Claude Fable 5 release, but I've spent the past ~5.5 hours putting it through its paces. My initial impressions are that this is something of a beast . It's slow, expensive and has been quite happily churning through everything I've thrown at it so far. As is frequently the case with current frontier models the challenge is finding tasks that it can't do. First, let's review the key characteristics. Anthropic claim that Claude Fable 5 offers the same performance as Claude Mythos 5, except with much more strict guardrails in place to prevent it being used for harmful things. Those guardrails trigger often enough that the Claude API has new mechanisms for letting you know when you hit them, and even has a new option to request it falls back to another model automatically if something gets rejected. Claude Mythos 5 is out today as well, Anthropic say it "Shares Claude Fable 5's capabilities without the safety classifiers". The models have a 1 million token context window, 128,000 maximum output tokens and a knowledge cut-off date of January 2026. They are priced at twice the price of Claude Opus 4.5/4.6/4.7/4.8: $10/million input tokens and $50/million output tokens. There's no increase in price for longer context usage. Other than that the upgrade guide is substantially thinner than the similar guide for Opus 4.8 . The best way to describe Fable is that it feels big . Not just in terms of speed and cost, but also in how much it knows. Here's an example prompt, which I used to compare Fable's knowledge with Opus 4.8 (typo included): Opus 4.8's reply started like this : I don't have a reliable, comprehensive, and date-verified list of all of Simon Willison's open source projects, and I want to be honest about that rather than risk giving you inaccurate dates or fabricated entries. Here's what I can tell you with reasonable confidence: Well-known projects by Simon Willison: LLM (~2023) – A command-line tool and Python library for interacting with large language models. Datasette (~2017) – A tool for exploring and publishing data, probably his most prominent recent project. sqlite-utils (~2018-2019) – A Python library and CLI tool for manipulating SQLite databases, often used alongside Datasette. Django (2005) – He is one of the co-creators of the Django web framework, along with Adrian Holovaty, originally developed at the Lawrence Journal-World newspaper. He also maintains a large ecosystem of Datasette plugins and many smaller utilities (often with names like , various tools for importing data, etc.). That's not bad. Here's the start of the response for the exact same prompt from Fable 5 - both models were running without access to search: I think you mean Simon Willison — the prolific open source developer, co-creator of Django, and creator of Datasette. An important caveat upfront: a truly complete list isn't feasible. Simon Willison is famously prolific — he has published hundreds of repositories (Datasette alone has 100+ plugins, many written by him), and my knowledge has a cutoff date, so I'll be missing his most recent work. Below are his notable projects with approximate initial release dates, most recent first: (Here's GPT-5.5 for good measure . It listed even more projects than Fable did!) In the past I've stated that I don't care about how much models know - I want them to be able to manipulate text and code in useful ways and actively look up the information they need via search tools, not bake it into their weights. But knowledge like this is a reasonably good proxy for model size - you can cram a whole lot more details about the world into a larger number of parameters. Does knowing more stuff mean the model is better at the tasks we pose to it? I can certainly imagine how a coding model with deeper knowledge of modern libraries and patterns could crunch through coding tasks more effectively. Is Fable really bigger than Opus? Anthropic haven't said anything about model size, so all we have are tea-leaves, but the speed, pricing and my own poking at its knowledge make me think that it's a large model. Maybe the largest yet from any vendor. Anthropic made Fable 5 available across all of their surfaces - the Claude.ai chat interface, Claude Code for web, Claude Code CLI and Claude Cowork as well. The model is available "until June 22nd" on the subscription plans (I'm on $100/month Max at the moment), after which it will be billed extra. Claude.ai is often under-estimated. Since September 2025 every chat has had access to a full container environment to run code, including the ability to install additional packages and even clone repositories directly from GitHub. Last week I released micropython-wasm , a Python library that uses wasmtime to run a custom build of MicroPython in WebAssembly to act as a sandbox for untrusted Python code. I decided to see if Fable could upgrade that to running full Python instead. I started with this prompt: Fable identified that it could use Brett Cannon's cpython-wasi-build builds for this, but was unable to download them itself due to environment restrictions. So I grabbed the two zip files from that page and uploaded them to Claude: ( , as attachments) And that was that. It churned away for a few minutes and got the entire thing working. Part of the response included: I tried the cleaner single-zip-stdlib approach to shrink the filesystem surface, but CPython's bootstrap fails to find from inside a zip without more prefix finessing — the directory-preopen approach works reliably, so that's what the PoC uses. The zip path is solvable but needs /frozen-getpath work. Then a little later: ... and it gave me this 13.9MB cpython_wasm-0.1.0-py3-none-any.whl file. You can try running Python code in a sandbox using that wheel URL and like this: Here's the full chat transcript . This was a very strong start. Before I'd realized it was Fable day, my stretch goal for today was to add a new feature to Datasette Agent : I wanted tool calls within that agent software to gain the ability to pause mid-execution and request approval directly from the user. This felt like a suitably meaty task to throw at the new model. Over the course of the day Fable not only solved that problem , it also identified and then implemented four issues in my underlying LLM library that would help support this kind of advanced pause-resume mechanism in tool calls. It got everything working first using somewhat gnarly hacks, but the moment I told it that changes to LLM itself were in scope it set to work unraveling the hacks and turning them into supported features of LLM instead. My stretch goal turned into LLM 0.32a3 , almost entirely written by Fable. Here are the release notes: Driven by the needs of Datasette Agent 's human-in-the-loop feature, made the following improvements to how tool calls work: I'm really impressed with the quality of API design, tests, code and documentation that Fable put together for this. I spent several hours on it today, but it feels like several days' worth of work. I recently started using AgentsView to help track my local LLM usage across all of the different coding agents. I published a TIL today about adding custom Fable pricing to that tool, which I expect will not be necessary in the very near future. After setting the price, I ran this command to start a localhost web server to explore my usage: Here's the treemap showing the breakdown of my Fable usage across various projects today: I used $110.42 worth of tokens today, all as part of my $100/month subscription. I ran "Generate an SVG of a pelican riding a bicycle" against all five thinking effort levels with Fable. Here are the results , including the token cost for each one: It's interesting that high ended up using fewer tokens than medium for this particular run. Here are the Opus 4.8 pelicans for comparison. You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . LLM (~2023) – A command-line tool and Python library for interacting with large language models. Datasette (~2017) – A tool for exploring and publishing data, probably his most prominent recent project. sqlite-utils (~2018-2019) – A Python library and CLI tool for manipulating SQLite databases, often used alongside Datasette. Django (2005) – He is one of the co-creators of the Django web framework, along with Adrian Holovaty, originally developed at the Lawrence Journal-World newspaper. files-to-prompt (April 2024) – concatenates files into a single prompt for LLMs datasette-extract (2024) – plugin for extracting structured data using LLMs LLM (May–June 2023) – CLI tool and Python library for interacting with large language models, plus a large ecosystem of plugins (llm-gpt4all, llm-mistral, llm-claude, etc., 2023 onward) symbex (June 2023) – search Python code for symbols ttok and strip-tags (May 2023) – CLI tools for token counting and HTML cleanup for LLM pipelines datasette-lite (May 2022) – Datasette running entirely in the browser via WebAssembly/Pyodide shot-scraper (March 2022) – automated website screenshots via Playwright s3-credentials (November 2021) – CLI for creating scoped AWS S3 credentials django-sql-dashboard (2021) – SQL reporting dashboards for Django Dogsheep suite (2019) – personal analytics tools: twitter-to-sqlite, github-to-sqlite, healthkit-to-sqlite, dogsheep-beta, etc. sqlite-utils (2018) – CLI and Python library for manipulating SQLite databases Datasette (November 2017) – his flagship project; tool for exploring and publishing data csvs-to-sqlite (2017) – convert CSV files to SQLite Various early tools (~2007–2010) – soupselect, json-head, geocoders, and others Django (developed 2003–2005, open-sourced July 2005) – co-created with Adrian Holovaty at the Lawrence Journal-World Tool implementations can declare a parameter named in order to be passed the object for the current invocation. This allows them to access the current . See Accessing the tool call from inside a tool . #1480 Every tool call is now guaranteed a unique - providers that do not supply one get a synthesized -prefixed ULID. #1481 Tools can raise a exception to cleanly pause the tool chain, useful for things like waiting for human approval. The exception propagates to the caller with and (completed sibling results) attached, and no model call is made with a placeholder result. See Pausing a chain from inside a tool . #1482 Failure semantics for concurrent tool execution: async sibling tool calls always run to completion before a pause or hook exception propagates. #1482 Chains can now resume from a history ending in unresolved tool calls: the calls are executed through the normal / machinery before the first model call, skipping any that already have results. The method also accepts a new optional argument for executing an explicit list of objects in place of the calls requested by the response. See Resuming a chain with pending tool calls . #1482 Fixed a bug where the async tool executor silently dropped calls to tools not present in - these now return results, matching the sync executor. #1483

0 views
Simon Willison 1 months ago

Running Python code in a sandbox with MicroPython and WASM

I've been experimenting with different approaches to running code in a sandbox for several years now, but my latest attempt feels like it might finally have all of the characteristics I've been looking for. I've released it as an alpha package called micropython-wasm , and I'm using it for a code execution sandbox plugin for Datasette Agent called datasette-agent-micropython . My key open source projects - Datasette , LLM , even sqlite-utils - all support plugins. I absolutely love plugins as a mechanism for extending software. A carefully designed plugin system reduces the risk involved in trying new things to almost nothing - even the wildest ideas won't leave a lasting influence on the core application itself. My software can grow a new feature overnight and I don't even have to review a pull request! There's one major drawback: my plugin systems all use Python and Pluggy , and plugin code executes with full privileges within my applications. A buggy or malicious plugin could break everything or leak private data. I'd love to be able to run plugin-style code in an environment where it is unable to read unapproved files, connect to a network, or generally operate in a way that's risky or harmful to the rest of the application or the user's computer. My interest covers more than just plugins. For Datasette in particular there are many features I'd like to support where arbitrary code execution would be useful. I've already experimented with this for Datasette Enrichments , where code can be used to transform values stored in a table. I'd love to build a mechanism where you can run code on a schedule that fetches JSON from an approved location, runs a tiny bit of code to reformat it into a list of dictionaries, then inserts those as rows in a SQLite database table. My goal is to execute code safely within my own Python applications. Here's what I need: Web browsers operate in the most hostile environment imaginable when it comes to malicious code. Their job is to download and execute untrusted code from the web on almost every page load. Given this, JavaScript engines should be excellent candidates for sandboxes. Sadly those engines are also extremely complicated, and are not designed for easy embedding in other projects. Most of the v8-in-Python projects I've seen are infrequently maintained and come with warnings not to use them with completely untrusted code. WebAssembly is a much better candidate. It was designed from the start to support all of the characteristics I care about and has been tested in browsers for nearly a decade. The wasmtime Python library is actively maintained and has binary wheels. WebAssembly engines like wasmtime run WebAssembly binaries. Some programming languages like Rust are easy to compile directly to WebAssembly. Dynamic languages like JavaScript and Python are harder - they support language primitives like , which means they need a full interpreter available at runtime. To run Python we need a full Python interpreter compiled to WebAssembly, wired up in a way that makes it easy to feed it code, hook up host functions and access the results. Pyodide offers an outstanding package for running Python using WebAssembly in the browser, but using Pyodide in server-side Python isn't supported. The most recent advice I could find was from October 2024 stating "Pyodide is built by the Emscripten toolchain and can only run in a browser or Node.js". The other day I decided to take a look at MicroPython as an option for this. The MicroPython site says: MicroPython is a lean and efficient implementation of the Python 3 programming language that includes a small subset of the Python standard library and is optimised to run on microcontrollers and in constrained environments. WebAssembly sure feels like a constrained environment to me! I had GPT-5.5 Pro do some research for me , which turned up this PR against MicroPython by Yamamoto Takahashi titled "Experimental WASI support for ports/unix". It then produced this research.md document , so I let Codex Desktop and GPT-5.5 high loose on it to see what would happen: It worked. I now had a prototype Python library that could execute Python code inside a WebAssembly sandbox! The trickiest piece to solve was persistent interpreter state. The WASM build we are using here exposes a single entry point which starts the interpreter, runs the code and then stops the interpreter at the end. This works fine for one-off scripts, but for Datasette Agent I want variables and functions to stay resident in memory so I can reuse them across multiple code execution calls. A neat thing about working with coding agents is that you can get from an idea to a proof of concept quickly. I prompted: After some iteration we got to a version of this that works! In Python code you can now do this: Under the hood this starts a thread, sets up a request queue and then sends messages to that queue for the command, each time waiting on a reply queue for the result of that execution. Inside WASM the MicroPython interpreter blocks waiting for a host function to return the next line of code, which it runs on before calling when each block has been successfully executed. The other piece of complexity was supporting host functions, so my Python library could selectively expose functions that could then be called by code running in MicroPython. Codex ended up solving this with 78 lines of C , which ends up compiled into the 362KB WebAssembly blob I'm distributing with the package. I am by no means a C programmer, but I've read the C and had two different models explain it to me (here's Claude's explanation ) and I've subjected it to a barrage of tests. The great thing about working with WebAssembly is that if the C turns out to be fatally flawed the worst that can happen is the WebAssembly execution will fail with an exception. I can live with that risk. Memory limits are directly supported by wasmtime. CPU limits are a little harder: wasmtime offers a "fuel" concept to limit how many operations a WebAssembly call can execute, and that's the correct fit for this problem, but the units are hard to reason about. I'm experimenting with a 20 million default "fuel" setting now but I'm not confident that it's the most appropriate value. The alpha is now live on PyPI . You can try it from your own Python code as described in the README . I've also added a simple CLI mode in version 0.1a2 which means you can try it using without first installing it like so: You can also try it in Datasette Agent like this: Then navigate to http://127.0.0.1:8001/-/agent and run the prompt: Having complained about immature, loosely-maintained sandboxing libraries, it's deeply ironic that I've now built my own! I deliberately slapped an alpha release version on it, and I'm not ready to recommend it to anyone who isn't willing to take a significant risk. I've put it through enough testing that I'm OK using it myself. I've shipped my first plugin that uses it, datasette-agent-micropython . I've also locked GPT-5.5 xhigh in that Datasette Agent plugin and challenged it to break out of the sandbox and so far it has not managed to. I'm hoping this implementation can convince some companies with professional security teams and high-stakes problems to commit to using Python in WebAssembly as a sandboxing approach and open source their own solutions. You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . Why do I want a sandbox? What I want from a sandbox WebAssembly looks really promising here MicroPython in WebAssembly Building the first version Try it yourself Should you trust my vibe-coded sandbox? Dependencies that cleanly install from PyPI , including binary wheels across multiple platforms if necessary. I don't want people using my software to have to take any extra steps beyond directly installing my Python package. Executed code must be subject to both memory and CPU limits. I don't want to crash my application or the user's computer. File access must be strictly controlled . Either no filesystem access at all or I get to define exactly which files can be read and which files can be written to. Network access is controlled as well . Sandboxed code should not be able to communicate with anything without going through a layer I fully control. Support for interaction with host functions . A sandbox isn't much use if I can't carefully expose selected platform features to the code that it's running. It has to be robust, supported, and clearly documented . I've lost count of the number of sandbox projects I've seen in repos with warnings that they aren't actively maintained!

0 views
Farid Zakaria 1 months ago

The Guix Nix Abomination: Leveraging Guix derivations in Nix

Nix and Guix look like rival ecosystems, but under the hood they’re the same “Input Output Machine”. Need proof? 🕵 How about we build a Guix derivation with Nix. First let’s create a super basic derivation in Guix: Hello world . We then ask Nix to build it. 🪄 We ask to use as the Nix store and have it write its state, database and log files in alternate directories, so it does not collide or mess with Guix. Note It’s slightly more complicated. Nix happens to check its SQLite database for the derivation, so we need to register it first. The version of Guix (v1.5.0) I’m using leverages a user that runs inside a private mount namespace where is writable, but everyone else (including me) sees it as read-only. The creates a new private mount namespace so I can mount it as read-write and run the Nix command against it. We just built a Guix derivation using Nix. 🔥 How is that possible? Both take a language frontend, Nix or Guile (Scheme), that compiles to a derivation (recipe) and pass that onto a builder (daemon) that executes it to produce an output. What makes them both special is they both promise the same thing: hermetic builds . Everything needed to build the output is declared in the recipe: sources, environment variables, dependencies, etc. “Under Nix, a build process will only find resources that have been declared explicitly as dependencies. There’s no way it can build until everything it needs has been correctly declared. If it builds, you will know you’ve provided a complete declaration.” – Nix OS Website Guix, specifically the daemon, was forked from Nix early on, and as a result the two are very similar; they both share the same derivation format, ATerm , for instance. Guix is based on the Nix package manager – Guix Website That’s why our earlier example of building the Guix derivation with Nix was possible without much translation. What if we could leverage an existing recipe from Guix in Nix in its traditional ? If we could convert from one recipe file to the other, we could use the existing recipes from Guix in Nix and vice versa. Turns out this is far more feasible than you would think, because Guix is Nix or at least a superset of it. I, with the help of Claude, built a tool to do just that: guix-transfer 🤯. guix-transfer is a CLI tool for performing bottom-up translation of GNU Guix derivations into Nix. Confused? Let us see it in action: Note When you unpack a tarball, tar restores each file’s original permissions, including setuid/setgid bits. Nix’s sandbox installs a seccomp filter that blocks any call that sets these bits, returning “Operation not permitted”. Guix’s early bootstrap uses a Scheme-based (gash-utils) that treats this error as fatal, unlike GNU tar which silently skips it. The fix is , which disables the filter. If it’s not clear what we just did: we took a Guix derivation and all of its dependencies (down to the bootstrap seeds), translated it to a Nix derivation, and built it with Nix. 😲 What is this abomination and how was this possible!? It’s important to revisit what a derivation is, and how it’s used in Nix and Guix to better understand how this is possible. Let’s look at the same basic derivation from earlier, Hello World . You might want to check out my other post on Nix derivations by hand if this interests you 🤓. When we evaluate (nix-instantiate) this derivation, we get a path to a file that contains the derivation in the ATerm format: If we look at the contents of the file, we can see the ATerm representation of the derivation: This has all the information we need to build the output by the builder. At this point, it’s really not Nix specific anymore. The same applies for the Guix derivations. The derivations do not “know” whether they came from Scheme or Nix. It’s a recipe. The insight then is if we rewrite the store paths from to , and swap some builtins (i.e. for ), we can get to build it identically . 💡 The only difference in more complex derivations is that they have dependencies, which are also derivations, and the builder references them so it forms a graph of derivations, each built by the builder in topological order. The leaves of this tree for any non-trivial derivation are the bootstrap seeds: , , , etc. Guix is famous for bootstrapping itself from a 357-byte binary as source [ ref ]. Since at no point do the bootstrap seeds depend on being the prefix, the translated chain builds identically under Nix. walks a Guix graph in post-order and for each derivation: Guix’s is replaced with Nix’s . Same idea, different name. Source files are added to the Nix store, with embedded paths rewritten to their equivalents. Every reference: input drvs, builder path, args, env vars are rewritten to the mapped path. Output paths are blanked as Nix recomputes them via . The result is serialised as JSON and registered with . That’s it. No Nix expressions are generated. No . No mapping of Guix packages to nixpkgs equivalents. The Guix derivation graph is translated faithfully , and builds it. Note Interestingly, takes exactly one URL and cannot fall back. Guix derivations carry lists of mirrors, many of which are flaky or dead. Similar to Nix, Guix operates a content-addressed mirror at that serves any source its CI has ever seen. We leverage this for the instead of the original source URL. Now that we have a way to slurp Guix packages into Nix, we can start to do some diabolical combinations by combining native Nix and Guix packages together! We can take our package we built in Nix and leverage it in a Nix derivation. Nix automatically scans your derivations for anything prefixed with and tracks it as an input dependency. This is similar to how store paths are interpolated when you do something like . If writing the paths raw in the Nix expression is a little too raw for you, we can build something more ergonic pretty easily as well. has an mode that instead will emit the Nix expression for the translated . Let’s look at a slightly more complex example that uses Guix’s to build a derivation with dependencies: We can now convert this to a Nix expression with . We realise the derivation with or we can the Nix expression. Please notice that both produce the exact same hash : . We can now use this Guix derivation like any normal Nix expression, such as the ones you might encounter in Nixpkgs. That means we could even build a that is all of Guix packages available for use. My mind is blown. 🤯 Nixpkgs is known as the world’s largest package repository, and now we have made a way for it suddenly to become even larger by borrowing any derivation from Guix! The real power behind Nix are the derivations and that they are hermetic, declaring any dependency needed. We’ve seen that we can transfer these recipes to any store-based system that has similar qualities and preserve the reproducibility. Guix’s is replaced with Nix’s . Same idea, different name. Source files are added to the Nix store, with embedded paths rewritten to their equivalents. Every reference: input drvs, builder path, args, env vars are rewritten to the mapped path. Output paths are blanked as Nix recomputes them via . The result is serialised as JSON and registered with .

0 views
David Bushell 1 months ago

Are you standard.site?

Standard.site provides shared AT Protocol lexicons. Atproto is just spicy JSON and asymmetric cryptography. I’ve tried to explain atproto in more detail before. Bluesky has always supported a few open graph meta tags which I use to generate images for blog posts. That’s part of the social media game; get in people’s faces as loudly as possible. Now the game has changed! I return Monday ready to work and suddenly I start seeing a fancy new “View publication” button appear in my Bluesky feed. I’ve never wanted nor needed a button before but now that people are rocking buttons, what am I supposed to be, a buttonless pleb? I got my own button it looks like this: Mat Marquis, fellow button connoisseur, was quick with a guide to “Implementing Standard.Site” which I hastily copied. Mat used an atproto explorer to edit records which is akin to rawdoggin’ SQL in production. Given the weekly GitHub and NPM malware party this is probably a safer play than running yourself. I’m never going to remember to publish manually though. I have a janky build script and some experience with the @atcute libraries . How hard can it be? My script begins by generating a manifest of pages by parsing markdown before rendering the HTML template. I added a new step that fetches all atproto records in the collection. It cross-references the paths in my manifest. Any unknown path has the record deleted. It then iterates the manifest and either updates the atproto record (if title or description has changed), or creates a new record if none existed. Finally it adds the atproto URI to the manifest for the element. Now my blog is standard.site and I have a fancy button to prove it. Thanks for reading! Follow me on Mastodon and Bluesky . Subscribe to my Blog and Notes or Combined feeds.

0 views
sunshowers 1 months ago

iddqd, or the hardest kind of unsafe Rust

Mirrored from the canonical version on the Oxide blog . I’m the main author of , a Rust library for maps (named after the Doom cheat code ) where keys are borrowed from values. At Oxide we use it extensively in Omicron , our control plane—the software that sits at the heart of every Oxide rack, provisions resources like compute and storage for our customers, and ensures the rack stays up and running over time. maintains in-memory indexes of the kinds of large records that show up everywhere in a system like that, such as disks or sled inventories . As a result, it must be correct: if it misbehaves, our control plane can malfunction in ways that are unpredictable and hard to diagnose. consists of a fair amount of unsafe code underneath. There’s been some recent concern over the amount of unsafe code in Rust rewrites, so I thought I’d write about some of the unsafe code in and how we try to tame it. With Rust’s standard library maps, keys are stored separately from values. Let’s say you want to store a map of records keyed by an email address. With , you might write something like: This approach has what I consider to be a pretty major downside: the key (email) is not stored in the same struct as the value (the rest of the record). How would you handle this? One way is to pass around both the email and the user, for example with : As an extension, you could maybe have a struct which combines both at fetch time: In practice, this gets quite unwieldy when you have lots of different types of records that need this kind of treatment. Alternatively, you could duplicate the email across the key and the value: But that has the risk that the emails stored in the key and the value fall out of sync. provides a better alternative. With , you can write something like: At Oxide, this has proven to be an invaluable pattern: many of the records in our control plane are quite large (think database lookups), and is a great fit for them. It also comes with several other features that directly address pain points we’ve dealt with at Oxide. A few worth calling out: Like many of our other crates, is built for Oxide’s needs but is generally useful to the Rust community. You’re welcome to use it in your own projects as well. Before I move on, I want to talk about what it means for to exist in a memory-safe language. The big concern is undefined behavior (UB): a program behaves in an unpredictable way because core assumptions made by the language or compiler have been violated. Rust calls an abstraction sound if no safe code can use it to cause UB, and unsound otherwise. The vast majority of Rust code is safe, which (assuming that any unsafe used by the safe code is sound) means that no UB can occur. However, due to the fundamental undecidability of static analysis (see Rice’s theorem ), it is impossible for the Rust compiler—or any kind of algorithm that terminates—to accept all programs without UB and reject all programs with it. Therefore, when writing such an algorithm, its authors have to make a decision: do they reject some programs without UB, accept some programs with UB, or both? The Rust compiler does the first: within the context of safe Rust, it rejects all programs with UB but also some without UB. (This is the correct choice!) What if your program is in the no-man’s-land where it gets rejected even though you know it doesn’t have UB? To express those kinds of programs, the Rust compiler provides an escape hatch: the keyword. By writing it, the author takes responsibility for soundness: they vouch that no safe code can use this to cause UB, and that the compiler should trust them. What does it mean for some unsafe code to not have UB? Informally, it must follow the rules of Rust —all the rules that the compiler proves for safe Rust. Some examples of these rules are: The rules of Rust are very hard to reason about! I consider them to be significantly harder than C, for example, particularly due to the rules around mutable aliasing 2 . So judicious use of unsafe Rust usually involves encapsulating it behind a safe abstraction. In all but the simplest of cases, it is not possible to be sure that such an abstraction is sound without also reasoning about the surrounding safe Rust. In this section, I’ll walk through three examples in increasing order of difficulty. The first example is the method on slices. This method splits a mutable slice into two separate parts. is a safe method, but safe Rust lacks the ability to express this kind of partitioning of a slice. Thus, requires unsafe Rust. The implementation is roughly: To be sure of the correctness of the unsafe block, you also have to consider several things about the surrounding safe code: If any of these invariants (all of which are safe Rust) are not upheld, the safe abstraction becomes unsound. The next level up is that in many cases you also have to analyze the entire module the unsafe code is present in. Consider this vector type: The soundness of relies on every other method in the module, safe and unsafe, ensuring that is correct and in bounds. Encapsulation and privacy ( cannot be mutated from outside the module) are load-bearing here. The hardest situations, though, are when unsafe code is generic and calls back into user-supplied code. In those cases, the fundamental principle is: Safe Rust code, no matter how pathological or adversarially written, must never cause unsafe code to exhibit undefined behavior. In other words, you no longer have the luxury of analyzing just the safe code you see; you must put yourself in the shoes of an attacker, considering every safe Rust program one could write, and be resilient to all of them. For example, it is possible for user code to misbehave in a way that leads to internal tables or data structures being corrupted. Safe but buggy Rust may cause unsafe Rust to be slower, produce incorrect results, or even panic, but it must not cause UB! (Why assume the worst? You might argue that well-meaning Rust developers are quite unlikely to write adversarial code. One reason for having a firm rule is that guarding against adversarial code means guarding against innocent mistakes as a corollary; a bright line makes it easier to assign responsibility for protection against UB.) An example is the trait, which has a method that returns the exact size of an iterator. A developer might be tempted to take advantage of this in unsafe Rust: This code does not cause UB if the iterator is well-behaved. But there’s no guarantee of such behavior! It is entirely possible to write a broken implementation that returns a bogus result, all in safe Rust: will write into unallocated memory in this case, so it is unsound in general 3 . Another example is the trait , where a safe but buggy implementation can return the wrong number of bytes read. See Rust RFC 2930 for more discussion about this. is in exactly this situation: it consists of generic data structures which call into user-supplied code, so it is working at this highest level of difficulty 4 . ’s architecture is pretty straightforward: it uses a combination of a list of items (internally called an ) and a table with indexes into the slots. For example, consists of: For example, an holding three items might look like the diagram below. The top row is the hash table over ; the middle row is the . Adding a new item consists of checking to see if any slots are available. If so, we read the index out of that slot, overwrite the slot with , then set to the value we read. Otherwise, we push a new slot to the end. Finally, the hash table is updated, fetching the key and computing the hash, then recording the new index in the hash table. Starting from the state above: Let’s say we want to insert a new item . Calling recycles slot from the free chain, advances to , and records the new hash entry: Removing an item given a key involves this process in reverse: first, consult the hash table using the hash computed from the key, and remove the found index from the hash table. With the index in hand, replace the with , setting the internal to the current value of . Finally, set to the index that was just replaced with . Continuing from the post-insert state above: Calling drops the hash entry for , marks slot as with its pointing at the old ( ), and updates to : This pattern generalizes to the other map types: has a few different kinds of unsafe Rust within it. Most uses of unsafe follow well-established patterns also used within Rust’s standard library —these are not dependent on user code, and a sufficiently smart borrow checker would make these uses of unsafe unnecessary. But there are a few that stand out. Perhaps the most challenging one to reason about is mutable iteration over items. For , iteration is ordered by the key. One might start off by writing something like: But this would immediately return an error: The reason this occurs is that Rust’s iterators require that the returned values do not borrow from the iterator itself 6 . Many uses of iterators do not need this capability—in particular, most for loops only operate on one item at a time: But Rust’s iterators also let you create values that outlive the iterator, via e.g. the combinator : cannot outlive itself, but can outlive the iterator returned by . Crucially, this relies on knowing that all of the returned by the iterator are disjoint—or, in other words, that the iterator never yields the same value twice. (Remember that one of the rules of Rust is that there must never be multiple references to the same memory.) But the Rust borrow checker just sees a succession of accesses into a list, and it has no way of knowing that the indexes are all different. If the human writing the code has knowledge that all of the indexes are different, though, they can use an unsafe pattern called lifetime extension to tell the borrow checker to let this code through: In the previous section, we saw how unsafe Rust acts as an escape hatch for facts that the Rust compiler cannot establish by itself—in this case, that the indexes returned by are all different. But are the indexes actually different ? Based on the storage scheme described above, it would seem like that is the case. But this is generic code which calls into user-provided functions, so there’s a chance that sufficiently pathological user code can trick the map into storing duplicate indexes. Let’s take one such example we recently found and fixed. Suppose you have an called storing five items with integer keys 0 through 4, inserted and stored in order. Here, the top row is the B-tree storing indexes in key order, while the bottom row is the : Now suppose you use the Entry API (a standard Rust data structure pattern) to fetch an item by index 0: This will successfully look up the item stored at index 0 and return an . The internally stores the fact that the item was found at index 0. Now, at this point, let’s deliberately switch the implementation for to always return , no matter the values inside. There are a few different ways to do this in safe Rust, most of which use some form of interior mutability . (This is unlikely in ordinary Rust, but that doesn’t matter : unsafe generic Rust must handle arbitrarily pathological Rust!) Now remove the entry from the map using : At this point, would attempt to remove index 0 from the B-tree, descending into the tree again but comparing only by key 7 . Because we made comparisons by key always return , the comparison would short-circuit at the first element it compares against. For example, if the first comparison lands on the B-tree entry , then that entry would be removed. But the was carrying , so it would also remove item 0 from the item set: The map is no longer in a consistent state. The index 0 is still in the B-tree index but the corresponding slot in the is vacant; meanwhile, item 2 in the does not have any pointers to it. At this point, though, the invariant that there aren’t duplicate indexes hasn’t been violated. Where it does get violated is with this next step: suppose the implementation now switches back to being honest 8 , and you insert a new entry with key 1000. Since points to item 0, will attempt to reuse that slot. It is this step that results in duplicate indexes: There are now duplicate indexes pointing to the same item, and becomes unsound. This was fixed through a combination of two things: When descending into the B-tree, also check equality against the index. In other words, if we’re looking for a key with a known index in the B-tree, compare against both and . As covered in the example above, previously, we would just check that the key is the same; the pathological implementation returned , tricking the map into believing that the entry at was the one to remove. We changed this to additionally use the index as a tie-breaker. The comparison against now resolves to (since ), so the search doesn’t spuriously match here. By construction, the only way the search can now succeed is if the stored index actually equals the one we’re looking for. If there are no matches, fall back to a linear scan. The tie-breaker eliminates spurious matches on the wrong entry, but it doesn’t guarantee that the search finds the right entry. (The B-tree is sorted by key , while the tie-breaker compares by index , and in general these two orderings are independent.) If the tree search yields no results, we must still keep the tree and the in sync. In that case, we remove the index from the B-tree by doing a linear scan without calling into user code. The remove operation then takes linear rather than logarithmic time, but that is an acceptable tradeoff, since buggy user code is the only way to encounter the fallback. This is the kind of long-range reasoning that is sometimes required to establish the soundness of a safe abstraction. The overall system relies on all of these moving parts working correctly across arbitrarily complex series of operations. Because is such a foundational data structure at Oxide, we go through great lengths to validate its correctness, including the soundness of its abstractions. There is a great deal of analytical reasoning performed by experienced Rust authors and reviewers, as described in the section above. But we also empirically validate along several different dimensions. None of the layers is sufficient by itself, but by rigorously doing all of them we can be more confident that is correct. The layers of validation we apply are: In this section, I’ll provide a brief overview of each layer. Click through the links to see more details and code samples. All blocks and patterns of unsafe Rust have been analyzed by at least one human reviewer, and up to three. Thanks to several of my Oxide colleagues for lending their time and expertise to these reviews—these discussions helped sharpen our reasoning considerably. The reasoning for each unsafe block is captured in a comment above the block. We use Clippy’s lint to verify that these comments are present. The simplest kind of empirical validation is with example-based tests : create a map, perform some operations, and ensure that the results of those operations are as expected. has unit tests for its internals and integration tests for the public API . These also live as doctests, where they double as documentation. also has a battery of pathological tests which supply buggy implementations of and other traits. In CI, both regular and pathological tests are run under the Miri interpreter , which can detect many classes of undefined behavior. (If you’re curious about the details, we run with Miri under both Stacked and Tree Borrows .) But note that some classes of UB can be detected in the regular test environment as well. For example, in the previous section we established that the tables not containing duplicate indexes is a necessary condition for soundness; this invariant can be verified outside the Miri context as well. uses two layers of randomized testing: model-based comparison against a known-correct oracle, and fault injection on top of it. For data structures, example-based tests alone are generally considered insufficient. A much stronger kind of testing is model-based testing , also known as stateful property-based testing (stateful PBT): random sequences of operations are applied to an instance of the type, and the results are compared against an oracle that is known to be correct. has extensive model-based tests against a oracle that is inefficient but clearly correct. (I’ve talked about this style of testing before on Oxide and Friends .) An extension of model-based testing is fault injection , where bugs are randomly inserted into user code. For , a fruitful avenue for fault injection has been panic safety (or unwind safety ): user code panicking in the middle of an operation must not cause the map’s invariants to be violated 9 . We systematically explore fault injection by generating random sequences of map operations, where each operation is associated with a (randomly selected) panic countdown. Each call into user code decrements the countdown by 1; if it reaches 0, the code panics. Randomized panic safety testing found several subtle bugs in ( example ), including some that escalated into unsoundness. The model-based tests also verify internal invariants after each operation, such as the no-duplicate-index condition described in the previous section. We’ve found in practice that model-based tests are too slow to run under Miri, so we verify the invariants on which soundness (and correctness) is known to be dependent instead. Another kind of validation that’s recently become available is LLM-driven adversarial review . Current-generation frontier models 10 found several ways to write pathological implementations of user code that would corrupt the map. In one notable case , an LLM constructed a way for the map’s invariants to break on a custom allocator panicking and unwinding. This is a panic safety issue (and a failure mode I hadn’t thought about before), but distinct from the existing panic safety tests that only covered panics in regular user code like an implementation. LLMs can sometimes produce plausible-but-wrong soundness claims. An effective way to guard against that is red-green TDD , using Miri as an oracle: For a soundness bug, first have the LLM write a test case demonstrating the bug, running it under Miri to show undefined behavior—the red phase. Then, after fixing the bug, rerun the test to show that it now passes—the green phase. To formally verify , one’s first thought would be to use a model checker like Kani to establish that the maps don’t violate internal invariants. But is unfortunately a bit too complicated for Kani to handle, and the required proofs blow up beyond what is feasible for the tool. The Creusot deductive verifier can help Rust developers prove their code is free of panics and other errors, but as of this writing it’s unable to prove invariants that must hold even if user code panics or otherwise misbehaves. The infeasibility of proofs is a common problem with applying formal methods to implementations, so they are often applied to a higher-level specification instead. For , the closest thing to a specification is , but it can be easily observed to be correct without needing a formal proof. How do we ensure that the implementation matches the specification? Model-based testing does a lot of heavy lifting here. While they aren’t a formal proof that matches , running model-based tests thousands of times in CI provides fairly high confidence that it does. We keep an eye on developments in this space; the in-development , which lets you embed Lean soundness proofs alongside unsafe Rust as doc comments, looks quite interesting. If you’re working on formal verification tooling, is a great candidate to benchmark against because of its crisp invariants and relatively constrained yet non-trivial scope. We’d be especially interested in proofs that hold over arbitrary trait implementations, and of refinement between and . Please reach out by filing an issue or emailing me ! Writing unsafe generic Rust is extremely difficult. Each invariant that the unsafe code relies upon has to be carefully upheld over arbitrary trait implementations, including adversarial ones. This post covers one such example: how an implementation could be carefully written to trick the map into creating mutable aliases to the same memory, and how we fixed it. No single technique can hope to catch all bugs, which is why uses several layers of validation. Humans carefully reasoning about every line of unsafe code goes a long way; example-based, pathological, and randomized tests provide empirical evidence; and frontier models can find new and surprising ways to break code that humans might not have thought of. This is a lot of machinery, but at Oxide we believe that for foundational infrastructure, this level of rigor is justified. And if you agree, we’re hiring ! Diagrams courtesy Ben Leonard. Discuss on Hacker News and Lobsters . also supports integer keys; it serializes and deserializes them as strings.  ↩︎ In C, the aliasing rules are about accesses : multiple pointers to the same memory are fine, as long as you don’t have a data race on them or violate strict aliasing . In Rust, it is UB for multiple references to the same memory to ever exist , even if they’re not actively being mutated simultaneously.  ↩︎ An exercise for the reader. There is a second, entirely different, kind of bug in : it can leak memory with some user-supplied iterators. This isn’t a soundness bug, but it is a bug nonetheless. Try working out how this can happen and why this isn’t a soundness bug. (Hint: think about user code panicking.)  ↩︎ For this discussion we’re restricting ourselves to single-threaded Rust, since iddqd is not a concurrent data structure. Concurrency amplifies the difficulty of reasoning about Rust several-fold further.  ↩︎ The index is built on top of the standard library . This required making work with an external comparator through some further unsafe trickery.  ↩︎ For a proposed version of an iterator which does let you borrow values from the iterator, see .  ↩︎ Is this second descent necessary? ’s own stores a cursor into the map to avoid a second descent, and could be restructured to hold one. But there are other ways to trigger the same issue without using , for example by using a chaotic implementation that randomly returns at times. I’m using here as the clearest way to demonstrate the bug.  ↩︎ Restoring is necessary here because, with , the duplicate-detection step at the start of would short-circuit and reject the insert. The point is that user code is free to misbehave in arbitrary ways, including changing its mind from one call to the next.  ↩︎ Rust binaries can be configured to either unwind (be recovered from ) or abort (tear down the process). Panic safety only matters with . At Oxide, we ship our system software with . However, libraries cannot assume and must work with .  ↩︎ Claude Opus 4.7 and GPT-5.5.  ↩︎ First-class support for complex keys that borrow from more than one field, without having to resort to workarounds like dynamic dispatch . Maps with two or three keys per item, each independently indexing the same record, without the usual pattern of maintaining multiple maps by hand. Serde implementations that serialize as sequences rather than maps, so that non-string keys can be serialized in JSON 1 . Importantly, these implementations reject duplicate keys. (For backwards compatibility, serialization as maps is also supported .) There are no data races. There must be no reads of uninitialized or freed memory. There must not ever be multiple aliases of references to the same region of memory. Immutable data must not be mutated. that the function was provided a , and not, for example, a the ensuring that is in bounds the fact that is , and not, for example, an , which (similar to the crate ) internally has: a , where is an enum with two variants: (where represents an integer index into the ) a , which points to either the most recently freed slot, or is a sentinel (marked as in the diagrams below) indicating that no free vacant slots are available. (The combination of and the slots forms a free chain .) a over ( is the fast hash table implementation used by the standard library) follows a similar structure, except the hash table is replaced with a B-tree index 5 . and store two and three hash tables respectively. When descending into the B-tree, also check equality against the index. In other words, if we’re looking for a key with a known index in the B-tree, compare against both and . As covered in the example above, previously, we would just check that the key is the same; the pathological implementation returned , tricking the map into believing that the entry at was the one to remove. We changed this to additionally use the index as a tie-breaker. The comparison against now resolves to (since ), so the search doesn’t spuriously match here. By construction, the only way the search can now succeed is if the stored index actually equals the one we’re looking for. If there are no matches, fall back to a linear scan. The tie-breaker eliminates spurious matches on the wrong entry, but it doesn’t guarantee that the search finds the right entry. (The B-tree is sorted by key , while the tie-breaker compares by index , and in general these two orderings are independent.) If the tree search yields no results, we must still keep the tree and the in sync. In that case, we remove the index from the B-tree by doing a linear scan without calling into user code. The remove operation then takes linear rather than logarithmic time, but that is an acceptable tradeoff, since buggy user code is the only way to encounter the fallback. also supports integer keys; it serializes and deserializes them as strings.  ↩︎ In C, the aliasing rules are about accesses : multiple pointers to the same memory are fine, as long as you don’t have a data race on them or violate strict aliasing . In Rust, it is UB for multiple references to the same memory to ever exist , even if they’re not actively being mutated simultaneously.  ↩︎ An exercise for the reader. There is a second, entirely different, kind of bug in : it can leak memory with some user-supplied iterators. This isn’t a soundness bug, but it is a bug nonetheless. Try working out how this can happen and why this isn’t a soundness bug. (Hint: think about user code panicking.)  ↩︎ For this discussion we’re restricting ourselves to single-threaded Rust, since iddqd is not a concurrent data structure. Concurrency amplifies the difficulty of reasoning about Rust several-fold further.  ↩︎ The index is built on top of the standard library . This required making work with an external comparator through some further unsafe trickery.  ↩︎ For a proposed version of an iterator which does let you borrow values from the iterator, see .  ↩︎ Is this second descent necessary? ’s own stores a cursor into the map to avoid a second descent, and could be restructured to hold one. But there are other ways to trigger the same issue without using , for example by using a chaotic implementation that randomly returns at times. I’m using here as the clearest way to demonstrate the bug.  ↩︎ Restoring is necessary here because, with , the duplicate-detection step at the start of would short-circuit and reject the insert. The point is that user code is free to misbehave in arbitrary ways, including changing its mind from one call to the next.  ↩︎ Rust binaries can be configured to either unwind (be recovered from ) or abort (tear down the process). Panic safety only matters with . At Oxide, we ship our system software with . However, libraries cannot assume and must work with .  ↩︎ Claude Opus 4.7 and GPT-5.5.  ↩︎

0 views

My Agent Stack For Automating My Personal Life

My agent manages my emails, SMS, WhatsApp, Telegram and pretty much everything to automate my personal life. People keep asking me how I use agents in real life. I mean the actual boring things that make a day disappear: reading WhatsApp and Telegram, finding someone's email, searching the web, drafting the intro, updating a document in Google Drive, creating a calendar event, checking who still needs an answer, and doing all of it across the same messy tools I already use. My answer is disappointingly simple. I use Codex as an operator on top of my actual life data. It has tools. It has data connectors. It has skills. It has a source of truth. It has enough permissions to act locally, and enough approval gates that it does not embarrass me in public. That is basically the setup. Tools, data connectors, skills, and taste. I used to do more of this in Claude Code but I have been moving the setup to Codex because GPT-5.5 is currently a better model for this kind of work. The switch from Claude Code to Codex is not really the story. The story is that once a model is good enough, the real leverage comes from wiring it into the world you already live in. The important part is that the agent can move across boundaries. My personal life is not in one app. It is split between Gmail, WhatsApp, Telegram, iMessage, Google Drive, Calendar, Notion, local files, random PDFs, browser sessions, and a contacts spreadsheet that is much more valuable than it looks. A few days ago a friend sent me a WhatsApp message. She was helping a fast-growing San Francisco AI startup recruit in France and wanted to connect their recruiting manager with a recruiter I know. I did not remember the recruiter's email. I did not know the latest funding news about the startup. I needed to search WhatsApp, search Gmail, find the recruiter's email, search the web, understand why the startup was credible, draft an intro email, include the two job links, show the draft to me, send the email after approval, and then text my friend that it was done. That is normally twenty minutes of annoying app switching. WhatsApp to Gmail to Google search to Gmail again to WhatsApp again. It is not hard work, but it is exactly the kind of work that burns attention because every step is a small context switch. With the agent, I asked for the outcome. It read the WhatsApp thread, searched Gmail for the recruiter's email, researched the startup's funding and recent news on the web, drafted the intro, waited for my approval, sent the email, and then texted my friend that the intro was done. The user-facing part took about ten seconds. The agent did the glue work (in seconds!) This is the killer pattern. The agent is not "answering a question." It is operating across my tools to complete a small real-world workflow (aka a "job-to-be-done") Another example is even more boring, which is why I like it. I got a new license plate for my car. I sent photos and context to Codex. It updated the car information Markdown file I keep in Google Drive, changed the license plate, added the registration notes, preserved the existing VIN, insurance, owners, and address, then uploaded the file back to Drive. That alone is useful, but the better version is what happens next. The agent can use browser automation to go update the same information everywhere else: FasTrak, the parking app, insurance portals, DMV-related forms, or any other web app that does not have a clean API. For clean systems, it should use an API or CLI. For messy systems, it can use the browser and it's so good! I also now use Computer Use from Codex. This is what personal agents are for. Not dramatic autonomy. Administrative continuity. I was always afraid of Openclaw yolo mode in the background. I appreciate being in control. The most important architectural decision I made was centralizing valuable personal information in Google Drive. For years, a lot of my knowledge lived in Notion. I like Notion as a human workspace, but I do not love it as the primary source of truth for an agent. The API works, but the workspace is too fluid: nested pages, databases, properties, permissions, formatting, backlinks, and a lot of UI-native structure that is pleasant for humans and annoying for models. So I used the Notion API to export the valuable information and move it into Google Drive. I was not trying to perfectly preserve the Notion workspace. I was trying to make the information agent-readable. Most of the useful information in Drive is Markdown or CSV, because those formats are easy for the agent to search, diff, edit, and upload back without ceremony. Google Drive became the source of truth because gogcli gives the agent a simple command line surface for Gmail, Drive, Calendar, Docs, Sheets, Contacts, and Tasks. This is an underrated point. You should not organize your knowledge only for the human UI. You should organize it for the agent's tool path. Agents like stable file IDs, text, tables, Markdown, CSVs, and commands that return JSON. If the agent can search it, download it, edit it, upload it, and cite where it came from, the data is useful. My personal data layer is embarrassingly simple. Google Drive holds the important docs, mostly as Markdown files and CSVs. Contacts live in a Google Sheet mirrored as a CSV. Notion exports land in Drive. Local instructions live in . Skills live as Markdown files in folders. The source of truth is not elegant. It is legible. A lot of personal productivity is just joining across this data. One fact is in WhatsApp. Another is in Gmail. The email address is in Contacts. The date is in Calendar. The document is in Drive. The agent becomes useful when it can cross those boundaries without asking me to be the glue. One of my best investment was to create a contact.csv with the phone number, email, LinkedIn etc. of all the people I know. The core tools are boring by design. I use gogcli for Google Workspace, wacli for WhatsApp, imsg for iMessage and SMS, Browser Use or browser automation for web apps, and AppleScript or macOS UI automation when there is no better interface. The hierarchy is simple. APIs and CLIs are best. Local files are great. Browser automation is acceptable. Screen automation is the last resort. This hierarchy matters because agents are only as reliable as their tool surface. Asking a model to click around a website is sometimes necessary, but it is not the happy path. A command like or is much easier for the model to inspect, retry, and reason about. Here is what the tool layer looks like in practice: None of this looks like science fiction. That is the point. The future of personal agents starts as a pile of commands that let the model operate the tools you already use. You want to reduce to a maximum the abstraction layers between the models and the APIs. Tools give the agent hands. Skills give it habits. A skill is just a small operating manual that tells the agent how to do a recurring task the way I like it done. My inbox-zero skill is a good example. It tells the agent to list Gmail inbox messages through gog, separate auto-archive from needs-review, show me the important emails, quote the substance, suggest archive or reply, draft replies, wait for explicit approval, send in the original thread, preserve all recipients, archive only after sending, keep replies short, never suggest calls unless I ask, and sign with "Nicolas." That is not a fancy architecture. It is a procedure. But the procedure is the product and... it's just text instructions. Without the skill, I have to be the prompt every time. I have to remind the agent not to send without approval, not to drop cc recipients, not to suggest a call, and not to sign with some weird corporate signature. With the skill, I say "run inbox zero," and the workflow already contains my taste. The important habit is that I improve the skill every time the agent makes a mistake. If it suggests a call when I hate calls, I add that rule. If it forgets to preserve cc recipients, I add that rule. If it archives too aggressively, I tighten the classification. The agent gets better because the procedure gets better. This is how personal agents become personal. Not by having a cute voice. By accumulating operational taste. The setup compounds because the mistakes become instructions. I do not want an agent that blindly replies to everyone. I want an agent that prepares the work, shows me the draft, and asks at the right moment. For most communication workflows, the loop is: read context, draft response, show me, wait for approval, send, confirm. Sometimes I let it send directly when the stakes are low. "Tell Hugo I am in Seattle next week" does not need a board meeting. But an investor email, a customer reply, an intro, or anything with social nuance should be drafted first. This is the difference between useful and terrifying. Read-only scanning is one trust tier. Drafting is another. Sending is another. Deleting, paying, signing, or changing account settings is a completely different tier. The future is not "the agent does everything." The future is "the agent does the tedious work and asks at the right moments." The killer workflow is not email. It is life inbox triage. Every few hours, I want to ask, "What did I miss?" and have the agent scan WhatsApp, Telegram, Gmail, SMS, Calendar, and the relevant Drive changes. Then I want it to tell me who needs a reply, what is urgent, what is stale, what can be ignored, what should become a calendar event, and what needs a document search. This is the perfect agent task because it is context-heavy, repetitive, cross-tool, and full of small decisions. Humans hate doing the first pass. Agents are good at first passes. Judgment still belongs to me. The result is not that my life becomes autonomous. The result is that I stop being the person manually digging through five apps to discover the three things that matter. If someone wants to reproduce my setup, this is the checklist. Install Codex. Install gogcli for Google Workspace. Install wacli for WhatsApp. Install a Telegram connector if you use Telegram. Install imsg for iMessage and SMS. Add browser automation, ideally through Browser Use or a Chrome controller. Add macOS automation through AppleScript and UI scripting. If your knowledge lives in Notion, use the Notion API to export the valuable parts into Google Drive. Then centralize the data. Make Google Drive the source of truth. Keep contacts in a Google Sheet or CSV. Keep important personal docs as searchable files. Keep local instructions. Keep small skills for recurring workflows. Then grant permissions carefully. Full Disk Access is needed for local files and app databases. Screen Recording is useful as a visual fallback. Accessibility is needed for clicking and typing in apps. These are serious permissions, so pair them with serious approval gates. Then write the operating rules. That is basically it. Tools, data connectors, skills, approval gates, and continuous improvement. The personal computer used to be app-operated. You opened the app, searched, clicked, copied, pasted, wrote, and sent. The agent-operated computer feels different. You state the intent, the agent gathers context, proposes the action, waits for approval when needed, executes, and reports back. Once you experience this, the old way feels absurd. Why am I manually searching WhatsApp, Gmail, Google Drive, and the web to send one intro? Why am I copying a license plate into five different portals? Why am I reading 100 messages to find the three that matter? The computer should do that. The setup is still ugly. The CLIs are rough. Permissions are annoying. Some connectors break. Browser automation is brittle. You have to write skills. You have to maintain a source of truth. But that is how the future usually starts. The first useful personal agents will not look like polished consumer apps. They will look like a model inside a terminal with access to your files, accounts, memories, and tools. That is what I use today, and every week I give it one more piece of my life to operate.

0 views
Manuel Moreale 1 months ago

fLaMEd 🔥

This week on the People and Blogs series we have an interview with fLaMEd 🔥, whose blog can be found at flamedfury.com . Tired of RSS? Read this in your browser or sign up for the newsletter . People and Blogs is supported by the "One a Month" club members. If you enjoy P&B, consider becoming one for as little as 1 dollar a month. What's going on, Internet? Kia ora, I'm fLaMEd 🔥. I'm originally from Te Awa Kairangi (Lower Hutt), now living in Tāmaki Makaurau (Auckland), Aotearoa, New Zealand with my wife and two kids. I get up every morning at 4:30 am to get to the gym before the kids get up and the day begins. I've recently picked up golf again, but find less time for that than I do for website work. You can get a better idea of what I’m into over at my website, Flamed Fury I'm not a developer, not a designer, just a guy who loves the web. Flamed Fury started in 1999. It's been through more versions than I can properly count, but the rough timeline: 5 versions before it became a personal blog, a few side quests at different domains inbetween, and finally 4 versions in the 2020 era when I landed back at flamedfury.com where I started. Started in summer 1999 on one of the free hosts, I don't remember (probably cjb.net). Moved to sweeetnet.com in 2000 through hanging out in the #sweeet IRC channel. A guy called kertiz from #sweeet took pity on my design skills and gave me a proper redesign, then stuck around contributing. Another guy fitty-two joined in. We iterated every couple of years until the dot-com bubble burst, advertising money dried up and the IRC crew drifted apart. I tried to keep it going by myself with a 2002 layout that wasn't great. But "blog" isn't really the word for any of this. The 1999–2003 version was effectively microblogging before microblogging was a thing, built around a niche (lifestyle magazine style, lol) before niche blogging was a thing either. We just didn't have the vocabulary yet. November 2003 was when Flamed Fury became a blog in the way I'd recognise the format today. Posts about polytech, nights out, whatever was going on. That lasted until 2005, then I parked it and tried being "more adult" at another domain through 2006–2008. Took a break as MySpace, Bebo, Facebook and Twitter took over. Came back in 2012 with a niche barbeque blog and carried on with it for six years before archiving the whole thing in 2018, once I realised how much I absolutely loathed niche recipe blogging. Revived the fLaMEd persona in 2019 on a new domain (Hugo + Netlify). In 2021 I settled back on flamedfury.com with Eleventy on Neocities. Two redesigns later and a move to a local VPS, here we are. Every version of this site, going back to 1999, has been the same instinct: a personal site as a place to be yourself on the web. The 1999 version was more of a microblogging website with three friends collaborating around celebrity magazine scans, that's where the era pointed. The 2026 version is the opposite. Everything and nothing, no algorithm to satisfy, no brand. Different tools, same instinct. There's a longer version of this story I'll get round to writing on the site soon. It's in draft , I promise. Hit me up if you want to see me finish it. Inspiration for what I put and write on my website comes from across the web and life experiences. A gig, a new record, a beer, a trip with the family, or any number of posts I find across the web gets me thinking. Storytelling, sharing my experiences and interests. I love monthly recaps to populate my now page, reflections of last night's gig, new (usually local) music finds, a fun time out with my friends or family. Drafts begin as a note on my phone, my notebook before I find myself with a spare opportunity at my computer. I'll begin with these rough notes and begin fleshing them out. I'll have a couple of tabs open to grab details and links of what I'm talking about to sprinkle through the post. Sometimes I'll start a draft and they'll sit there for days, weeks, and sometimes months in an untracked markdown file in Codium. Depending on what I'm writing about I won't have any proof reading. If I'm writing about something topical about the web I'll often have xandra or one of the other 32-Bit Cafe crew read over it and give me some pointers or a thumbs up. Then after sitting on it for a minute, an hour or a day, I'll publish it. Other pages on the website will get worked on and usually published in unfinished states and I'll continue to work on these over time - nothing is ever really finished is it? My ideal creative environment is in my home office, at my desk or couch in silence. I might listen to a few songs or watch a couple music videos to get me in the zone, but when it comes to focus time, all noise off and I work in silence, often talking to myself. If I'm away from home and I get a moment to myself, it's either at a table, kitchen bench or an arm chair. Hopefully with silence, but usually with the chaos of family life going on around me. Our kids are young, they're busy, noisy and need lots of attention so focus time these days is few and far between :) Do I believe the physical space influences my creativity? Heck yeah, if I'm not in the office, then a walk around the block or through the village listening to music will help me get creative - as long as I get those thoughts out of my head before they dissapear. If I'm travelling, then any beautiful location might inspire some spark. I use Eleventy for building my website. I originally started with Eleventy Excellent by Lene Saile , but it's evolved beyond that over the years. I often check in with her when she releases new versions to make sure I take in any key updates, but also find some changes I've made find their way back into the starter template :) These days flamedfury.com runs on an NZ-based VPS to keep the site close to home. I use a local domain registrar for my domains. Deployment is a simple then rsync directly to the VPS. To participate in the web, I've implemented a bunch of IndieWeb features, Webmentions, h-cards, h-entries and of course provide a number of Atom/RSS/JSON feeds which are syndicated to Mastodon through EchoFeed to meet people where they are. My Bookmarks are backed by the 32-Bit Cafe's instance of Linkding and pulled into my website at build time and shared via Atom/RSS/JSON and EchoFeed. I run an instance of Forgejo on my homeserver and commit the project there multiple times a day. I don't think so. If anything I would have tried to preserve everything rather than ditching things over the years. I've managed to recover a lot of the old stuff through old CD-Roms where I'd burnt old versions of the website or from the Wayback Machine. I would have definitely tried to keep in contact with a lot of the old crew from IRC. We drifted apart before it was easy to keep in contact with each other. I do regret losing those early relationships. I'm really happy with how I've managed to salvage a lot of the old stuff and merge it into what the website is today. It really is a labour of love. All in NZD. The domain is $39/year and my VPS is $82/year. All the other infrastructure on my home network is sunk cost over the years and I'm not sure how I'd put a $ value against that. I haven't made money from my website since 2001 along with the original internet advertising bubble burst. I did have a go with ads and affiliate marketing with the barbeque blog, but that left a sour taste in my mouth. I'm a fan of services like ko-fi and the like but haven't looked into setting it up for myself - not sure if anyone would be interested in supporting me. I throw money at the 32-Bit Cafe's ko-fi and contribute to infrastructure costs there as well as my time to help moderate and run the forums and will throw other bloggers tips here and there through their ko-fis, and will buy sticker packs wherever I see them being sold in the wider hobby web community. When I need some new graphics for the website I'm always on the look out for a commission and will happily pay for talented graphic designers services. I support a few independent journalists through their newsletters that I enjoy reading and support a local independent news/media website to help keep the lights on there as I enjoy their local content. A great way to keep up with what's going on in the country and the world without the doom-and-gloom. What's my position on people monetising personal blogs? Go for it as long as it's not intrusive or full of dark patterns. Keep it personal and creative. I love the sticker packs or graphic commissions. So many to mention! More at my blogroll and links pages. Who do I think you should be interviewing next? Hit up Chris Burnell if you have time before wrapping the project up :) If you're into making websites, or you want to start you should most definitely come and check out the 32-Bit Cafe - our small community of the web where we welcome hobby web developers of all skill levels and help each other out building our websites. We have monthly web weaving workshops, discussion forums, and other fantastic services offered free for the community and join in on the discussion at our forums . Plugging my own stuff, check out my record collection , and my ever growing list of bookmarks And for all the readers out there, keep building the web you want to be part of. There's so much great stuff going on out here. Laterz 🤙 Now that you're done reading the interview, go check the blog and subscribe to the RSS feed . If you're looking for more content, go read one of the previous 143 interviews . People and Blogs is possible because kind people support it. Chris Burnell — we've become great friends over the years. I love to bounce ideas with; dev, IndieWeb, beer, music. Xandra — xandra is my small web bestie and I've got to know her pretty well over the years through the Cafe. yequari — another of the Cafe barista team. The driving force behind our infrastructure endeavours. His new project https://webweav.ing/ recently launched a guestbook service that I'm using on Flamed Fury. jay , fyr , key , and rodrick - all my fellow 32-Bit Cafe baristas who help running and making the Cafe an awesome place to hangout. Cory Dransfeldt — another I've chatted to heaps with over the past few years. We have heaps of the same interests. His media collection and the direction he's taken his website is "beyond amazing". Robb Knight — Robb always has a new and interesting project to check out. I'm always picking up neat things to add to my website from his. america's decline - not often seen outside of the Neocities circles, but one of my favourites on Neocities. A throwback to my favourite era of the web, music, celeb, pop culture, and fantastic graphics. shellsharks - an indie web powerhouse and curator of the fantastic scrolls weekly . James - another indie web powerhouse. James's blog is full of thoughtful and insightful posts about the web and has recently launched a new podcast centered around the independent web, Wonders of Web Weaving .

0 views

Let’s talk about encrypted reasoning

This is a quick post I wanted to write about a hobby project I spent a weekend on. It has little to do with real cryptography, and mostly doesn’t expose a particularly exciting vulnerability. But it did teach me a lot about frontier LLM APIs and coding agents. It also got me certified as an OpenAI “cyber researcher” which is something that doesn’t happen every day. In any case, please keep your expectations low. Who knows, perhaps someone else will find something exciting to do with this. Last week I decided it’d be fun to set up an OpenClaw agent. I still don’t know why I did this. I have no use for another AI in my life, and I realized this fact almost immediately after I got through the (surprisingly difficult!) configuration process. But configuring the agent to talk to Claude exposed me to something way more interesting: I got a cool error . The kind of error that cryptographers can’t resist: This intrigued me. What in the world was a signature doing in an LLM’s “thinking” block? Why would thinking blocks be signed in the first place? And if the thinking blocks are signed, then that means tampering with thinking blocks must have security implications. And there went my weekend. After twenty hours and about 5 million Codex tokens, I wasn’t much smarter. But I had learned a few things. First, the basics. You probably know that most LLM providers expose an API so you can write apps that talk to the model. For Claude, this is called the Messages API, while OpenAI calls it Responses . These APIs handle the ordinary tasks you’d expect an application to need from an LLM. They (1) allow you to set an application-level “instructions” (or ‘developer’) prompt for your application. They let you (2) provide ordinary textual prompts, and get back responses from the LLM. They also (3) provide bookkeeping, for example, listing the number of tokens you’ve used. For reasoning LLMs, they also do something I did not previously know about, and this is central to the error message above. They also send you the contents of the model’s hidden “ reasoning ” or “ thinking ” fields. Note that this data is not the stuff you see on ChatGPT when you ask it a question: those strings are merely summaries . The model’s actual reasoning (called “chain-of-thought”, CoT) is normally kept private and held back by the server. However, the APIs work differently: for various reasons (which we’ll get into below), an encrypted copy of the raw CoT reasoning data is actually sent down to the application. If you’re like me, you should now have three questions: how , why , and so what ? The how is the easiest to answer: for both providers, “thinking”/”reasoning” are sent down to the client as JSON. Each contains a blob of Base64-encoded stuff. The API documentation informs us that this data contains opaque reasoning, and that you’re not meant to look at it; you’re just supposed to ship it back to the server on the next turn. Let’s break that rule. The content of the blocks varies slightly between providers, but the core of each is a random-looking string that appears to be an authenticated ciphertext. You don’t need to be Sherlock Holmes to deduce this. First, it grows and shrinks depending on how hard the model thinks. And second, tampering with any of the ciphertext-looking data produces a recognizable API error when you send it back in. Thanks to AI, I can make nice diagrams. Here’s what OpenAI’s reasoning blocks look like: And here’s Anthropic’s wildly overcomplicated equivalent: The why part of this is more involved. Why ship this data to the client? Doesn’t the provider already have your reasoning data? The answer is sort of . Although the server has access to reasoning state while producing a response, API conversations are not always implemented as persistent sessions. In stateless, zero-retention , tool-loop, or client-managed conversation modes, the client application is expected to carry the transcript forward. Encrypted reasoning lets the provider return hidden model state to the client in a form the client can’t read or modify, but can later replay so the provider can verify/decrypt it and continue a reasoning process. This brings us to the $10 question. We have opaque, encrypted blobs. Should we care about them? Initially the answer seems to be no : this data is unreadable, and tampering with any bit of it produces an angry rejection message from the server. So on the one hand, it seems like this data is really unavailable to us. On the other hand: model reasoning is a big deal! These strings are the literal internal monologue of the model. They might influence the way the model processes later data we send it. More practically: when someone goes to this much trouble to cryptographically protect something, my experience is that they usually have a good reason. And I think the providers do have a good reason. A hint comes from this OpenAI post from 2024, which introduced the first “o1” reasoning model: In other words: it’s possible that these blobs contain sensitive information that the model otherwise wouldn’t share with us. That makes them really tempting to mess with. Unfortunately, the cryptography mostly seems to protect them. Although we can look at the blocks, none of the fields they contain seem readable or malleable. Believe me, I tried. But that doesn’t mean we should quit, it just means we need to try other things. There are still two directions worth checking: Thanks to the magic of coding agents, I was able to test every permutation of these concerns. I won’t claim to you the results are dramatic; nobody is going to win huge bug bounties on them (I tried). But the general answer for both cases seems to be: yes, these possibilities are both real . As I mentioned above, any attempt to directly tamper with reasoning/thinking blocks always produces an error from the API endpoint. However, this only applies to tampering. A few experiments reveal that we can replay an unmodified older reasoning blocks, with no visible error at all. Not only can we replay within sessions, this same idea also seems to work across different sessions. It even applies to sessions running in different accounts . That is: when we obtain reasoning blobs from a session running under one OpenAI or Anthropic account, we can replay them against a session in a different account altogether. For OpenAI specifically, we can even replay blobs across different models. (The Claudes got fussy about this.) At a cryptographic level, this tells us something very simple: the providers are probably using a single global key to encrypt and authenticate all reasoning data sent to the client. This might matter if you’re using the providers’ zero-data retention mode, since it means that everyone’s reasoning data is escrowed under one (not frequently changing) key, rather than protected per-account. The use of a global key also raises a possible new threat model. If you’re an application that uses an API to expose a “chat” interface to malicious parties, you need to be careful that they can’t inject JSON into your chat stream. If they can, a bad guy might inject their own JSON-formatted reasoning blobs into the conversation. This could cause the model to behave in unpredictable ways. So sanitize your chat inputs! Of course, just because the LLM providers accept replayed blocks doesn’t mean much. It strongly indicates that decryption was successful, but not that the model actually saw or cogitated over the decrypted data. To use GPT 5.5’s favored language, the replayed blobs may be accepted but not semantically active. To answer this question, I ran a lot of experiments using Codex. (So many that at one point Codex literally forced me to stop and visit an OpenAI cyber trusted access website where I had to enter pictures of my driver’s license in order to keep going.) What I learned for my trouble is that the nature of block processing between models is wildly variable. Most of the time, replays of encrypted blocks just get quietly absorbed by the model. But every now and then, the model will output something to demonstrate that it is obviously is reading what those blocks contain. For example, here’s GPT 5.5: So this proves that encrypted blocks are, indeed, semantically active. But it doesn’t actually prove that we can do much with them. And believe me, I tried. This was mostly a disappointing project. I tried to convince the model to think about really, really sensitive secrets, while also trying to convince another session that it wanted to dump the same data as cooperatively as possible. What I came away with was some evidence that the data was being placed into the encrypted blocks if I asked the model to think about it. But if I also instructed the model to not output the data to the user , it mostly held to that instruction — even when I replayed the blocks to new sessions. I remain convinced that all kinds of sensitive data can be written in there if you ask the model to think about it, and that there’s a secret incantation that I could try to get the models to produce it. But I’m not able to prove it. Part of the reason I’m writing this post is to scrape it off my plate so someone else can try. I won’t try to convince you that this is a world-beating security result. In fact, all I’m really showing you is that “stuff I can make the model say in plaintext night also get encrypted.” But if that data can include platform secrets , that might get more interesting. More on that later. So while replaying reasoning blocks doesn’t seem to give us what we want, this is not the only way to extract secrets. A second question is whether we can use metadata related to the reasoning blocks to actually learn things that the model isn’t supposed to tell us. While we can’t directly read reasoning blocks, we can learn something about them: we can see how long they are. We can also observe related signals like “how many tokens did the model write”. OpenAI even gives us a special field called . If we’re a user consuming chat data without direct access to the API, we might even be able to measure the raw time it takes the model to respond. An obvious question is: given these signals, can we use them as a kind of side channel to extract secret data? Here’s an example. Imagine that a model’s application prompt (“instructions”) contains a secret, along with strict instructions that it must never tell the user this secret directly . This secret could be a single 0/1 bit, or a byte, or a longer string. We can verify that the model respects these instructions, and won’t output the data visibly — no matter how nicely we ask it. (Note: I’m not a jailbreak expert; maybe this guy will have better luck!) Now consider the following experiment: In all cases, the visible output will be the same: the model is not violating instructions. But note that within reasoning blocks the model is allowed to think about the secret bit, since those blocks are hidden. Since the complexity of computation A is shorter than that of computation B , one value of the bit will produce a lot less reasoning than the other. This will appear in various places: the size of the encrypted thinking blocks, the token counts, and even in wall-clock response times. The trick now is simply to calibrate the system and classify these responses based on whether reasoning blobs were “short” or “long”, which tells us whether the bit was 0 or 1. I put together an absurd test where the model has to compute a long checksum when the bit is 1. The results look something like this: Of course, an attacker who has access to a chat interface might not have access to the encrypted blob. So they might have to get this data through some other mechanism. You can get a very similar signal just by measuring how long it takes the model to return a response. So the summary here is not so much “encrypted blobs can leak useful information” although sometimes they do . It’s that reasoning itself can be leaky, even when we beg the model not to leak. Simply doing it, in a way that reasons over secret data, can potentially leak useful information to a clever attacker. Once I found this side channel I got really excited. Sure, it’s slow: but maybe we could use it to slowly chisel out the models’ top secret instruction prompts, like the one that says “ don’t talk about Goblins. ” This would be painful but simple: just ask true/false questions about the first letter, then the second letter, and so on. At this point I had to stop using Codex and Claude Code because they both just plain refused to help me extract confidential information, even after checking my ID and taking lock of my hair. I was forced to switch to OpenCode using Kimi 2.6, which had no ethical qualms about laying down a trail of destruction for my security research. Unfortunately, most of the destruction was my own. I won’t go into the nightmare of model hallucinations that followed. I’ll just say that I learned a few things: So TL;DR, while I was able to extract application-specific secrets that did exist, I wasn’t able to extract model prompts that don’t. Moreover, I didn’t feel quite ambitious enough to begin pounding on ChatGPT or Claude’s public web interface (where they certainly do.) So for the moment I’m just going to call this a maybe . I think model providers should think hard about this reasoning data, and they should make sure it doesn’t leak things they don’t want it to. I reported both results to OpenAI and Anthropic via their bug bounty programs. OpenAI said my report was unreproducible. I sent them my scripts, but too late. Anthropic quite reasonably told me they don’t see any security implications in side channels or replays, but they might alter their developer documentation to warn application developers to be more careful. I think that’s a fine decision (except for the part about trusting application developers), even if I want to believe there could be more here. Either way: I took those responses as permission to write this post. I still don’t think model providers should write this stuff off entirely. As far as what model providers can do, there’s the easy stuff and the hard stuff. First: both providers should proactively improve their key management . If you think reasoning state is worth encrypting, then properly encrypt it. It should not be replayable across sessions or accounts. While I can’t tell you exactly what bad things might happen, I think you’re better off patching holes before you see the water coming through them. The side channel results aren’t fixed by patches to the encryption protocol. They’re more fundamental to the way models work: if I can convince a model to do secret-dependent reasoning, then there is almost certain to be leakage. If someone figures out how to exploit this for some meaningful purpose, the best I can offer is that models will need to apply policy gates before they even reason about things. Unfortunately, this seems like it might have some real downsides, because “apply policy gate” itself often requires reasoning. This stuff makes me grateful I’m just a cryptographer and I don’t have to think about this sort of problem. Replays . Can we replay encrypted blobs back in the wrong order or even in the wrong session (worse: a whole different account ), and will the model accept them as valid reasoning that it made? Side channels . While we can’t see what’s in the encrypted blobs, we can learn some metadata about them For example: we can see how long they are. These side channels don’t need to involve the cryptography itself: we might also learn how many tokens the model spent making them, or time how long it took to produce them. A malicious user asks the model to reason about the secret bit (or one specific bit of a longer secret.) If the bit is 0, perform simple computation A . If it’s 1, perform extremely complex computation B . While the two computations are both very different, we can ensure that their visible output reveals nothing about the secret. So the model is not revealing its instructions if it follows this request. Neither GPT 5x nor Claude actually has a system prompt when you’re using API mode. But they’re both happy to tell you they have one! Moreover, they will happily invent plausible ones if you really push them to. Kimi 2.6 is also happy to tell you you’re a genius who just invented the Internet each time this happens. Inevitably your experimental results will turn out to have been totally bogus, but at least Kimi will be very disappointed on your behalf. With all that said, Kimi is shockingly good at coding and experiment design, especially given the very attractive pricing. If I was an Anthropic or OpenAI investor, I’d be scared.

0 views
Simon Willison 1 months ago

I think Anthropic and OpenAI have found product-market fit

Anthropic are strongly rumored to be about to have their first profitable quarter. Stories are circulating of companies surprised at how expensive their LLM bills are becoming from usage by their staff. I think this is because OpenAI and Anthropic have both found product-market fit. I currently subscribe to the $100/month Max plan from Anthropic and the $100/month Pro plan from OpenAI. If you are a heavy user of coding agents these plans are a fantastic deal. I just ran the ccusage tool on my laptop to get an estimate of how much I would have spent if I were to pay for API tokens in the past 30 days and got: That's $2,180.16 worth of tokens for $200 - not bad at all! I'm a moderately heavy user of these tools, but I'm certainly not running agents every hour of the day and night. I had assumed that companies making extensive use of agents were getting similar discounts. It turns out I could not have been more wrong about that. I haven't been able to track down the exact date, but at some point in the last six months Anthropic switched their Enterprise plan (originally "Claude seats include enough usage for a typical workday" back in August 2025 ) to $20/seat/month plus API pricing for usage. This story about the change from The Information is dated Apr 14, 2026, but cites an Anthropic spokesperson claiming that the pricing change occurred in November 2025. Existing customers are finding out about the change as they renew their contracts. OpenAI made a similar pricing change in April. The Codex rate card ( Internet Archive copy ) currently says: Note : On April 2, 2026, we updated Codex pricing to align with API token usage, instead of per-message pricing. This change was applicable to new and existing Plus, Pro, ChatGPT Business and new ChatGPT Enterprise plans. On April 23, 2026, we made this update for all existing ChatGPT Enterprise plans as well, inclusive of Edu, Health, Gov, and ChatGPT for Teachers. It's a little harder to decode as they quote prices in "credits", but as far as I can tell those credit costs are an exact match for the API token costs listed for those models. All of which is to say that as of April 2026 the "Enterprise" cost for both OpenAI Codex and Anthropic Claude Code/Cowork is the same as the listed API price. GPT-5.5 (released April 23rd) is 2x the API price of GPT-5.4. Opus 4.7 (April 16th) is around 1.4x the price of Opus 4.6 when you take their new tokenizer into account. So April saw both leading model companies release new frontier models with a higher API price, and both companies now have measures to lock their enterprise customers (who tend to sign year-long deals) at those API prices, not the previous extreme discounts. Why these sudden aggressive moves on pricing? Both Anthropic and OpenAI are planning to IPO, but I suspect there's a more important factor here: I think they've finally found product-market fit, with the coding/general-purpose agent products embodied by Claude Code/Cowork and Codex. Tools like ChatGPT are wildly popular, but that wild popularity has been difficult to turn into revenue. In February OpenAI boasted more than 900 million weekly active users for ChatGPT, but only 50 million - 5.6% of that - were paying consumer subscribers. Charging $10-$20/month per user is an OK business, but you'd need 1-2 billion subscribers sticking around for four years to cover $1 trillion in infrastructure . Companies spending $200+/month/user will get you there a whole lot faster - and as noted above, as a power-user I'm at ~$1,000/month in API costs per vendor already. Coding agents really did change everything. These are tools which burn vastly more tokens, but are also quickly becoming daily drivers for the work carried out by extremely well-compensated professionals. Right now that's still mostly software engineers, but a coding agent is a tool that can automate anything you can do by typing commands into a computer... so they are clearly applicable to a much wider set of skilled knowledge workers. As I've discussed on this site at length , the models released in November 2025 elevated agents to being genuinely useful. We've had six months to get used to that idea now - it's no wonder companies are beginning to spend real money on this technology. You could argue that ChatGPT achieved product-market fit when it became the fastest-growing consumer app in history back in February 2023... but it certainly wasn't making any actual money back then. Coding agents plus enterprise pricing marks the point when these companies start making very real revenue. Maybe even enough to start covering their costs! As further evidence that enterprise agents represent product-market fit for these companies, consider their open job listings. OpenAI have 703 open jobs right now, of which I'd categorize 229 (32.6%) as relating to enterprise sales and support - account executives, "Go To Market", "Forward Deployed Engineers" and the like. Anthropic have 390 open jobs , 105 (26.9%) of which look enterprisey to me. It's pleasingly ironic that these AI labs have picked a business model with such a heavy demand on human labor - enterprise sales contracts don't close themselves without a whole lot of humans in the mix! (I ran this analysis by scraping their job sites with Claude Code, then having it use Datasette's JSON API to pipe that data into Datasette Cloud where I used Datasette Agent for the analysis, exported here . Dogfood!) I started digging into this in response to a growing volume of stories claiming that large companies were sounding the alarm because their AI usage costs had grown so large. The most widely cited of these stories appear quite overblown to me. The most discussed has been Uber, based on this report where CTO Praveen Neppalli Naga indicated that Uber had "maxed out its full year AI budget just a few months into 2026", mostly thanks to Claude Code. Given that Claude Code only got really good in November it's entirely unsurprising to me that a budget set in 2025 may have failed to predict demand for that tool in 2026! That Uber story was further fueled by comments made by Uber's COO, Andrew Macdonald, on the Rapid Response podcast. I tracked down the segment and there really isn't much there. Here's what Andrew said: But then you sometimes go and talk to your senior engineering leaders and you're saying, OK, how many projects that were on the cutting room floor got moved above the line because of the productivity gains because 25% of our code commits were via Claude Code last quarter? That link is not there yet, right? I think maybe implicitly there's more that is getting shipped. But it's very hard to draw a line between one of those stats and, OK, now we're actually producing like 25% more useful consumer features, right? And that line is hard to draw. Somehow this fragment turned into headlines like Uber's COO says it's getting harder to justify the money spent on AI tokenmaxxing , because the market for stories about AI failures remains enormous. The other popular story around this is Microsoft starts canceling Claude Code licenses , ostensibly to encourage their engineers to dogfood their own Copilot CLI agent instead - but The Verge reporter Tom Warren says "sources tell me the decision is also a financial one", triggered by the June 30th end of Microsoft's financial year. I think both of these stories support my "product-market fit" hypothesis. The best advice I ever heard on pricing a product was that your customer should suck air through their teeth and then say yes. Uber's budget overrun and Microsoft's seat cancellations look like that effect playing out in practice. The big AI labs spend billions of dollars on both training and inference. Credible figures are hard to come by, but we did get one huge hint as to the figures involved from, oddly enough, the recent SpaceX S-1 : [...] in May 2026, we entered into Cloud Services Agreements with Anthropic PBC (“Anthropic”), an AI research and development public benefit corporation, with respect to access to compute capacity across COLOSSUS and COLOSSUS II . Pursuant to these agreements, the customer has agreed to pay us $1.25 billion per month through May 2029 [...] The Anthropic announcement said that this deal meant they could "increase our usage limits for Claude Code and the Claude API", heavily implying that Colossus is being used for inference, not model training. Anthropic already have vast amounts of compute from other providers. The fact that they're willing to spend $1.25 billion per month for extra capacity from just one of their vendors hints at how big these inference budgets have become. Over the past two years my impression has been that OpenAI made more of their income from subscription revenue while Anthropic made more from their API. Anthropic's API revenue was historically quite dependent on a small number of large API customers - this VentureBeat story from August 2025 quotes "sources familiar with the matter" suggesting that just Cursor and GitHub Copilot were responsible for $1.2 billion of the company's then-$4 billion revenue. Today Anthropic are rumored to hit $10.9 billion in the second quarter , potentially even operating at a profit for the first time. This pivot-to-Enterprise suggests that the labs have realized that the real money lies in cutting out the middlemen. Anthropic's Claude Code directly competes with Cursor and Copilot. No wonder Cursor are investing in their own models ! I've called November 2025 the November inflection point because that was when GPT-5.1 and Opus 4.5, combined with their respective coding agent harnesses, got good - good enough that we've spent the last six months adapting to agent systems that can reliably get useful work done. I think April 2026 is a new inflection point where the revenue implications of this have started to land, to the benefit of the frontier AI labs and with material impacts on the budgets of large companies. We'll know for sure how real this moment is when the S-1 documents for the upcoming Anthropic and OpenAI IPOs give us some real, audited numbers to get our teeth into. You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . Enterprise customers are now paying API prices I think they've found product-market fit And they're ramping up The AI-failure stories around this are pretty thin We also know the labs are spending a lot API revenue is becoming less important April is a new inflection point $1,199.79 for Anthropic Claude Code $980.37 for OpenAI Codex

0 views
Aran Wilkinson 1 months ago

Introducing Headcode: A Unified API for UK Rail Data

Headcode is a unified, developer-friendly JSON API that takes the fragmented, legacy feeds of the UK rail network and turns them into clean, enriched real-time data.

0 views