Posts in Python (20 found)
Corrode Today

The Rust Foundation

Most Rust developers use the language, compiler, package registry, and tooling every day without thinking too much about the organization that helps keep parts of that ecosystem funded and sustainable. This episode is a re-introduction to the Rust Foundation: what it does, what it does not do, how it relates to the Rust Project, and why that distinction matters for teams using Rust professionally. My guests are Rebecca Rumbul, Executive Director and CEO of the Rust Foundation, Lori Lorusso, Director of Outreach at the Rust Foundation, and David Wood, Principal Software Engineer at Arm, Compiler Team Co-Lead in the Rust Project, and a Rust Foundation board member. Together we talk about the practical side of ecosystem stewardship: infrastructure, security, interop, maintainer support, governance, corporate membership, open-source funding, and the pressure new technologies like AI put on language ecosystems. CodeCrafters helps you become proficient in Rust by building real-world, production-grade projects. Learn hands-on by creating your own shell, HTTP server, Redis, Kafka, Git, SQLite, or DNS service from scratch. Start for free today and enjoy 40% off any paid plan by using this link . The Rust Foundation is an independent non-profit organization supporting the success, sustainability, and positive impact of the Rust programming language. Its work includes funding and supporting ecosystem infrastructure, security and interoperability initiatives, maintainer support, project administration, community programs, events, and collaboration with member companies and donors. The Foundation is separate from the Rust Project. The Rust Project governs the language, compiler, standard library, and technical direction through its own teams and decision-making processes. The Foundation provides organizational, financial, legal, and operational support around that work, without owning Rust’s technical roadmap. Rebecca Rumbul is the Executive Director and CEO of the Rust Foundation. She leads the Foundation’s work on organizational strategy, member engagement, sustainability, and support for the broader Rust ecosystem. Lori Lorusso is Director of Outreach at the Rust Foundation. Her work connects the Foundation with the Rust community, member organizations, trainers, contributors, and companies adopting Rust in production. David Wood is a Principal Software Engineer at Arm, CE-SW Rust Team Lead, Compiler Team Co-Lead in the Rust Programming Language Project, and a board member of the Rust Foundation. In this episode, David adds the perspective of someone involved in Rust’s technical work as well as Foundation governance. Mozilla - The first home of the Rust language Python Steering Council - The governing body of the Python Project How to Write a C++ Language Extension Proposal - Bjarne Stroustrup, the inventor of C++, on why C++ needed a standards committee SCRC - The Safety-Critical Rust Consortium FLS - The Ferrocene Language Specification, a specification of the Rust language that is required for certain steps in the certification of Rust for safety-critical applications Foundation Membership Tiers - The different quantifiable benefits from Diamond to Silver and Associate Memberships Rust Commercial Network - A group of organisations that use Rust in production working together with the Rust Project Rust-C++ Interoperability Initiative - An initiative of the Rust Foundation to improve interoperability between Rust and C++ Rust Embedded Working Group - An official working group of the Rust language to improve usability of the language in hardware-constrained environments An AI Security Engineer in Residence for the Rust Ecosystem - Describing the position of the security engineer made possible by funding from Alpha-Omega Rust Foundation Maintainers Fund - The Foundation’s fund to support Rust maintainers Rust Foundation Trusted Training - The Foundation’s accreditation program for Rust training providers Rust Foundation Website Rust Foundation Media Room Rust Foundation on GitHub Rust Foundation on LinkedIn David Wood’s website

0 views

How to stay in the coding flow using LLMs

We all know that moving to LLMs and agents has caused the feeling of losing touch with parts, or maybe even all, of a code base. This isn’t just something that is problematic for managing and handling the translation from business logic to implementation it is a problem because it feels exhausting . I’ve had coding sessions that lasted 12 hours and afterwards felt great. Meanwhile I’ve done LLM prompting for a few hours and felt exhausted or unsure of what I did. Lately I’ve been keeping this in mind and have been looking for a few ways in which I can maintain a flow state and take advantage of LLMs. Remember back in 2025 when this was the default way of using them? I actually still find this to be my preferred way. Using LLMs with code harnesses in projects injects so much unnecessary information that asking simple questions gets out control. For example, here I’m exploring some data, and I wanted a quick regex, I turned over to my VSCode chat window, and forgot that it was an agent, and asked it the question. It proceeds to start looking at the files, wanting to run code etc. All off target of what I need . So next I switched VSCode to “Ask” instead of agent, again the LLM is flooded with context about my project and proceeds to output a massive amount of distracting and off topic code suggestions. Switch to a browser chat window which has little to no context about what you’re working on and ask it my specific question, boom it spits out a few quick regexes for my Python list comprehension that are exactly what I need . Is this bad advice? Well, maybe. But was this what you’re already doing, definitely. But the point here is to multitask coding on more than one thing at a time. I’ve found that this keeps me in the flow state much better than if I let myself browse the news. So instead of switching from your agent -> browse social media switch between multiple projects. This depends on how your code / work is structured, but depending on the scope this means either switching between several agents in the same project or having several projects open at once. Types of positive multitasking to stay in the zone: For me, working on AppGoblin’s free ASO and mobile app ecosystem data , I have certain areas that *I* need to understand what is happening, for those reasons I do not let AI write anything more than boiler plate code. The clearest example of this I can give is SQL, where a lot of my most important relational logic exists. Sure, I can let an LLM one shot a complicated SQL and it will “work” but come weeks (or months!) later and I’ll find a complicated bug that slipped in. It’s not even necessarily about who was right/wrong in this situation, it’s that *I* need to know what’s going on in certain parts of the codebase. Something that ‘looks fine’ is a terrible feeling that later it was not what I wanted. This last one is probably best suited for other data crunchers out there, but it’s where I find a great sweet spot for staying in the zone. My favorite way to write code has always been to write code in an editor and send line to a REPL. This is also more or less how SQL gets written as well where you build queries in your SQL editor by slowly making changes to the data, checking values / assumptions and eventually getting to your final SQL query. With the LLMs, I find myself using this flow lately: It’s more or less the same as I did before, just a lot less writing and let’s me hold onto the difficult concepts longer. If you’re actually in the flow of editing code, the best way to augment your coding is with code completion. I’ve found this to be the most powerful in that I don’t even have to start letting my mind wander for how to do some boiler plate code, it just pops up automatically. I love this because it helps me think at a high level in the code without the distractions of trying to remember how to do something when the how is not the important part. Probably the only issue with this is that code completion can be quite annoying and distracting in some situations. For example, writing free form and handling imports at the top of a file are examples where LLM ‘helpful’ code completion is just not helpful. If you enjoyed this feel free to share. Working on related projects File and project cleanup. LLMs generate many extra files and code and it’s best to stay on top of that yourself. Go through and delete extra files. Try asking LLMs for advice on what to remove, but do be careful with this idea. Tell LLM to write new code for processing data Step through the code my self line by line, checking the hotspots where I know assumptions / tricky data might be

0 views
DYNOMIGHT 2 days ago

Pseudpocalypse

Here’s a conjecture: If you put any significant amount of text on the internet under different names, those identities can be linked using only the text itself. This is possible (I conject) because of the statistical “fingerprint” you leave in everything you write. Imagine a website where you can paste in some brand-new text someone just wrote. In return, the website provides links to all the text that writer has ever published under any name. It’s not perfect, but it’s pretty good. As far as I know, no such website exists—at least not on the public internet. But I suspect it’s possible and will soon become easy. This will pose some difficulty for pseudonymous blogging. Note : I wrote most of this essay in mid-2025, after which I idiotically sat on it for a year tinkering with theorem statements that none of you will read. 1 In the meantime, LLMs have gotten much better at guessing authors from text. (Given the first 1000 words of a draft of this post, Claude 4.8 knows it’s me.) Still, I think we’re just getting started. I expect to see increasingly obscure writers identified from increasingly small bits of text. I expect that this work even when people are writing in a different register or about unrelated subjects. And I expect that everything I’ve ever written under any pseudonym will soon be linked to my genuine-nym. 2 A stronger conjecture is that we’re heading towards a sort of generalized pseudpocalypse. Perhaps, in the future, if you interact with the world through essentially any high-bandwidth channel, then you identify yourself. Say you wear a mask in public and only speak by sub-vocalizing into a voice changer. That’s fine, you’ll still be identified using your body shape, gait, or chemical signature. Or say you don’t like your car being tracked everywhere, so you stop carrying a phone and you somehow convince lawmakers to ban license plates. No problem, your car will still be tracked using tiny scratches or unique pinging sounds from the engine. Or say you don’t like being tracked on the internet, so you lock down your browser profile, buy stuff only with Monero, and connect through a chain of three VPNs. That’s OK. You’ll still be identified through how you wiggle your finger as you scroll down the page. We’re all just too unique, and the information theoretic limit is coming for us. Let’s start from first principles. Imagine that at birth, everyone is assigned a random binary string. Whenever you post anything on the internet, you’re required to sign it with that string. If the strings are very short, like , then lots of other people will have the same one as you. But if the strings are very long, then yours would almost certainly be unique and it would be trivial to link all your pseudonyms. Where’s the transition point? If you only know that the author is currently alive and living somewhere in the Anglosphere, it’s around 29 bits. That’s because if there are K digits, then there are 2ᴷ possible binary strings, and if K = 28.86, then 2ᴷ ≈ 490,000,000 is the number of currently-alive Anglosphere-dwellers. If the strings have fewer than 29 bits, then someone else will probably share your string. If they have more than 29 bits, then your string is probably unique. We don’t (yet?) have to sign the things we write with immutable government-issued strings. But the way you write still provides lots of clues about you by way of your tone, personality, word choice, and so on. Theoretically speaking, I think it has to be possible to link the identities of anyone who writes enough. Imagine again that everyone is assigned a random binary string at birth, but instead of you needing to sign the stuff you write with your string, each time you write a word, there’s some chance that a random bit from your string is revealed and added as a signature to your message. For example, maybe a signature of is added, indicating that your string at position 129 has value 1. Think of your string as representing all your writing style quirks, and a bit being revealed as representing when you write something that reveals a preference. For example, maybe bit 18 indicates if you prefer to write your em-dashes with hideous spaces — like this — or without spaces—like this. If you use an em-dash, that bit is revealed. So imagine you’ve written a lot under Pseudonym A, enough that the full bit-string has been revealed. Maybe it’s this: Now say you start writing under Pseudonym B. Initially, none of the bits will be known: But slowly, you’ll start to leak a few bits: And eventually you’ll leak a lot of bits: Now think about this from the perspective of an “attacker” who wants to know if A and B are the same person. Let’s assume they’ve only seen the above bits, and have no information about anyone else. Then here’s what the attacker knows: Intuitively, if K was 5, then the fact that all bits match wouldn’t prove much, since with 490 million people, lots of people would match on those bits by chance. But if K was 70, it’s extremely unlikely that two different people would share all of them, even with such a gigantic pool to start with. It turns out that if there are N other people with random bits, and you pick K of your bits, the probability that someone exists who matches all of them is 1 - (1-2⁻ᴷ)ᴺ. When N is 490 million, that looks like this: Look at that, 29 appears again. (Isn’t math wonderful?) In general, the transition happens around whatever number of bits K makes 2ᴷ ≈ N, namely K = log₂(N). If you reveal significantly fewer than 29 bits under pseudonym B, then it’s almost guaranteed that there’s someone else out there who matches all of them. But if you reveal significantly more than 29 bits, then there’s almost no chance that anyone else exists who matches all of them. So the attacker essentially knows that A and B are the same person. And I stress again: They know that without needing to see anything from the other 490 million people. Of course, we don’t literally leak bits of immutable feature strings as we write. But you can make the model more realistic, and the same issue persists. If you want to reflect that text only provides noisy information about the writer, then you can add noise to the bits before they’re revealed. If you want to reflect that some writing styles are more common than others, then you can make the distribution over bit strings non-uniform. If you want to reflect that certain quirks are more obvious than others, you can give different bits different probabilities of being revealed. All these make the math more complicated. But they don’t change the basic conclusion: If your writing style contains at least 29 bits of information, and you do enough writing, you’re done. That’s my argument that pseudpocalypse is possible. But I don’t just want to claim that it could happen, eventually. I think it is likely to happen, soon, and that the amount of text you need to reveal isn’t very large. To make that argument, we need to get specific: What features do people have that are reflected in their writing? How many bits of information do those features contain? How accurately can those bits be guessed from written text? Note : To avoid this turning into a giant information theory lecture, I’ll mostly use words like “bit” and “information” without being 100% fully precise about what they mean. I’m doing that because I expect that most people reading this aren’t definition-of-bit fetishists, and anyway being hyper-technical would obscure the big picture. If you’re an information theory enthusiast and/or skeptical that I know what I’m doing, I refer you to the Section For Skeptical Information Theory Enthusiasts, below. Until then, use your intuition and have faith. Say you knew nothing about me other than that I wrote the above words. And say you had to guess my age or religion or occupation. You could guess , right? It wouldn’t be perfect, but you’d do much better than you would without being able to read those words. Thus, somehow, those words contain information about my demographic characteristics. So I tried to make a list of similar things that you could plausibly guess from text at least somewhat better then chance. Here’s what I came up with: In the same spirit, if you only read the above words, could you guess how extroverted or conscientious I am? Again, not perfectly. (When I meet people who read this blog, they usually seem surprised I can survive direct sunlight.) But still, I’m sure you’d do OK. So, again, these words contain information about my personality. What features does personality have? The HEXACO model lists six, namely honesty-humility, emotionality, extraversion, agreeableness, conscientiousness, and openness to experience. I suspect those can all be guessed with reasonable accuracy from a long-enough writing sample. But could you guess more? For each of those six factors, the HEXACO model lists four “facets”. In the abstract, trying to guess 6 × 4 = 24 different personality features from text sounds ludicrous, but just look at them: If you think about specific people, I think you can convince yourself that these 24 represent real things, and that it’s plausible to guess them from text. (Your favorite existential angst + science blogger, for example, might score lower on “modesty” than the other honesty-humility facets.) The different sub-factors are surely correlated, but not perfectly correlated. Of course, the biggest thing you learn from people’s writing is how they write . Do they tend to pointlessly split infinitives? Do they use hyphen-connected words? Do they, incorrectly, position their adverbial clauses? The idea of attributing authorship using writing style features goes back to at least 1440, when Lorenzo Valla demonstrated that the Donation of Constantine —in which Emperor Constantine supposedly donated the Roman Empire to the Catholic Church—used a vernacular that came from 400 years after Constantine’s death and was therefore a forgery. In 1851, Augustus De Morgan observed that average word length tends to be stable for the same author. The first “modern” attempt seemingly came in 1964, when Mosteller and Wallace published Inference in an Authorship Problem : This study [attempts] to solve the authorship question of The Federalist papers; […] Word counts are the variables used for discrimination. Since the topic written about heavily influences the rate with which a word is used, care in selection of words is necessary. The filler words of the language such as an , of , and upon , and, more generally, articles, preposition, and conjunctions provide fairly stable rates, whereas more meaningful words like war , executive , and legislature do not. After an investigation of the distribution of these counts, the authors execute an analysis […] based on Bayesian methods. The conclusions about the authorship problem are that Madison rather than Hamilton wrote all 12 of the disputed papers. Get that? The idea is that your usage of the word war depends mostly on if you happen to be talking about war. But your usage of upon mostly depends mostly on how much you like the word upon . To demonstrate this, they took 48 papers written by Hamilton and 50 by Madison and made this table of how many times they used by , from , and to : Madison liked by . Hamilton was more a to man. Using these kinds of statistics, they concluded that the disputed Federalist papers must have been written by Madison. So I did some research looking for other writing style features that are believed to be stable when people write about different subjects. I found that there are a lot. There were so many that I struggle to even organize them into meaningful groups: Low-level frequencies: Lexical features: Syntactic features: Style features: Rule preference features: Idiosyncratic features: That’s a lot. There are surely more. And these are all “shallow” features that humans came up with using our tiny little brains. I strongly suspect that there are many more “deep” features that could be found by looking for statistical patterns in a sufficiently large dataset. Many of those features might not even have a coherent English-language description. But they’re still there, providing bits for those who seek them. So we leak information about lots of different stuff when we write. But how much information? Is it possible to say how many words are needed to uniquely fingerprint someone? No. To a first approximation, the answer is no. But to a second approximation, maybe? Within an order of magnitude? I’ll try, but it’s going to be hard. How many bits of identifying information does text provide by way of demographic features like age and sex and so on? At first glance, this seems a perilous question, as it depends on the number of categories you consider those things to have. Take sex. For pseudpocalypse purposes, your opinion about how sex should be defined or how many sexes exist is irrelevant. Finer categorizations always provide more information, and our de-pseudonymizing attacker friends will use that information if they can. However, going beyond two categories for sex makes little difference, because the additional categories will be hard to guess and even if you could, categories with low prevalence don’t contribute much extra information. 3 So, for us, two categories is the right answer. And what about age? At first glance, converting age into a set of categories seems meaningless. If you code age by the millisecond, then there are 3.156 trillion categories for people born in the last 100 years. If you code age by the decade, there are only 10. Here, the thing to notice is that while you might be able to guess my decade of birth from how I write, you don’t have a snowball’s chance in hell of guessing the millisecond. (See what I did there? People born in certain decades are more likely to use expressions like snowball’s chance in hell ? 4 ) If we took age to have some crazy number of categories, we’d have to discount later to reflect the difficulty of guessing. My intuition is that it would be hard to guess age more accurately than around five years, so 20 categories seems reasonable. Following this kind of logic, I chose a number of categories for each of the demographic variables, trying to hit the upper end of what could be guessed from text. (I’ll provide the actual categories below.) If each of the age bins were equally likely, then knowing what bin someone fell into would provide 4.32 bits of information, because 2ᴷ ≈ 20 when K = 4.32. Doing that same calculation for each feature gives the maximum amount of information they could contain. But there’s a problem. There are more people aged 30-35 than there are people aged 90-95. So, even if you could guess those age bins perfectly, they’d provide less than 4.32 bits of information on average. However, it turns out that categories need to get pretty damned uneven before information content drops very much. A perfectly balanced 50/50 distribution provides 1 bit of information, but if you switch to a 60/40 distribution, you still get 0.971 bits, and you need to go almost to 90/10 before information content drops to 0.5 bits. 5 The same basic thing is true when there are more than two categories. 6 So I went through all those features, rated them by how unevenly people are distributed, and tried to discount the bits accordingly. I’ve put the full details of what the original categories are and how I discounted them in a footnote. 7 But there’s another problem. Female 65 to 70 year-old Asians living in Scotland tend to have different {occupations, family statuses, religious affiliations} than 15 to 20 year-old Latinos living in Southeast Australia. That is, the above features are correlated. So as you look at more of them, they gradually become less surprising and thus contribute less information. How much less? Answering that the right way would require us to estimate how likely someone is to fall into each of the 20 × 6 × 6 × 2 × 11 × 3 × 3 × 2 × 23 × 3 × 3 × 23 × 3 × 2 = 8,144,737,920 joint categories. That seems hard. But a not-completely-ridiculous approximation is that if a group of variables are all pairwise correlated at a level of ρ>0, then the total information might be reduced by a fraction of ρ. 8 So how correlated are those features? In the social sciences, a correlation of 0.5 is considered quite high. That’s plausible for some pairs of variables, e.g. age vs. health or political leaning vs. religious affiliation. But many of those correlations are are probably quite weak, e.g. age vs. native language or region vs. sex vs. marital status. 9 Overall, my guess is that correlations reduce the total information by at least 10% but I doubt they reduce it by more than 60%. So I’d think the total information in the above features (if you could guess the categories perfectly) is somewhere between 10.6 and 23.9 bits. Let’s take the average and call it 17.2 bits. What about personality features? Let’s use the same same recipe we used for demographic features, but faster: To start, let’s give each of the 24 personality features five bins, in deference to dynomight personality notation . That would correspond to 24 × 2.32 = 55.68 bits total, because 2ᴷ ≈ 5 when K = 2.32. Then we need to discount for correlations. The six main HEXACO personality factors are designed to be uncorrelated, but the different “facets” inside each factor are correlated (usually with a coefficient between 0.3 and 0.6). It seems reasonable to use an overall discount factor of 0.3 to reflect strong intra-factor correlations but weak inter-factor correlations. That suggests 39.0 bits overall. And what about writing style features? How much information do they contain? This seems hard. Some of the features, like character n-grams are actually themselves long lists of features. (Frequency of typing , frequency of typing , etc.) However, many of those features contain little information, since almost everyone types around 0% of the time. And, of course, writing style features are correlated, since people who write instead of are less likely to put spaces around their em-dashes. In absence of a better idea, I’m going to give one bit for each leaf node in the above list of style features. I think of this as giving each feature two bins, and then assuming that uneven distributions of features and correlations (which reduce information) are canceled out by the fact that many features deserve more than one bin and that there are probably more “deep” features that aren’t listed (which increase information). This gives us the suspiciously round number of 50.0 bits. If you believe the above numbers, then we have at least 17.2 + 39.0 + 50.0 = 106.2 bits of identifying information that we leave clues about when we write. That’s a lot. If you could see all those features, it would be enough to identify people even on a planet with 93 million trillion trillion people. But to argue that the pseudpocalypse is nigh, it’s not enough to argue that those bits exist. We need to argue that they can and will be guessed from a relatively small amount of text. So obviously we need to talk about nuclear weapons. In a nuclear detonation, many unstable atoms are created. These spontaneously decay into more-stable atoms, in the process emitting radiation. Some types of atoms are very eager to decay, meaning they release a lot of radiation but stop existing within a few weeks (iodine-131). Others are reluctant to decay, meaning they don’t release as much radiation but they stick around for decades (strontium-90). Others stick around for millions of years, but they produce so little radiation that they’re not a big problem (cesium-135). 10 So, the residual radiation produced after a nuclear detonation is the sum of many different exponential curves, one for each isotope created during the detonation. I suspect that identifying bits in text are sort of like that. Your level of formality and your average sentence length are revealed almost immediately. Your preference for latinate vs. germanic words takes a while to come through. And your social boldness and the fact that you live in Queensland rather than Southeast Australia are revealed very slowly, perhaps so slowly that it’s effectively not revealed at all. Right. So if you start with 106.2 bits, how many of those do you reveal after writing a given number of words? I will answer that question through the noble method of making up numbers. But first, let’s calibrate. You just read 4500 words written by me. How well could you guess my demographic and personality features? As a sanity check, I gave the above words to an LLM and asked it to guess. It did unnervingly well. It wasn’t always right, but it usually was, and it did a great job of rating the confidence of the individual predictions. I don’t think there’s any magical explanation for this. The fact is, if you look at the individual personality and demographic features, guessing them just isn’t that hard. So I’m sure you could do just as well. And given enough time, I’m pretty sure you’d do even better for writing style features. Even so, you’re probably bad at it. Take the example of GeoGuessr , where people guess a location in the world from a random photo. Random people are sort of OK, but if you pick the top natural talents and have them practice obsessively, they’re really good. I don’t think LLMs are particularly good at guessing features from text, either. They weren’t trained for it. It’s just an emergent property of their general intelligence. The information-theoretic limit is surely much higher. So here’s a very rough cut: After 4500 words, I’d think it’s possible to guess around: If we model each of those with a separate exponential, and start them at 17.2 / 39.0 / 50.0 bits, then the total number of identifying bits that remain hidden after writing a given number of words is as plotted here: 11 Et voilà , pseudonymity is compromised when you leak 29 bits, which happens after 1071 words. Of course not. The above figure stands on a creaking tower of tenuous assumptions. I’ve gone through the details of deriving that curve not because you should trust it, but because I think seeing the calculations makes the following points hard to argue with: I’ve made lots of debatable choices in terms of choosing features, assigning numbers of categories, estimating distributions across those categories, discounting for correlations, and guessing how many bins can be guessed. Those choices are all individually suspect. But the above points are supported by a pretty wide margin of error. You can make different choices, but it seems very hard to avoid concluding that the above three points are true. 12 You might be wondering why I’m using so many made-up numbers. After all, there’s a whole field devoted to identifying authors from text, usually called “stylometry” or “authorship attribution”. They have research papers and competitions and all that. However, as best I can tell, state of the art published results look something like this: That sounds OK, but that’s only identifying people against a pool of ~50 authors. For my claim to be true, similar accuracy would have to be possible with 490 million people. That’s seven orders of magnitude more. The thing is, the methods those papers are using are extremely weak. All the above math assumes that you’re operating at the “information-theoretic limit”, making perfect use of all available information. If you want to get close to that, we now have some idea how to do it: You apply the “modern” machine learning recipe of gigantic dataset + gigantic neural network + gigantic fortune spent on GPUs. My guess is that for us, that would require something on the order of “all the words ever written” + “tens of billions of parameters” + “tens of millions of dollars”. I couldn’t find a single paper that came remotely close to attempting that. So I don’t think those papers tell us much, for the same reason that a 3rd-order Markov model trained on a few books doesn’t tell us much about how good computers could be at writing text. LLMs have shown that if you use the above recipe, then computers can get close to the information-theoretic limit for generating text. 13 So, I suspect that an LLM-level effort could achieve the same thing for identifying authors. You might also wonder: Why am I talking about this as some possible future technology? Isn’t that technology just LLMs? I suspect the technology will be quite LLM-like in how it models human language. But current general-purpose LLMs aren’t trained for this task. They’re good at it “by accident”. So, just like specialized chess AIs can crush LLMs at chess, I suspect specialized stylometry methods could crush general-purpose LLMs at stylometry. It’s just that those specialized stylometry methods don’t seem to exist yet, or at least aren’t public. 14 So we shouldn’t imagine that current LLMs are anything close to what’s possible, even if you assume that generic LLM progress stopped today. 15 If this is all true, what could be done about it? The most obvious “countermeasure” would be to get used to it. I mean, imagine that we did live in a world in which everyone literally had to sign everything they wrote with a unique immutable string. What would happen? I’d expect a mixture of: There are strong historical analogies here, since over the past 20 years many governments and tech companies have in fact decreed that people must sign the things they write with their real names. The effects seem to vary quite a lot based on the ambient culture and political system. Overall, my impression is that people are already much more comfortable with the idea that their work colleagues might read their dating profile or learn that they go to furry conventions. I’m optimistic that culture will continue to adapt to respect the fact that we all encompass multitudes. This seems healthy. Some effects seem clearly positive. Self-censoring is not necessarily bad. For example, on the margin, real-names surely stop some teenagers from engaging in cyber-bullying. On the other hand, were you ever a teenager? I’m pretty sure that for anyone who is “different”, having those differences broadcast to the world creates a much larger “bullying surface area”. So the effects are mixed. And adults aren’t as different from teenagers as we might like to think. Twenty years ago, I might have predicted that real names would discourage people from expressing controversial political ideas online. Superficially, that seems completely wrong. At least in the West, lots of people are very happy to express minority political views, and if you disagree at all, then you can go to hell. But I also tend to think this hides a lot of self-censorship, where most people don’t want engage in political mortal combat and so are cowed by a feisty minority. And, obviously, people in certain countries know that it’s unwise to criticize the Party. So, getting used to it seems like an imperfect solution at best. Another countermeasure would be to not build this technology, or not make it widely available. In the short term, this seems plausible. As far as I can tell, it’s been possible for years for a modestly-funded group to build a phone app that would identify most people on the street from a photo. And yet, almost no one reading this has access to such an app. If general-purpose LLMs continue to get better at stylometry, it seems entirely possible that AI companies might decide it’s a safety issue and train their AIs to refuse to do it. 16 This could work for a while. But if the technology is possible, it seems certain that governments will build it and use it. They might try to keep it out of the hands of normal people. Certain governments might restrict their own use. My privacy-minded allies always seem very jaded, but it wouldn’t surprise me at all if the Supreme Court declared that a warrant was needed before the FBI could de-pseudonymize a U.S. citizen. But when/if that technology becomes sufficiently cheap, it seems like it would be very difficult to keep it out of the hands of normal people and/or bad actors. My guess is that it’s possible to create a program that’s a few hundred gigabytes large and can run (slowly) on most modern laptops. If that program is made public, it would be hard to put the genie back in the bottle. There are also technological countermeasures. Most obviously, you could run your writing through a “filter” to try to remove identifying bits, e.g. by asking an LLM to rewrite it. It’s hard to be sure how well this would work, since we don’t have accurate estimates of how many bits you’re starting with or how many bits this would remove. But I’d guess this would be pretty effective if done carefully. The reason is that the number of identifying bits you leave in writing probably isn’t that large, relative to the number needed to identify you. If you “homogenize” your writing to remove all style and personality, you should be able to remove most of those bits. Theoretically, you’ll still leak some information. But I’d think this would substantially increase the amount you could write while remaining pseudonymous. 17 But after thinking about it, this makes me sad. Effectively, this countermeasure would preserve pseudonymity by taking writing and destroying all traces of humanity. It seems like this would work well for the “bad” uses of pseudonymity, like cyber-bullying or coordinated violence, but it wouldn’t work at all for the “good” uses, like for example someone who likes to write pseudonymously because they feel like it allows them to be more honest and vulnerable and more fully themselves, damn it. Maybe this isn’t just true for writing. Maybe it’s just a feature of our universe that if you interact with the world in any significant way, then you leave traces that make it possible to identify you. If you walk around in public, then you can likely be identified by your face, your gait, your voice, your DNA, your retinas, or your literal fingerprints. Or say you use the internet. Even if you lock down your browser fingerprint and hide your IP address using a VPN or Tor, a sufficiently powerful adversary could still identify you by analyzing global packet flow. Or say you use any phone or computer. You might be identified through keystroke dynamics or the way you jiggle your finger or mouse. Say you buy food at the grocery store, but you pay with cash and somehow shop at a grocery store with no cameras. If you buy more than a handful of items, I’d bet you can still be identified through the patterns in the stuff you buy. (Incidentally, did you ever notice that cash has serial numbers on it? And did you know that more and more ATMs are starting to track those numbers?) Or say you don’t like your car being tracked, so you stop carrying a phone and somehow get lawmakers to outlaw license plates. Still, your car surely has a few small unique scratches, and the engine probably doesn’t sound exactly the same as other cars, even from the same model and year. So if there’s any high-resolution video or audio, that’s still enough to track you. Say you plug your headphones into a charging station at the airport. Your headphones have eccentricities in their analog charging circuits. If someone really wanted to, they could track that. Or say you use electricity. Given high-resolution power-usage data, what can be said about how many people live with you? And what devices you’re using? Probably a lot? Or say you use a toilet. Many places already test sewage and know, at a population level, what drugs people are using and how prevalent various diseases are. Imagine this was upgraded to test many places in the system, with high temporal resolution, possibly correlated with flow measurements from individual houses. That would be exciting. Or say you are a country and you have submarines. Can they be detected by adversaries using distributed acoustic sensing? What about satellite-based synthetic aperture radar? Gravity Gradiometers? Quantum magnetometry? As far as I can tell, the general trend is that without countermeasures, almost everything can be identified. Countermeasures can make it harder, but they’re costly, and on the whole, the arms race seems to favor the identifier, not the person who doesn’t want to be identified. I stress: This is not all bad. The goodness / badness of a generalized pseudpocalypse depends on how society is structured. After all, the foundation of civilization is finding ways for people to make deals, and arguably less privacy makes that easier. The degree that we live in a vulnerable world where it’s easy to create civilization-destroying technologies, perhaps we’re very lucky to find ourselves in a non-private world. Still, I do worry that privacy has long provided a kind of “slack” from laws and norms. Historically, that slack has limited the power of institutions to enforce their rules. If privacy is going away, we need to think about how to preserve slack, particularly when institutions don’t want to. Above, I tried to estimate the number of bits of identifying information in writing. But what is a “bit”? In general, if x is a discrete random variable, then the Shannon entropy of x in bits is H(x) = ∑ₓ p(x) log₂(1/p(x)) , where the sum is over all the values x can take. This is always bounded between zero and the logarithm of the number of values x can take. That’s fine, but “writing style” is not a discrete variable with a discrete number of categories. So how can I estimate the entropy of writing style? The short answer is that I can’t. What I’ve actually estimated above is the mutual information between writing and writing style. Let s be a random variable representing writing style. Think of this as some sort of high dimensional continuous vector representing all the quirks of how different people write. And let x be a writing sample of some length. This is discrete because we can represent writing on digital computers. Then what I’ve estimated above is the mutual information I(x;s) = H(x) - H(x|s) , where H(x|s) is the conditional entropy of x given s . This can be measured in bits because both H(x) and H(x|s) can be measured in bits. So that’s what my estimate above really says: I(x;s) ≈ 106.2 bits . Now, you still might be skeptical. Above, I’ve implicitly assumed something like the following was true: It’s possible to identify one person out of N possibilities with low accuracy if and only if the mutual information between identifying features and writing is at least log₂(N) bits. That’s how I justified pseudonymity being compromised around 29 bits. But is it really true? Strictly speaking, no. Actually, even more strictly speaking, it’s “not even untrue” because it’s not precise enough to be true or false. But as far as I can tell, basically any precise version of that statement is false. However, it’s possible to find versions of that statement that are true, provided you add some extra not-too-crazy assumptions. To start, let’s consider an extremely simple model of information leakage. Theorem. Suppose the world consists of you plus N other people, and suppose each person has a binary identity string, drawn at uniform from the distribution over M -bit binary strings. All these strings are known to the attacker. Suppose you pick some subset of K bits and reveal them. Then the probability that this identifies you is Furthermore, in order to hold the probability of being identified below (1-1/N)ᴺ ≈ exp(-1) ≈ 36.7% , it is necessary that K ≤ log₂(N) . Proof. The probability that all K observed features collide with any random person in the crowd is 2⁻ᴷ . Thus, the probability of no collisions after checking the crowd of N people (meaning you are the only one matching the observed features) is (1-2⁻ᴷ)ᴺ . □ That’s simple. But it’s not realistic at all, since it assumes that people have immutable binary strings that they leak into their writing. Can we make it more realistic? Well, there is a simple lower bound. That is, we can say in general that if the mutual information is significantly less than log₂(N) , then it’s not possible to reliably identify someone. Theorem. Suppose N random people are selected and their full writing style features are made public. One person from that group is chosen and produces a writing sample. Then, the attacker must guess who produced it. The average success rate of the attacker (averaged over the random pool, the random choice of author, and the random writing sample) is at most (I(x;s)+1)/log₂(N) . Proof. Let S=(s₁, s₂, s₃, …) be the pool of N styles and let n be a random variable indicating which person was chosen. Fano’s inequality says that the highest possible success rate is bounded by the conditional mutual information between the writing sample x and the identity n , conditioning on the pool of writing styles, i.e. the probability of success is at most (I(x;n|S)+1)/log₂(N) . However, we can bound that conditional mutual information as I(x;n|S) ≤ I(x;n,S) = I(x;n,sₙ) = I(x;sₙ) = I(x;s). The first inequality is standard. The second step uses the fact that given n , the writing x is conditionally independent of all styles except the chosen writer. The third step uses the fact that n is conditionally independent of x given sₙ . The last step uses that (x,sₙ) is distributed as (x,s) . Substituting this bound gives the claimed result. □ So, if mutual information is much less than log₂(N) , reliable identification is impossible, even if the attacker knows all the style vectors perfectly. So, provided you don’t leak that many bits, you’re definitely safe. But is the converse true? Does leaking more than log₂(N) bits always identify you? The general answer is no . The basic problem is that I(x;s) is the average information that an average person leaks in an average writing sample. Without further assumptions, you can construct scenarios where some rare people and writing samples contain gigantic amounts of information, but most people usually leak nothing. That would mean that the attacker is very certain in some cases but usually learns nothing. So, to get a guarantee that identification is actually possible, you need to make some kind of additional assumption that the information leakage rate doesn’t vary too much between different writers or between different things they write. Suppose that p(x,s) is the joint distribution over writing styles s and writing samples x . Let’s suppose that the attacker knows the true style vector ŝ for some person. Then, they will be given a writing sample x that either came from that person or came from a randomly chosen person, and must decide which. Formally, the attacker’s goal is to guess if x was sampled from the writing distribution for that person, p(x|ŝ) or from the population marginal p(x) . Intuition suggests that the attacker’s best strategy will be to look at the ratio p(x|ŝ)/p(x), and “accept” x as coming from ŝ if above some threshold, and reject it otherwise. In fact, the Neyman-Pearson lemma guarantees that this is the optimal strategy, in a very strong sense: That ratio contains all the information that’s useful for making that decision. Now here’s something interesting: Instead of looking at the ratio, the attacker could look at the logarithm of the ratio. It makes no difference since it’s monotonic. But if you take the logarithm of that ratio, and take the expectation over people and over texts, what do you get? Well: 𝔼 ln (p(x|s)/p(x)) = 𝔼 ln (p(x,s)/(p(x) p(s))) = I(x;s) It’s the mutual information! So, intuitively, the mutual information is how much an attacker learns about the style of the writer “on average”, where that average is over both writers and text. The following theorem will look at the average information in text for a writer with a particular style. I’ll define this as D(s) = KL(p(X|s) || p(X)) . Intuitively, this is how different the writing of someone with style s is from the population average. That’s because if you take the average of this value over different styles, you get the mutual information. That is, I(x;s) = 𝔼[D(s)] . 18 Theorem (informal). Suppose that the attacker will observe some text and wishes to classify it as either coming from a writer with specific known style ŝ , or coming from someone with a random style. Suppose that the attacker is only willing to tolerate some small risk ε of a false positive. Provided that D(ŝ) is significantly larger than -ln(ε) , the attacker can achieve that, while also keeping the risk of false negatives very low, provided that the variance of how much information is revealed in a random writing sample is bounded. Theorem. Let D(ŝ) = KL(p(X|ŝ) || p(X)) to be the divergence between the target’s writing distribution and the marginal distribution. Also, define qₜ(x) ∝ p(x|ŝ)ᵗ p(x)¹⁻ᵗ to be the family that interpolates between those two distributions. To formalize the idea that “information leakage” for ŝ doesn’t vary that much, we assume that some constant V exists such that for 0 < t < 1 , the variance of log(p(x|ŝ)/p(x)) under qₜ is bounded by V . Then for any ε satisfying exp(-D) < ε < exp(-D + ½ V) , it is possible for the attacker to simultaneously achieve a false positive rate of FPR ≤ ε and a false negative rate of FNR ≤ exp( - ½ (D+ ln ε)² / V). This false positive rate reflects the mistake rate provided the writing sample x came from a randomly chosen other person, while the false negative rate reflects the mistake rate provided the writing sample x actually came from the person with style ŝ . Proof sketch. Let f be the distribution of l(x) = log(p(x|ŝ)/p(x)) with respect to p(x|ŝ) and let g be the distribution of l(x) with respect to p(x) . The stated variance assumption implies a quadratic bound K(u) ≤ D u +½ V u^2 for -1 < u < 0 , where K is the cumulant generating function of f . Observe that g is an exponential tilting of f . The attacker’s strategy must be to “accept” x as coming from ŝ if l is above some threshold c and “reject” it otherwise. Use K in a Chernoff bound on the probability l is less than c under f to upper-bound FNR ≤ exp( - ½ (D-c)²/V) . Now, using that g(l) = exp(-l) f(l) , again use K in a Chernoff bound on the probability l exceeds c under g to upper-bound FPR ≤ exp( -c - ½ (D-c)²/V) . Both of these bounds are simultaneously valid when D-V < c < D . Setting c to make the false-positive bound equal to ε gives FPR ≤ ε and FNR ≤ exp( -½ (V - √(V² - 2V(D + ln ε)))²/V). The latter can be relaxed into the stated result using that √(1-x) ≤1-x/2 for 0 ≤ x ≤ 1 . □ Now, if we suppose that the attacker wants to find a particular person, with a particular known style s . And suppose that the attacker has a pool of N people and will see one writing sample from each, but wants to limit the total probability of a false positive to δ after seeing one sample from each person. Then, they will need that (1-ε)ᴺ ≈ exp(-εN) = (1-δ), which is satisfied by ε ≈ δ/N . Substituting this into the previous result says that the attacker can hold the total risk of a false positive to δ while achieving a false-negative risk of FNR ≤ exp( - ½ (D(s) + ln δ - ln N)² / V). These results use natural logarithms because the math is easier if you measure information in nats. If you measure information in bits then you would get log₂ δ and log₂ N . (Rescaling D and V appropriately.) So, again, as long as the average information for user s is significantly larger than log₂ N , the attacker can identify that user with minimal risk of false positives. Some writers might leak more information (higher D(s) ) and some writers might leak less information (lower D(s) ). But remember, I(x;s)=𝔼 D(s) . So as long as information leakage doesn’t vary too much between people, and assuming that I(x;s) is much larger than log₂ N (and assuming that variance condition), almost everyone can be identified. Editor’s note: After this sentence was written, many additional hours were devoted to further idiotic tinkering.  ↩ It’s fine.  ↩ A standard binary variable that is 0 or 1 with 50% probability conveys 1 bit of information, while a variable that is 0 / 1 / 2 with probability 49.8% / 49.8% / 0.4% conveys 1.0336 bits.  ↩ People born in certain decades are also presumably more likely to employ see what I did there gambits.  ↩ For example, here is the information content for seven different “bent coins”: Here’s a more formal looking version of the table from the previous footnote: You can generate that table by running this code: With three categories, the story is much the same. Things need to get quite uneven before information drops too much: You can generate that with this code: Roughly speaking, we we should discount those maximum bits as follows: The Shannon entropy of a categorical distribution is - Σᵢ pᵢ log₂ pᵢ. Or, in python: Age: It’s hard for me to imagine you could guess age from text with accuracy higher than 5 years. If you assume an age between 0 and 100, that would be 20 categories and log2(20)=4.32 bits. These are mildly non-uniform so I’ll reduce to 3.9. Education: I’m assuming 6 categories: less than high school, high school, some college, finished college, master’s degree, doctorate. That would be log2(6)=2.58 bits, but fairly uneven, so I’ll reduce by 20% to reflect that. Ethnicity: Assuming 62% white, 11% black, 16% latino, 6% asian, 1.5% indigenous, 3.5% mixed/other, and actually using the entropy formula. Family status: I’m using two categories: Children / no children, on the logic that guessing the number of children would be very hard. These are mildly non-uniform, so I’ll drop to 0.8 bits. You could have a third category for having children that are grown and that had left home, but this would be heavily redundant with age. Income: The US census gives 11 income brackets. That seems as good a way of discretizing as anything. That would be log2(11) = 3.459 bits, but these are again moderately non-uniform, so I’ll reduce to 2.5. Marital status: I’m taking 3 categories (single, married, divorced / widowed / etc). That would be log2(3)=1.58 bits at maximum, but again these are somewhat non-uniform, so I dropped that to 1.2. Mental health: I’m using 3 categories: “Healthy”, “chronic condition”, and “severe issues”. Assuming 73% healthy 25% chronic condition, 2% “severe issues”, and using the entropy formula gives 0.9 bits. Native language: I’m using 2 categories, namely “English native”, and “non-English native”. These are pretty uneven inside the Anglosphere, so I’ll drop from 1 bit to 0.6 bits. Occupation. The BLS classification gives 23 major groups. That would be log2(23)=4.523 bits, but it’s moderately non-uniform, so I’ll reduce to 4 bits. Physical health: Assuming 60% “healthy” 30% “chronic condition” 10% “severe issues” and using the entropy formula. Political leanings: I’m using three categories (left, center, right). These are fairly uniform so I’m using 1.58 bits. Region: I asked an LLM to divide the Anglosphere up into a number of regions with reasonable granularity. With some tinkering, it gave 23 regions: South East England, South West England, Midlands, Northern England, Scotland, Wales, Republic of Ireland, Northern Ireland, Quebec, Ontario, Western Canada, Atlantic Canada, Northeast US, Southern US, Midwest US, Western US, Alaska, Hawaii, Southeast Australia, Western Australia, Queensland, Central & Southern Australia, New Zealand. With LLM-generated population estimates (which looked reasonable) and plugging into the entropy formula, this gave 3.5481 bits. Religious affiliation: 3 categories (christian, other religion, atheist / agnostic). These are uniform-ish. Sex: 2 categories, near-even  ↩ Consider a set of binary random variables, each of which is equally likely to be 0 and 1, yet all are correlated with a pairwise correlation coefficient of ρ. There are many distributions that satisfy this condition, but a natural choice is an Ising model. If there are many variables, then the entropy per-variable in an Ising model with pairwise correlations of ρ tends to h((1+√ρ)/2), where h is the binary entropy function . We can print out those numbers: As you can see, the entropy per-variable is always a bit more than 1-ρ. But the Ising model is optimistic, in the sense that it has the highest entropy of all distributions meeting the given conditions. So, screw it, let’s estimate the entropy per-variable to just be 1-ρ.  ↩ If it means anything to you, I asked Kimi 2.6 to hallucinate some numbers: Personally, this doesn’t mean very much to me…  ↩ It’s more complicated than this, because some atoms (e.g. strontium-90) emit more energy per decay than others. And some types of radiation are more harmful to human life than others.  ↩ In general, if you want an exponential curve f(n) that starts at 1 for n=0 and decays to 1-X for n=N, you should choose f(n) = exp(n × ln(1-X) / N). So for demographic features we’re using X=0.6 and N = 4500, meaning f(n) = exp(-0.00020362 × n). For personality features, we’re using X=0.7, meaning f(n) = exp(-0.00026755 × n), and for writing style features, we’re using X = 0.8, meaning f(n) = exp(-0.000357653 × n). So the total number of bits remaining hidden is 17.2 × exp(-0.00020362 × n) + 39.0 × exp(-0.00026755 × n) + 50.0 × exp(-0.000357653 × n).  ↩ OK, what’s the most likely reason I might be wrong? Above, I used math to estimate the information in features, and then I basically made up numbers for how much of that information can be guessed from text. Even so, my greatest concern is that the first part. I’m a bit worried that I might be overestimating the amount of information in the features themselves due to inadequately discounting for correlations. For one thing, there are probably correlations between feature groups. (For example, I’d bet that people who are high in perfectionism are less likely to use lose and loose interchangeably, and that people who live in Northern England are more likely to use the character string than people who live in Hawaii.) Also, my crude method of discounting information by ρ due to pairwise correlations of ρ might not discount enough: I used an estimate based on an Ising model, which is the maximum-entropy (highest information) distribution given the correlation constraints. I haven’t been able to figure out how much lower the information could be in the worst-case.  ↩ People debate if this is true for “intelligence”, but it’s definitely true in terms of bit-rate.  ↩ Also, arguably, stylometry is about language. This means that large language models probably have much of what they need baked in. That might explain why they’re pretty good at it just “by accident”. But to do this optimally I think they’d need self-reflection (e.g. access to probabilities of text given different contexts) that current LLMs aren’t typically capable of, and wouldn’t know how to manipulate correctly without task-specific training.  ↩ You could conjecture that near-optimal stylometry abilities are some kind of “emergent property”. But the general lesson so far is that LLMs mostly don’t have emergent properties but are just good at what they’re trained at.  ↩ (Meta-joke about you—person who works at an AI company—thinking, “maybe we should do that”, coming to this footnote, and seeing this meta-joke.)  ↩ Instead of “homogenizing” writing by imposing a generic style, perhaps it would be better to “camouflage” it by enforcing a very strong but random style.  ↩ Be a little careful here: Typically, the KL-divergence is understood to be measured in nats. But in this article, I’ve measured mutual information in bits. That’s fine, but you need to convert. For example, 106.2 bits = 73.60 nats.  ↩ A and B have revealed K overlapping bits, which all match. Different people have a 50% chance of matching on any given revealed bit. Non-different people have a 100% chance of matching on any given revealed bit. There are 490,000,000 people. Family status Marital status Mental health Native language Physical health Political leanings Religious affiliation Honesty-humility Sincerity Greed avoidance Emotionality Fearfulness Sentimentality Extraversion Social self-esteem Social boldness Sociability Agreeableness Forgivingness Flexibility Conscientiousness Organization Perfectionism Openness to experience Aesthetic appreciation Inquisitiveness Unconventionality Word lengths Sentence lengths Paragraph lengths Punctuation frequencies (commas, colons, dashes, parentheses) Function word frequencies ( the , of , and , to ) Adverb frequencies Intensifiers ( very , really , quite , pretty , so ) Evidential markers ( apparently , evidently , obviously ) Downtoners ( somewhat , fairly , rather ) Pronoun usage Overall preferences ( I / we vs. you vs. he / she / they ) Third-person singular preferences ( he , she , he or she , they , one ) Modal verbs ( can , could , might , must , should , will , would ) Hedges ( perhaps , maybe , possibly , probably ) Conjunctions ( and , but , yet , so ) Known stable ratios ( the / a , this / that , these / those , I / me / my ) Character N-grams (3-grams and 4-grams) Word N-grams (often 3-grams) Vocabulary size Lexical diversity / type-token ratio (Number of distinct words divided by number of words.) Frequencies of rare words Semantic density Discourse marker positions, combinations ( So , anyway , so anyway ) Use of abbreviations and acronyms Preference for latinate vs. germanic words ( The majestic creature traversed the terrain vs. the mighty beast strode across the land .) Syntactic complexity Subordination index Average parse tree depth Use of passive voice. Nominalization ( She was shocked I ate the pizza vs. My pizza consumption shocked her ) Verb tense and aspect ( I walk vs I walked vs I was walking vs I have walked ) Sentence structure preferences: Branching preferences (Cursed everyone had a good time when Alice taught some cool dogs I met and brought to dinner to juggle vs. clumsy-but-readable I met some dogs and they were cool and I took them to dinner and Alice taught them to juggle and and everyone had a good time .) Adverbial clause positioning ( Suddenly I was hungry vs. I was, suddenly, hungry vs. I was hungry, suddenly ) Sentence-final weight ( Your plan won’t work because of the dyslexic bears vs. Dyslexic bears mean your plan won’t work. ) Polysyndeton ( I like dogs, cats, and ferrets vs. I like dogs and cats and ferrets .) Repetition / breaking of syntactic structures. Register / formality. Patterns in sentence length (long/short/long/short vs. long/long/short/short) Stressed syllable interval preferences (e.g. iambic vs. trochaic) Minor punctuation ( I laughed—you cried vs. I laughed — you cried , “…” (three periods) vs. “…” an actual ellipsis) Capitalization. (Job titles, seasons, after a colon, mistakes) Apostrophes ( Steve Jobs’ car vs Steve Jobs’s car , 1990’s vs 1990s ) Hyphenation ( a highly-stable feature vs a highly stable feature ) Oxford commas. Article omissions ( Local dog was petted. vs. A local dog was petted. ) Relative pronoun omissions ( the dog you petted vs. the dog that you petted ) Who vs. whom . Split infinitives ( To obsessively blog vs. to blog obsessively ) Whitespace habits. Spelling errors ( loose instead of lose ) Grammar errors. ( Between you and I ) Consistent, unique typos Other consistent errors (repeated words, un-closed parentheses) 60% of the demographic features 70% of the personality features 80% writing style features You have far more than 29 bits of identifying information that you leak into your writing. Some of those bits take a long time to get revealed, but others are revealed pretty quickly. There are enough “fast leaking bits” that you can be identified from a writing sample that’s “pretty small”. Take 50 people. Get a few hundred writing samples from each author, each 1000-2000 words long. Now, take a new writing sample from one of those authors. Do some standard machine learning stuff. Hey look, the author can be identified with ~95% accuracy! People become more comfortable with their “full selves” being public, with less compartmentalization. People pull back from communicating in public channels, relying more on group chats and the like. People self-censor. If you walk around in public, then you can likely be identified by your face, your gait, your voice, your DNA, your retinas, or your literal fingerprints. Or say you use the internet. Even if you lock down your browser fingerprint and hide your IP address using a VPN or Tor, a sufficiently powerful adversary could still identify you by analyzing global packet flow. Or say you use any phone or computer. You might be identified through keystroke dynamics or the way you jiggle your finger or mouse. Say you buy food at the grocery store, but you pay with cash and somehow shop at a grocery store with no cameras. If you buy more than a handful of items, I’d bet you can still be identified through the patterns in the stuff you buy. (Incidentally, did you ever notice that cash has serial numbers on it? And did you know that more and more ATMs are starting to track those numbers?) Or say you don’t like your car being tracked, so you stop carrying a phone and somehow get lawmakers to outlaw license plates. Still, your car surely has a few small unique scratches, and the engine probably doesn’t sound exactly the same as other cars, even from the same model and year. So if there’s any high-resolution video or audio, that’s still enough to track you. Say you plug your headphones into a charging station at the airport. Your headphones have eccentricities in their analog charging circuits. If someone really wanted to, they could track that. Or say you use electricity. Given high-resolution power-usage data, what can be said about how many people live with you? And what devices you’re using? Probably a lot? Or say you use a toilet. Many places already test sewage and know, at a population level, what drugs people are using and how prevalent various diseases are. Imagine this was upgraded to test many places in the system, with high temporal resolution, possibly correlated with flow measurements from individual houses. That would be exciting. Or say you are a country and you have submarines. Can they be detected by adversaries using distributed acoustic sensing? What about satellite-based synthetic aperture radar? Gravity Gradiometers? Quantum magnetometry? Editor’s note: After this sentence was written, many additional hours were devoted to further idiotic tinkering.  ↩ It’s fine.  ↩ A standard binary variable that is 0 or 1 with 50% probability conveys 1 bit of information, while a variable that is 0 / 1 / 2 with probability 49.8% / 49.8% / 0.4% conveys 1.0336 bits.  ↩ People born in certain decades are also presumably more likely to employ see what I did there gambits.  ↩ For example, here is the information content for seven different “bent coins”: Probability of landing heads Information 0.50 (fair coin) 1.000 0.60 0.971 0.70 0.881 0.80 0.722 0.90 0.469 0.95 0.286 0.99 0.081 ↩ Here’s a more formal looking version of the table from the previous footnote: p(A) p(B) Information 0.50 0.50 1.000 0.60 0.40 0.971 0.70 0.30 0.881 0.80 0.20 0.722 0.90 0.10 0.469 0.95 0.05 0.286 0.99 0.01 0.081 You can generate that table by running this code: With three categories, the story is much the same. Things need to get quite uneven before information drops too much: p(A) p(B) p(C) Entropy 0.333 0.333 0.333 1.585 0.400 0.300 0.300 1.571 0.500 0.250 0.250 1.500 0.600 0.200 0.200 1.371 0.700 0.150 0.150 1.181 0.800 0.100 0.100 0.922 0.900 0.050 0.050 0.569 0.950 0.025 0.025 0.336 0.990 0.005 0.005 0.091 You can generate that with this code: ↩ Roughly speaking, we we should discount those maximum bits as follows: Near even: No discount. “Mildly uneven” (E.g. 70/30 with two categories) Discount by 10%. “Quite uneven” (E.g. 90/10 with two categories) Discount by 50%. “Extremely uneven” (E.g. 99/1 with two categories) Discount by 90%. Consider a set of binary random variables, each of which is equally likely to be 0 and 1, yet all are correlated with a pairwise correlation coefficient of ρ. There are many distributions that satisfy this condition, but a natural choice is an Ising model. If there are many variables, then the entropy per-variable in an Ising model with pairwise correlations of ρ tends to h((1+√ρ)/2), where h is the binary entropy function . We can print out those numbers: ρ h((1+√ρ)/2) 0.0000 1.00000000 0.1000 0.92661216 0.2000 0.85048963 0.3000 0.77121926 0.4000 0.68826012 0.5000 0.60087604 0.6000 0.50801160 0.7000 0.40803633 0.8000 0.29811751 0.9000 0.17212786 1.0000 0.00000000 As you can see, the entropy per-variable is always a bit more than 1-ρ. But the Ising model is optimistic, in the sense that it has the highest entropy of all distributions meeting the given conditions. So, screw it, let’s estimate the entropy per-variable to just be 1-ρ.  ↩ If it means anything to you, I asked Kimi 2.6 to hallucinate some numbers:   Age Edu Eth Fam Inc Mar Mhe Nlg Occ Phe Pol Reg Rel Sex Age 1.0 -0.2 0.0 0.6 0.1 0.5 -0.1 0.0 0.2 -0.5 0.1 0.0 0.2 -0.1 Edu -0.2 1.0 0.3 0.2 0.6 0.2 0.1 0.1 0.7 0.3 0.3 0.2 -0.2 -0.1 Eth 0.0 0.3 1.0 0.2 0.3 0.1 -0.1 0.7 0.3 -0.3 0.2 0.4 0.4 0.0 Fam 0.6 0.2 0.2 1.0 0.2 0.7 -0.1 0.0 0.1 0.0 0.1 0.0 0.2 0.1 Inc 0.1 0.6 0.3 0.2 1.0 0.3 -0.2 0.1 0.7 0.3 0.1 0.2 0.0 -0.1 Mar 0.5 0.2 0.1 0.7 0.3 1.0 0.2 0.0 0.1 0.2 0.1 0.0 0.2 0.0 Mhe -0.1 0.1 -0.1 -0.1 -0.2 0.2 1.0 0.0 -0.2 0.4 0.0 0.0 -0.1 0.1 Nlg 0.0 0.1 0.7 0.0 0.1 0.0 0.0 1.0 0.1 0.0 0.1 0.5 0.3 0.0 Occ 0.2 0.7 0.3 0.1 0.7 0.1 -0.2 0.1 1.0 0.1 0.2 0.2 0.0 0.3 Phe -0.5 0.3 -0.3 0.0 0.3 0.2 0.4 0.0 0.1 1.0 0.0 0.1 0.0 0.1 Pol 0.1 0.3 0.2 0.1 0.1 0.1 0.0 0.1 0.2 0.0 1.0 0.5 0.4 0.1 Reg 0.0 0.2 0.4 0.0 0.2 0.0 0.0 0.5 0.2 0.1 0.5 1.0 0.2 0.0 Rel 0.2 -0.2 0.4 0.2 0.0 0.2 -0.1 0.3 0.0 0.0 0.4 0.2 1.0 0.1 Sex -0.1 -0.1 0.0 0.1 -0.1 0.0 0.1 0.0 0.3 0.1 0.1 0.0 0.1 1.0 Personally, this doesn’t mean very much to me…  ↩ It’s more complicated than this, because some atoms (e.g. strontium-90) emit more energy per decay than others. And some types of radiation are more harmful to human life than others.  ↩ In general, if you want an exponential curve f(n) that starts at 1 for n=0 and decays to 1-X for n=N, you should choose f(n) = exp(n × ln(1-X) / N). So for demographic features we’re using X=0.6 and N = 4500, meaning f(n) = exp(-0.00020362 × n). For personality features, we’re using X=0.7, meaning f(n) = exp(-0.00026755 × n), and for writing style features, we’re using X = 0.8, meaning f(n) = exp(-0.000357653 × n). So the total number of bits remaining hidden is 17.2 × exp(-0.00020362 × n) + 39.0 × exp(-0.00026755 × n) + 50.0 × exp(-0.000357653 × n).  ↩ OK, what’s the most likely reason I might be wrong? Above, I used math to estimate the information in features, and then I basically made up numbers for how much of that information can be guessed from text. Even so, my greatest concern is that the first part. I’m a bit worried that I might be overestimating the amount of information in the features themselves due to inadequately discounting for correlations. For one thing, there are probably correlations between feature groups. (For example, I’d bet that people who are high in perfectionism are less likely to use lose and loose interchangeably, and that people who live in Northern England are more likely to use the character string than people who live in Hawaii.) Also, my crude method of discounting information by ρ due to pairwise correlations of ρ might not discount enough: I used an estimate based on an Ising model, which is the maximum-entropy (highest information) distribution given the correlation constraints. I haven’t been able to figure out how much lower the information could be in the worst-case.  ↩ People debate if this is true for “intelligence”, but it’s definitely true in terms of bit-rate.  ↩ Also, arguably, stylometry is about language. This means that large language models probably have much of what they need baked in. That might explain why they’re pretty good at it just “by accident”. But to do this optimally I think they’d need self-reflection (e.g. access to probabilities of text given different contexts) that current LLMs aren’t typically capable of, and wouldn’t know how to manipulate correctly without task-specific training.  ↩ You could conjecture that near-optimal stylometry abilities are some kind of “emergent property”. But the general lesson so far is that LLMs mostly don’t have emergent properties but are just good at what they’re trained at.  ↩ (Meta-joke about you—person who works at an AI company—thinking, “maybe we should do that”, coming to this footnote, and seeing this meta-joke.)  ↩ Instead of “homogenizing” writing by imposing a generic style, perhaps it would be better to “camouflage” it by enforcing a very strong but random style.  ↩ Be a little careful here: Typically, the KL-divergence is understood to be measured in nats. But in this article, I’ve measured mutual information in bits. That’s fine, but you need to convert. For example, 106.2 bits = 73.60 nats.  ↩

0 views
Martin Fowler 3 days ago

Fragments: July 13

Some more of my notes from Thoughtworks Future of Software Development Retreat . When we had our first retreat in Utah early this year, nobody had heard of Harness Engineering . This time we had a whole session on it. When comes to the guide side of harnesses, most of the discussion is about context management. While context windows have increased is size as models get more sophisticated, that doesn’t mean that models will properly focus on the right bits. Models typically only focus attention on part of the context, and to get the best behavior, we need to manage that focus. One attendee keeps their context small, limiting the file to less than 200 lines On the sensor side, we see more attention on computational sensors. Two patterns from one participant was shifting to languages with greater controls, (eg Rust rather than Python) and “leveling up” validation approaches, using more property-based testing and techniques from formal methods. One commented that while they aren’t smart enough to write specifications in a formal specification language, they are smart enough to read it and check it makes sense for their domain. Will our attention on harnesses last long enough for our next retreat? Will the models just get so good that harnesses become unnecessary? Those with some mechanical sympathy for LLMs seem to think not - but are they overly coupled to the current state of technology? I find such speculation tends not to lead anywhere useful, I’ve not seen much success in guessing the future in the past, and with technology as radical as this, I don’t see it being any easier. So for the moment, attention to harnesses pays off. We find it reduces token usage, and also allows weaker models to be useful, supporting such things as local hosting of open-weight models. ❄                ❄ Which naturally segues me to a session on self-hosted models. Increasing token costs have made hosting an open-weight model more attractive, particularly due to the decreasing time for open-weight models to catch up with frontier models. Cost isn’t the only factor, however, many folks find a desire to be independent of the frontier model firms to be the the driving force. After all we’ve seen the U.S. government intervene to deny access to models, increasing the desire for greater model sovereignty. Information security is also something to consider, some attendees just can’t give models necessary data for critical work. Even without that, if someone else hosts the model then their model learns rather than your model. And although recent events have increased interest, several participants worked with companies that had been self-hosting for up to a couple of years. Is this trudging down the same path of self-hosted clouds, which led to lots of folks spending excessive funds on half-arsed private clouds ? The answer hinges upon whether it ends up being simpler to host a model than a cloud, perhaps due to a simpler interaction protocol. The hard part of this may be the talent required to efficiently use the GPUs, managing an inference data center currently isn’t a widely available skill. Even self-hosted models are a cost to operate, capital costs in GPUs, ongoing costs in electricity. The physical design of a data center can affect optimal usage. There’s an opportunity here for professional services firms to help companies manage this. Cost control also involves teaching people to pick the right model for the job. Can we teach engineers, or indeed other users, to pick a less-powerful model? This, of course, could be a job for model itself, acting as a broker, deciding which model is the best choice to tackle certain jobs. Self-hosting may lead to a greater use of fine-tuning. Currently that’s a niche activity, but over time we could well find that models that are fine-tuned to a particular domain need less reasoning, consume less tokens, and thus are cheaper to operate. We are seeing models trained specifically to support programming. As with any topic with this degree of uncertainty, the big win isn’t finding the right answer, but coming up with a strategy that will cope with the inevitable and unpredictable changes. ❄                ❄ After an event like this, many people come up to me and ask me to make some grand summing up. I hate this, because I rarely leave these kinds of event with some grand narrative. Even after mulling on it afterwards (in writing the above notes) I still usually don’t have one, and distrust one that forms, as my skepticism includes attempts to make coherent narratives of an event that’s naturally rather jumbled. However my failings are irrelevant this time, because Kief Morris has put together such a narrative, and it’s a convincing one , even to a narrative-denier like me. The sessions had different titles and different casts, and on the surface they were about different problems. But they weren’t. Nearly every one of them was a different facet of the same argument. How much do we let an agent decide, and how do we stay confident in what it does? He looks at code review, questions whether it matters, but sees that the rigor that many associate with code review shifts to other forms. He describes the disagreements about how much we should trust an agent to identify and fix production incidents. He sees that the contrast between how much leeway teams give to agents depends on the context they are operating Underneath all of these sessions, the operations debate, the wide-remit team, the dark-factory spectrum, the argument about who’s allowed to steer the model, people were making the same handful of choices over and over about a single thing: the unit of work they were prepared to hand to an agent. How big it is. How much of the job it covers. What you do to get it ready to hand over. How you check what comes back. What you put around the agent to keep it inside the lines. Different rooms set those differently, but they were setting the same controls. ❄                ❄ Sam Ruby convened a session called “Bring me a Rock”. The name evokes a particular kind of management dysfunction. The manager tells his underlings to bring him a rock, and then starts rejecting the results without explaining why (“no not that one”, “no not that one”) until eventually one rock matches the unstated expectation. It names a manager who substitutes serial rejection for the work of saying what they want, and makes you pay for their unfinished thinking one rock at a time. Sam had already written why he thought with LLMs, this changed from a slur to a defensible way to work . When its a bunch of tireless machines with endless patience, that return new rocks in minutes rather than days, then an approach like this (using the brainstorming register becomes a defensible way to work. Sam described the discussion : The room pulled it somewhere narrower than I’d framed, and the narrower place was the more interesting one: not how to explore by elimination but who should even be allowed to. Product managers, increasingly people managers, are reaching for these models directly, and seasoned engineers get measurably better results from them than untrained people do — so the worry followed. If expertise is what separates a good outcome from slop, should non-engineers be steering the model at all? It’s a fair question, and I think it’s the wrong one, because it mistakes the act. When a manager reaches for an LLM instead of routing the work to the team that reports to them, they didn’t pick up a tool — they made a hire. And you don’t ask permission to manage your own team; a manager who decides a piece of work is better given to a new participant than to the existing one is doing the most ordinary thing a manager does. Framed that way, the permission question dissolves into an older, better-understood one — the one Drucker named in 1959: when the worker knows more about the specifics than the manager does, you manage by objective, not by method. The non-engineer steering an agent is exactly that manager, out-known by the thing they’re directing, and the slop the room feared is the old danger of managing by method when you should be managing by objective. The question isn’t may they hire? It’s do they know how to manage by objective? — which you can teach, hire for, and hold people to without anyone first becoming an engineer. Sam’s article explores managing an LLM by objective, giving it a goal rather than a task. And Kief’s earlier point about the essence of the discussion still holds: how confident can we be that it’s done the right thing? We can outsource many things, but not the acceptance criteria, at some point there’s a human request and a human judgment on whether that request was properly executed. But the danger lies in important unstated objectives, unstated perhaps because they weren’t even imagined. It’s easy to state objectives around desired functionality. Give me a an application that will examine my emails and form a todo list for today. But behind that simple statement is a thicket of unstated assumptions. We tend to assume The Genie won’t include any undesired functionality, perhaps deleting emails it thinks are unworthy of our attention. We assume it won’t let an email tell it to send private information to [email protected]. We have some hope here - we hear more experiences that suggest that recent models can do an excellent job of finding (and hopefully fixing) security holes. The careful precision of the machine outruns the sloppy if imaginative thinking in squishyware. Perhaps we can assume the genie can take care of some of our unstated objectives. Conformance tests (sensors) are more valuable than specifications (guides), but it’s hard to imagine all the conformance tests that are needed to say what shouldn’t happen. Furthermore, building software is about exploration, finding out how a workflow can evolve as machines are embedded in the process. For a human to guide that process, we need some understanding of it. My sense is that model building is still important, and while I agree that the genie can take an active role in that construction, I don’t think the human can entirely outsource it. Even if the genie builds the model itself, it needs to teach us that model, because the model helps us imagine and communicate the goals, the objectives that we give to the machine. ❄                ❄                ❄                ❄                ❄ If you follow my feeds (which you probably do if you’re reading this), then you’ll know that Birgitta Böckeler has written a couple of memos on working with local models. She first looked the factors that influence how viable they are for programming , and then related some of her recent experiences evaluating such models . As a nice, if accidental, complement to these, Sebastian Raschka wrote a detailed guide to his local model environment . Like Birgitta, he’s found the Qwen 3.6 model to be the current sweet spot for local agentic programming. ❄                ❄                ❄                ❄                ❄ Simon Willison shares a useful tip to save money while using the latest Anthropic Fable model Tell Fable to use other models for smaller tasks, applying its own judgement about which model to use. ❄                ❄                ❄                ❄                ❄ Josh Comeau writes a blog and online courses for developer education, primarily front-end web material. His been successful for most of this decade but has found his online courses have had only ⅓ the sales this year . He attributes this to AI, partly as people worry if it’s worth spending money on a job that may not have a future, but also because AI can provide personalized tutoring. ideally, it shouldn’t cost any money to learn stuff. But I sorta worry about how this is supposed to work, going forwards, if there’s no incentive for people to make high-quality free content. I’ve spoken to a few course creators now, and we’re all seeing the same trend. Revenue down 50%+. Fewer people engaging with our content. People switching to LLMs, which slurp up all of our work and regurgitate it, without consent or compensation. It feels pretty bleak. 😅 ❄                ❄                ❄                ❄                ❄ John Gruber is annoyed that Claude’s desktop app for MacOS in uses Electron . Electron guarantees that an app feels just as wrong on all platforms. He has some tasty invective for the folks at Anthropic with ties to the Electron platform. Finding out that one guy — who is a senior Electron maintainer — has led the teams for the desktop clients for Slack, Notion, and now Claude is like discovering that it was one guy — whose family business was a distillery — who helmed the Titanic, piloted the Hindenburg, and then served as air traffic controller for Amelia Earhart. The deeper question here is whether there should be a future for cross-platform front-ends in the world of agentic programming. There’s lots of evidence that coding agents do a great job of building the same thing in multiple languages and platform ecosystems. That should mean that the days of least-denominator cross-platform UIs are numbered - and that number is small. ❄                ❄                ❄                ❄                ❄ Dan Davies tries to draw a distinction between interactional and contributory expertise . Contributory expertise is that held by people who are doing the work to advance a field of study, interaction expertise is held by folks that spend time talking to contributory experts, building up a decent store of knowledge themselves, but not steeped in the day-to-day of the work. it seems to me that there is an important distinction here, which is not any less important because the dividing line might be difficult to establish empirically, or even if that line turns out to be in a different place from where we guessed it was. As well as difficult cases where it’s not clear, I think we could also come up with cases where the distinction between interactional and contributory expertise would suddenly become very clear and important indeed – the ones where someone who was faking it got “found out”. And so the question that I think is quite important is whether there is a similar kind of distinction between the kind of expertise that it’s possible for a machine to get by industralised consumption and interaction with a much larger corpus of literature than any human being could inhale, and genuine contributory expertise that could apply to entirely new situations outside that literature. As a human, I’d like to think I’m more of a contributor than an interactor (especially given my increasing introversion), and thus relatively safe from being forced into obsolescence by silicon. But I’m also aware that my career is devoid of any original ideas, my skill is only that of someone who is good at selecting and explaining the ideas of others. (As Brian Foote put it more memorably: “an intellectual jackal with good taste in carrion”.) But there’s skill in being a good jackal too - and we don’t really know yet where the real boundaries of the LLMs will lie.

0 views
James Stanley 3 days ago

Optimistic epsilon-greedy

I've been working on optimising revenue on my Countdown website the last few days. I have had a Countdown solver tool online since about 2009. It is to this day the most popular website I have ever made, it currently gets about 70,000 pageviews per month. The site has been earning revenue from AdSense for years. Up until last week the site was just 2 static HTML pages: one for the Countdown Solver and one for the Countdown Practice game. That didn't give me much opportunity to run experiments on the site, and I never really had the inclination to try. It was basically a web program . But now, LLMs to the rescue. I now have a Python Flask application serving the site, and a lot more related information pages for people to read. And serving the site with an actual web application means I can run experiments like A/B tests to see if there are changes I can make to the site that cause people to stick around longer. And therefore look at more ads. A good alternative to A/B tests is multi-armed bandits . Instead of splitting your traffic equally between the different variants you want to try out, and then waiting to collect data, and then picking a winner, you have the site automatically determine the winner on a continuous basis, and show the winner 90% of the time (greedy), and a random selection the rest of the time (epsilon). I am using a multi-armed bandit to decide which "info" pages to suggest at the bottom of each page, and also to decide which Amazon Affiliate links to show. (Yes this is all very grubby, what can you do?). The "winner" is the choice that has the highest click-through rate. So for each choice we need to track how many times we've displayed it, and how many times it's been clicked on. If your reward function is more complicated you might find it more complicated. Steve Hanov's blog post on multi-armed bandits, linked above, goes over the case where you might worry that a particular variant gets a click early, just by random chance, which gives it an apparent high click-through rate, which then means the site is going to show that variant to everyone. And that's not actually a big deal, because showing the apparently-high-performing variant to 90% of traffic gives it a lot of opportunities to prove that it's not actually that good, and it's click-through rate will come back down. A much bigger issue, in my opinion, is when you add a new variant. Let's say you already have 9 variants that all have click-through rates around 1% and have had about 1000 views each. Then you add a new variant. This new variant starts out with 0 clicks. Now you have 10 variants, 9 of which have a CTR of 1% and your new one has a CTR of 0% (technically a degenerate case with 0 views, but becomes firmly 0% after the first view). And let's say your site expects 1000 views per day. 90% of the time your site is going to be showing one of the old variants, because no matter what happens to their CTRs, they can't go below 0% , so they will forever look better than your new variant. The remaining 10% of the time your site is going to be picking at random amongst all variants. So your new variant is going to get about 1% of your traffic. Or 10 views per day. If your new variant also has a CTR of about 1%, then you'll expect to get about 1 click per 100 views. If it is only getting 10 views per day then it could easily be 10 days before you get the first click, during which time you're not even gathering much data on it. So what I'm doing instead is defining the CTR to be (clicks+1)/(views+1) . That is, we always optimistically assume that the next view is going to get a click. That means a new variant starts out with a CTR of 1/1 = 100% . We skew the selection towards those variants that have not had many opportunities to prove themselves yet. In this case the new variant will get 91% of the traffic until its optimistic-CTR falls below that of the next best variant. That could easily happen within the first day of releasing the new variant, so this "optimistic epsilon-greedy" algorithm broadly behaves exactly the same once the number of views is high enough, but it discovers the true CTR for newly-added variants much more quickly than the standard algorithm. Even if the new variant actually never generates any clicks, its CTR drops below 1% within about 100 views ( 1/101 ) so it won't be taking much traffic away from your older variants if it doesn't work very well.

0 views
Simon Willison 1 weeks ago

sqlite-utils 4.0, now with database schema migrations

This morning I released sqlite-utils 4.0 , the 124th release of that project and the first major version bump since 3.0 in November 2020. In addition to some small but significant breaking changes (described in this upgrade guide ), this version introduces three major features: database migrations , nested transactions (via a new method), and support for compound foreign keys . Schema migrations define a sequence of changes to be made to a SQLite database, plus a mechanism for tracking which migrations have been applied and applying any that are found to be pending. Migrations are defined in Python files using the sqlite-utils Python library , which includes a powerful method providing enhanced alter table capabilities that are not supported by SQLite's statement. ( implements the pattern recommended by the SQLite documentation - create a new temporary table with the new schema, copy across the data, then drop the old table and rename the temporary one in its place.) Here's an example migration file which creates a table called , adds an additional column to it in a second step, then changes the types of two of the columns in a third: Save that as and run it against a fresh database like this: Then if you check the schema of that database: You'll see this SQL: The table is used to keep track of which migration functions have been run. The table above is the schema after all three migrations have been applied. To see a list of migrations, both pending and applied, run this: If you don't specify a migrations file, the command will scan the current directory and its subdirectories for files called and apply any instances it finds in them. You can also execute migrations from Python code using the method, which is useful for building tools that manage their own database schemas over multiple versions. My own LLM tool has been using a version of this pattern for several years now, as shown in llm/embeddings_migrations.py . My favorite implementation of this pattern remains Django's Migrations , developed by Andrew Godwin based on his earlier project South . Fun fact: Andrew, Russ Keith-Magee, and I presented our competing approaches to schema migrations for Django on the Schema Evolution panel at the very first DjangoCon back in 2008! My attempt was called dmigrations , developed with a team at Global Radio in London. Django's migrations can be automatically generated from model definitions and include the ability to roll back to a previous version. The approach is deliberately simpler: unlike Django, encourages programmatic table creation rather than a model definition ORM, so there isn't anything we can use to automatically generate migrations. I decided to skip rollback, since in my experience it's a feature that is rarely used. With a SQLite project, an easy way to achieve rollback is to create a copy of your database file before you apply the migrations! The design of migrations is three years old now - I had originally released it as a separate package called sqlite-migrate , which never quite graduated beyond a beta release. I've used that package in enough places now that I'm confident in the design, so I've decided to promote it to a feature of to make it available by default to all of the other tools in the growing sqlite-utils/Datasette/LLM ecosystem. I made one last release of , which switches it to depend on and replaces the file with the following: Any existing project that depends on should continue to work without alterations. Here are the release notes for this version, with some inline annotations: The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features: I think of migrations as the signature new feature, hence this blog post. has long had a confused relationship with database transactions, partly because when I started designing the library back in 2018 I didn't yet have a great feel for how those worked in SQLite itself. Adding migrations to the core library made me determined to finally crack this nut, since transactions make migration systems a whole lot safer and easier to reason about. I ended up building this around a context manager which looks like this: SQLite supports Savepoints , and as a result can be nested to carry out transactions inside of transactions. It's pretty neat! This came about when I asked a coding agent to review all open issues and PRs for things that should be included in a 4.0 release since they would represent breaking changes if I added them later, and it correctly identified that compound foreign keys were exactly that kind of feature. I started with a breaking change to the table.foreign_keys introspection method, and then decided to see if Claude Fable 5 could handle the more fiddly job of integrating compound foreign key creation into the library. The API design it helped create felt exactly right to me - consistent with how the rest of the library worked already. Other notable changes include: This was the change that first pushed me to consider a breaking-change 4.0 version bump. I built this to help support sqlite-chronicle , which uses triggers to keep track of rows in a table that have been inserted, updated or deleted. Probably the most disruptive breaking change - I've had to update a few places in my own code to switch from to as a result. The flag was a later addition to allow column types (text, integer, real) to be automatically detected based on the data in a CSV. It should be the default, and releasing a 4.0 means I can make it so. The oldest issue addressed by this release - the underlying bug was opened (by me) in October 2020. See Upgrading from 3.x to 4.0 for details on backwards-incompatible changes. The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in 4.0a0 , 4.0a1 , 4.0rc1 , 4.0rc2 , 4.0rc3 and 4.0rc4 . The upgrade guide was entirely written by Claude Fable 5, Claude Opus 4.8 and GPT-5.5. The same is true of the release notes. This is the kind of documentation I've slowly become comfortable outsourcing to the robots. It doesn't need to convince people of anything, or express any opinions - its job is to be as accurate and detailed as possible. I've reviewed the release notes closely and can confirm they are accurate and comprehensive. I released the first alpha of sqlite-utils 4.0 over a year ago . I've been dragging my heels on the stable release because of the amount of work it would take to track down and clean up the many other minor design flaws that a major version number allowed me to take on. Assistance from Claude Fable 5 (and to a lesser extent Opus 4.8 and GPT-5.5) gave me just the boost I needed to overcome inertia and make the most of the time I could afford to spend on this library. Fable has really good taste in API design, and is relentlessly proactive if you give it a more open goal. My most successful prompt was a review task that I issued against what I thought was the last release candidate: I tried this with GPT-5.5 xhigh in Codex Desktop and Fable 5 in Claude Code. GPT-5.5 wrote 5 Python scripts and didn't turn up anything particularly interesting - its final report is here . Fable 5 wrote 12 scripts , identified 4 release blockers and 10 additional issues in its report , and built a neat combined repro script , which, when run, output the following: I found myself agreeing with almost all of them. Here's the PR with 16 commits where we worked through them in turn. There's no doubt in my mind that sqlite-utils 4.0 is a significantly higher-quality release than if I had built it without the assistance of the latest frontier models. 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 . Database migrations , providing a structured mechanism for evolving a project’s schema over time. ( #752 ) Nested transaction support via , plus numerous improvements to how transactions work across the library. ( #755 ) Support for compound foreign keys , including creation, transformation and introspection through table.foreign_keys . ( #594 ) Upserts now use SQLite’s syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. ( #652 ) now executes immediately and rejects statements that do not return rows; use for writes and DDL. CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables’ column types. ( #679 ) and no longer create lookup table records for all- values. ( #186 )

0 views
Takuya Matsuyama 1 weeks ago

Inkdrop Roadmap vol.6: Completed 🎉 — Now preparing for the official v6 release

Hi folks, it's Takuya here, the solo developer of Inkdrop . I'd like to report a status update on the Inkdrop project here. About a year and a half ago, I published the roadmap of Inkdrop vol.6 . And I'm happy to announce that every planned feature and improvement on that roadmap is now done! 🥳 They all shipped as part of the v6 canary series — 21 canary releases so far, built and tested together with the community. When I wrote the roadmap, I honestly wasn't sure how long it would take. I would have been surprised if the me of that time had seen this result. Thank you so much for all your feedback along the way — I couldn't have done it without you. Even beyond the roadmap, I've added so many new features and improvements. So, I'm confident you'll enjoy it if you're coming from v5. Let's dive into what I accomplished along the roadmap, what came out of it beyond the plan, and what's next. What made the development slow down was the huge technical debt, as I mentioned in the past post . Inkdrop was originally built on the Atom editor's framework, and when Atom was sunsetted in 2022, many of the modules it depended on were no longer maintained. I had to replace them one by one while keeping the app stable — the hardest and least visible part of this journey. With v6, that debt is finally paid off. Here's a quick before & after: None of these are shiny features on their own. But they're exactly what allowed me to ship everything you'll see below, and they make Inkdrop much faster to develop going forward. The codebase is now modern, healthy — and honestly, fun to work on again. I'm an indie developer, and Inkdrop is a one-person project — so manpower has always been the bottleneck. Paying off the tech debt was a particularly big headache: some of the inherited modules were so large that it originally took the whole Atom team to maintain them. But thanks to the recent advancements in coding agents, that burden finally feels manageable — and even enjoyable to tackle. AI didn't just speed up the coding; it changed how I work: These new workflows have opened up possibilities that simply didn't exist for solo developers before. A refactoring of this scale used to be unthinkable for one person — now I can maintain a codebase that once took a team, and spend the saved energy on what matters most: the product itself and my users. Here's the roadmap vol.6, item by item, with what actually shipped: The roadmap was only half the story. While working through it, I ended up rebuilding a huge part of the app and shipping a lot of features that weren't planned. Here are the highlights, grouped by area: And on top of all that, hundreds of bug fixes reported by canary testers. The community has also been building amazing plugins on the new APIs — note-tabs (browser-like note tabs), code-runner (run JS/Python code blocks in notes), constellation (an interactive note graph), copy-as-jira , kanso-ink (theme), and more. Existing plugins are getting v6 support too, like hitahint , link-compact , thumbnail-list , and editor-utils . My goal remains the same as I wrote in the roadmap: keep improving the core user experience without bloating the app, so you can stay focused on taking notes. I believe v6 embodies exactly that. You can download the binary here: Please create a topic on the “ Issues > Canary ” category. This is the most preferred way for me because I can manage which issue has been resolved or not. We have our Discord server , where you can casually discuss and talk with other users. With the roadmap completed, I've shifted gears to preparing for the official release of v6 . That means polishing the details, stabilizing the canary builds, updating the documentation and the website, and helping plugin and theme authors migrate. Especially, building a new landing page is gonna be fun! I'm also going to work on the mobile app as well. The official v6 release is getting close. Stay tuned! 💪 I manage implementation plans as Inkdrop notes and let the agents work through them. Watch: Note-driven agentic coding workflow using Claude Code and Inkdrop I built and published a tool to manage multiple Claude Code sessions on tmux . While building the AI features, I had an agent explore Zed's source code and save the report to Inkdrop , to learn how it implements similar functionality. ✅ Share target & share extension — You can quickly stock web pages into Inkdrop from other apps on mobile. ( v5.5.0 ) ✅ Command palette — It became Telescope , a versatile Spotlight-like search bar (the name is borrowed from telescope.nvim, haha). It fuzzy-searches commands, notebooks, tags, and the table of contents of the current note, with scope prefixes like for commands and for notebooks. It's extensible, so plugins can add custom sources. ( canary.1 ) ✅ Migrate to CodeMirror 6 — The biggest one. The whole editor was rebuilt on CodeMirror 6, and it enabled a bunch of new editing features: a floating toolbar, slash commands, GitHub Alerts syntax support, emoji autocompletion, autocompletion inside code blocks, and quick note-link insertion with . ( canary.1 ) ✅ Outline view — Powered by Telescope. Click the button in the editor header (or run ) to jump between sections. It highlights the current section based on your cursor or scroll position, and even lists task items. It's provided as a plugin ( telescope-toc ), which doubles as a reference implementation for custom Telescope sources. (Thanks Basyura-san for the original sidetoc plugin!) ( canary.6 ) ✅ Preview pane improvements — Copy buttons for code blocks landed in both the preview and the editor, and double-clicking an image opens it in an image viewer. As a bonus, find-in-preview finally works — it highlights matches even across DOM elements, which is essential for finding text in code blocks. (Thanks q1701 and Basyura for the original plugins!) ( canary.2 , canary.4 ) ✅ Two-factor authentication — OTP-based 2FA is available for your account. ( v5.11.0 ) ✅ Prepare for ARM64 & other platforms — This required repaying a lot of technical debt. I replaced the deprecated LevelDB backing store with SQLite , stopped bundling (which used to bundle all of Node.js and npm!), and rebuilt it as a lightweight standalone CLI ( @inkdropapp/ipm-cli ). As a result, Inkdrop now supports ARM64 on Windows and Linux , plus Flatpak and AppImage packages for modern Linux distros. ( canary.1 , canary.4 , canary.5 ) ✅ Improve image upload speed — Attachments are now uploaded in parallel via signed URLs, so syncing image-heavy notes is significantly faster. ( canary.12 ) ✅ Diff view for revision history on desktop — The diff view I loved on mobile is now on desktop, too. ✅ Notebook icons — You can assign custom icons to notebooks from a picker with 1,500+ icons from the Lucide icon set, with category tabs and search. Icons show up everywhere — the sidebar, Telescope, and notebook selectors. ( canary.9 ) ✅ Visualize your progress and achievements — The activity stats view shows how many notes you created and tasks you worked on over the past 52 weeks, along with your current and longest streaks. Note-taking is a contribution to your work, after all! ( canary.14 ) ✅ AI integrations — Shipped as an opt-in, bring-your-own-API-key design, so you stay in control of your data. The inline AI assistant transforms selected text in place with built-in prompt presets (proofread, summarize, Mermaid diagrams, Markdown tables, and your own custom prompts). Next Edit Suggestions predicts your next edit like GitHub Copilot — set to manual trigger by default so it doesn't distract you — and it can even draw context from your linked notes and backlinks. ( canary.16 , canary.18 , canary.20 ) Reading highlights — Select text and hit the highlight button to wrap it in a tag, rendered beautifully in the preview. Perfect for emphasizing what resonates in your reading notes. ( canary.3 ) Native spellcheck support — The editor now uses the OS-native spellchecker. ( canary.10 ) Smarter link pasting — Pasting a URL now suggests link formats inline through the autocompletion menu instead of a dialog, and the page title is fetched in the background so nothing interrupts your flow. ( canary.15 ) Create a note from autocomplete — Start typing a title after , choose "Create new note," and it's created, linked, and opened in one step. ( canary.16 ) Little things that add up — ToDo item strikethrough, link-open tooltips, commands (Thanks Lukas and TheRabidOstrich !), View menu toggles for line numbers / line wrapping / readable line length, and a refurbished editor header with navigation back/forward, view mode buttons, and a native action menu (Cmd/Ctrl+J). ( canary.2 , canary.3 , canary.12 , canary.18 ) Embed GitHub code snippets by pasting a link — Paste a GitHub source URL and the code is fetched and inserted as a syntax-highlighted snippet with line numbers and a link back to the source. Connect your GitHub account via OAuth and it works with private repos too, including rich link titles for repos, issues, and PRs. ( canary.6 , canary.11 ) Advanced code blocks — Language icons, line numbers, and meta info rendering, plus GFM highlighting inside fenced code blocks — nested code blocks and YAML frontmatter included. ( canary.6 , canary.9 , canary.20 ) Mermaid got a serious upgrade — A pan & zoom toolbar with a full-screen viewer, and diagrams are now themed entirely through CSS variables, so they automatically match your theme in light and dark mode. (Thanks @inkwadra for the original pan/zoom PR!) ( canary.21 ) Manual notebook ordering — Drag and drop notebooks in the sidebar into your preferred order; it syncs across devices. ( canary.9 ) Fuzzy matching everywhere — Telescope, the notebook and tag list menus, and the tag input all use the same fuzzy-matching algorithm, so you find things fast without spelling them right. ( canary.15 ) Quicker navigation — Filter buttons for notebooks and tags in the sidebar, a search bar in the notebook picker, context menus on the workspace and note-list headers, and a sort-order button that shows the current order as a label. ( canary.6 , canary.15 , canary.16 ) Keep running in the system tray (Windows & Linux) — Handy if you use the local HTTP API, and it makes reopening the app instant. (Thanks Kyoichiro-san and Micha for the request!) ( canary.21 ) Plus a custom-built tooltip UI, a macOS "Look Up Selection" context menu, and an account usage stats tab. ( canary.14 , canary.16 ) A new CSS-variable-based theming system — Themes are now a thin layer of variables over the base styles instead of a full Semantic UI stylesheet, which makes them far easier to build and maintain. ( canary.18 ) One theme package instead of three — The UI / syntax / preview theme types inherited from Atom have been merged into a single unified package that styles the whole app. ( canary.21 ) Live theme previews — The Themes preferences show preview cards rendered live from each theme's color palette, and is uploaded to the plugin registry to power previews before you install. ( canary.20 , canary.21 ) New official themes — Kanagawa ( Wave / Dragon / Lotus ), Solarized ( Light / Dark ), and Nord ( Dark / Light ), plus a default syntax theme overhaul built on modern CSS like . ( canary.18 , canary.20 , canary.21 ) Dropped Electron's module — I replaced it with type-safe IPC bridges in a massive architectural overhaul. Database access from plugins became roughly 13x faster , and the app is more secure because only intended methods are exposed. ( canary.11 ) SQLite as the backing store — Replacing the long-deprecated LevelDB unblocked ARM64 support and repaid one of the oldest debts from the Atom era. ( canary.4 ) Modern build pipeline — Migrated from Webpack + Grunt to electron-vite (Vite + Rolldown), which made production builds 10x faster and the dev build launch almost instant. I also converted all Less stylesheets to plain CSS, moved drag & drop from the unmaintained to , and kept Electron riding the latest releases throughout the canary series. ( canary.14 , canary.18 ) Security hardening — Access keys moved to the system keyring, and the login flow is protected with Cloudflare Turnstile against credential-stuffing bots. ( canary.16 , Security Update ) A brand-new CLI — No more bundled Node.js and npm. It publishes tarballs directly like npm (no more committing compiled files to GitHub), and scaffolds a new plugin or theme in seconds with TypeScript all wired up. ( canary.5 , canary.18 ) Official TypeScript definitions — @inkdropapp/types gives plugin authors full type safety without exposing the app's internals. ( canary.14 ) Auto-installed essential plugins — mermaid, math, and markdown-emoji are installed and kept up to date automatically, and you can disable them anytime. ( canary.14 ) Vim plugin improvements — Relative line numbers (Thanks @p1n9_d3v !) and an option to keep Vim registers separate from the system clipboard (Thanks @birtles !). ( canary.11 ) Updated docs — The plugin migration guide and theme development guide are refreshed for v6, along with new component and module references. https://my.inkdrop.app/download/canary Inkdrop Website: https://www.inkdrop.app/ Send feedback: https://forum.inkdrop.app/ Join the Discord server: https://docs.inkdrop.app/start-guide/join-discord-server 𝕏: https://x.com/inkdrop_app 🦋: https://bsky.app/profile/devaslife.bsky.social

0 views
Simon Willison 1 weeks ago

sqlite-utils 4.0rc2, mostly written by Claude Fable (for about $149.25)

I wrote about the sqlite-utils 4.0rc1 release a couple of weeks ago. Since we only have Claude Fable on our Max subscriptions for a few more days, I decided to see if it could help me get to a 4.0 stable release that I felt truly comfortable about, since I try to keep to SemVer and like my incompatible major versions to be as rare as possible. I started with this prompt, in Claude Code for web on my iPhone: Here's that initial report it created for me. There were some significant problems that I hadn't myself encountered yet - 5 that Fable categorized as "release blockers". Here's the worst of the bunch: 1. never commits and poisons the connection (data loss) ( ) runs its DELETE via a bare with no wrapper — compare at , which wraps correctly. The connection is left , so every subsequent call takes the savepoint branch ( ) and never commits either. Reproduced end-to-end: That's a really bad bug! Very glad I didn't ship that, although at least it would have been a bug I could fix in a 4.0.1 point release, not a design flaw that would force a 5.0. Over the course of 37 prompts, 34 commits and +1,321 -190 code changes over 30 separate files, we worked through the entire set of feedback in turn, making several other design improvements along the way. A weird thing about coding agents is that harder tasks like this one actually provide more opportunity to do other things at the same time, since the agent sometimes needs 10-15 minutes to churn away on a new task. I went out to enjoy the Half Moon Bay 4th of July parade, occasionally checking in and prompting the next step for Fable from my phone. Full details in the PR and this shared transcript . I switched to my laptop for the final review, which I conducted through GitHub's PR interface. The most significant changes relate to transaction handling, which was the signature new feature in the earlier RC . The new RC now includes comprehensive documentation on the new transaction model, the intro to which I'll quote here in full: Every method in this library that writes to the database - , , , , , , , , and the rest - runs inside its own transaction and commits it before returning. Your changes are saved to disk as soon as the method call finishes: The same applies to raw SQL executed with db.execute() - a write statement is committed as soon as it has run. You never need to call , and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions: You want to group several write operations together, so they either all succeed or all fail - use db.atomic() . You are managing a transaction yourself with , in which case nothing is committed until you commit - the library will never commit a transaction you opened. In reviewing Fable's documentation - I find that reviewing the documentation edits first is an excellent way to build an initial understanding of what has changed - I spotted this detail : and the automatic per-method transactions are designed for connections in Python's default transaction handling mode. Connections created with the Python 3.12+ or options are not supported, because and behave differently on those connections. I admit I hadn't thought about how would react to the more recent autocommit setting , added in Python 3.12. It turns out "behave differently on those connections" equated to almost the entire test suite failing, so I worked with the model to ensure that this difference would not break how the library works. I used to think that the idea of having one model review the work of another was somewhat absurd - it felt weirdly superstitious. The problem is it really does work - I've started habitually having Anthropic's best model review OpenAI's work and vice versa, because I've had that turn up interesting results often enough to be valuable. I prompted Codex Desktop and GPT-5.5 xhigh with the following: Which was enough to turn up two issues worth investigating: I pasted that into a fresh Fable session, which ran some experiments to confirm the problem: Both findings were confirmed. called first, which auto-commits writes, and only then checked — so committed the update before raising . And the commit lived at the end of the returned generator, so it never fired unless you exhausted the iterator — or an un-iterated call left the transaction open, contradicting what the changelog and docs promise. Here's the PR with the fix, and the full Claude Code transcript . Reviewing this code helped me build a better mental model of the edge cases of SQLite transaction semantics! I upgraded to the Claude Max $200/month plan (I was previously on $100/month) to increase my Fable allowance for the remaining time until the July 7th Fablepocalypse , when even Claude Max subscribers will have to pay full API cost for the model. I was curious as to how much this would have cost me if I had been paying those costs directly. At first I thought those numbers weren't available to me since I had run the work remotely using Claude Code for web, and then I realized I could run AgentsView inside that existing session to get that cost estimate! Claude figured out how to use the command and came out with the following: I'm very glad I'm on that subscription! I really should have followed my own advice and leaned more heavily into subagents with cheaper models. Here's what claude.ai/settings/usage is showing me right now: I have several other major Fable-driven projects on the go right now as well, with the goal of hitting 100% on that Fable bar just in time for the price increase. Here are the full release notes for the RC. I had Fable add these to an "Unreleased" section of the changelog as each change landed, reviewing them as it went. This has the neat side effect that the commit history of the changelog acts as a concise summary of each of the changes that went into the release. In the past I've had a policy of writing release notes by hand, but honestly these are better than I would have created myself. Release notes are a great example of writing that I'm OK to outsource to agents because they need to be boring, predictable and accurate. Breaking changes: Everything else: 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 . You want to group several write operations together, so they either all succeed or all fail - use db.atomic() . You are managing a transaction yourself with , in which case nothing is committed until you commit - the library will never commit a transaction you opened. [P1] sqlite_utils/db.py:663 now rejects non-row statements only after calling , and sqlite_utils/db.py:705 auto-commits those writes first. So raises but the update is already committed. That is a surprising side effect for a method documented as “can only be used with SQL that returns rows.” [P1] sqlite_utils/db.py:672 through only commits after the returned generator is fully exhausted. without iteration, or common usage, leaves the transaction open and the write can be rolled back on close. This contradicts docs/changelog.rst:15 and docs/python-api.rst:232 , which say it takes effect without iteration. Write statements executed with are now committed automatically, unless a transaction is already open in which case they join it. Previously they opened an implicit transaction that stayed open until something committed it - writes appeared to work when read on the same connection but were silently rolled back when the connection closed. Code that relied on rolling back uncommitted writes should use the new method to open an explicit transaction first. The transaction model is documented in full at Transactions and saving your changes . now executes its SQL as soon as it is called, rather than waiting until the returned generator is first iterated. Rows are still fetched lazily during iteration. SQL errors are now raised at the call site, statements such as are executed and committed immediately without needing to iterate over their results, and passing a statement that returns no rows - previously a silent no-op - now raises a recommending instead. A statement rejected this way is rolled back before the error is raised, so it has no effect on the database. Python API validation errors now raise instead of . Previously invalid arguments - such as with no columns, on a table that does not exist, or passing both and - were rejected using bare statements, which are silently skipped when Python runs with the flag. Code that caught for these cases should catch instead. and now raise if a record is missing a value for any primary key column, or has a value of for one. Previously such records - which can never match an existing row - were quietly inserted as brand new rows, or triggered a confusing after the insert had already taken place. and now raise a if called while a transaction is open. Previously they would silently commit the open transaction as a side effect of changing the journal mode, breaking the rollback guarantee of and of user-managed transactions. The class no longer has an method. It existed only to raise , since full-text search is not supported for views - calling it now raises instead, and the method no longer appears in the API reference. The command shows a clean error when pointed at a view. The no-op flag has been removed from the and commands. Type detection has been the default for CSV/TSV data since 4.0a1, so the flag did nothing - invocations using it should simply drop it. remains available to disable detection. now raises a if passed a connection created with the Python 3.12+ or options. and behave differently on those connections, which previously caused every write made by the library to be silently discarded when the connection closed. Fixed a bug where , and did not commit their changes, leaving the connection inside an open transaction. Their work - and any subsequent writes - could then be silently rolled back when the connection was closed. All three now use , consistent with the other write methods. The command now refuses to drop a view, and refuses to drop a table. Previously each would silently drop the wrong type of object if the name matched. Both now exit with an error suggesting the correct command to use. Migrations applied by the new migrations system now run inside a transaction, together with the record of the migration having been applied. If a migration raises an exception its changes are rolled back and it stays pending, so it can be safely re-applied after the error is fixed. Migrations that cannot run inside a transaction, such as those executing , can opt out using - see Migrations and transactions . and now detect the primary key or compound primary key of an existing table, so the argument is no longer required when upserting into a table that already has a primary key. can now be used to insert a row consisting entirely of default values into an existing table, using . ( #759 ) Improvements to the command: values that do not match any known migration are now an error instead of being silently ignored, now works correctly with migration files that still use the older class, and is now a read-only operation that no longer creates the database file or the migrations tracking table. now returns migrations in the order they were applied. New , and methods for taking manual control of transactions, as an alternative to the context manager. New documentation: Transactions and saving your changes describes how transactions work and when changes are committed, and a new Upgrading page details the changes needed to move between major versions.

0 views
Max Bernstein 1 weeks ago

Travel notes: PLDI Boulder

I had another excellent PLDI this past June. It was my fourth 1 . I continued to meet new people and learn new things! Overall: I got to meet a lot of new people, which was exciting. I had some good chats about research. I asked a question at a talk! I got to show Aaron and Jacob PLDI and see them enjoy it. I missed hanging out with CF Bolz-Tereick and Chris Fallin, the usual suspects at conferences I attend. I’m looking forward to next year. This post is more about the conference than the town of Boulder (unlike the last PLDI post about Seoul) because I didn’t do much Boulder exploring. I got in late on Sunday. Then I had to take a long car ride from Denver airport to Boulder. I don’t think I had ever flown into Denver with intent to go to Boulder before so it was a bit of a surprise. Jacob offered to have a late dinner with me so we had a tasty meal at Gaia Masala and Burger. Shout out to Harry, our server. Monday was a workshop day. I signed up for EGRAPHS and mostly stayed in that workshop. People kept throwing around the term “Knuth-Bendix”, as they have been for several years, and only in one of these workshop talks did someone explain it in a way that made any sense at all. It seems kind of like equality saturation but for the rewrite rules themselves—no actual expression graphs involved. I DMed Phil this sketchy explanation during a talk to get his response and I got to watch him cock his head and think about it in real time. At lunch I met Qiantan Hong and we got to talking about Common Lisp and its object system, CLOS. Seems like a combination of ahead-of-time compilation and multiple dispatch is really tricky. I had dinner with Aaron at Postino and then wandered into a bunch of people staying at the conference hotel chatting in the lobby. Ben Titzer said “fix my subtyping bug”, which I interpreted as him saying hi. I ended up just planting myself at the table as a bunch of interesting people cycled through: Jared Roesch, Mae Milano, Hila Peleg, Russel Arbore. It was a late evening. Back to the workshops! But late because of aforementioned late evening. I saw Vadym’s talk about Remora. I only understood about 40% of it but it was good to catch up. I hadn’t seen him since leaving Northeastern. Around a break time I joined a little cluster of people talking about e-graphs and I guessed asked enough basic questions that Pavel convinced Max Willsey to run a “BYOEG” (build your own e-graph) tutorial. The structure was as follows: Max would instruct Jacob as to what kind of thing to build next but not be prescriptive about exactly how to build it and not look at Jacob’s screen. The rest of us would sit around a table and try to follow along as best we could. I hear Pavel has a blog post about this experience coming soon… I saw Slava Pestov walking around and introduced myself because we keep liking one others’ bad jokes on Mastodon. We ended up getting dinner with Aaron and Jacob that night at Leaf. We learned a lot about monoids, Knuth-Bendix (!), Factor, and Swift. Slava volunteered to do a similar follow-along “BYOKBC” (build your own Knuth-Bendix completion) tutorial the next day. First day of the conference! I was walking into the hotel in the morning and I had made it about three feet onto the property when Alexa VanHattum, who was going the opposite direction, convinced me to instead get coffee elsewhere. We had a nice catch up and I got to hear about what teaching is like these days. Lunch was fun. I got to do another round of “ambush person whose research I admire” and plopped down with Ben and Christian Wimmer. I’d spent a lot of time struggling with Christian’s papers on linear scan, then convinced him to chat about register allocation with us on a video call a couple of months ago. We continued some of that at lunch but then I (kind of accidentally kind of on purpose) got Ben started talking about Sea of Nodes and how it is and is not different between Java and (for example stand-in for dynamic languages) JavaScript. Apparently he is thinking about a similar thing that he is calling Sea of Variables. We talked about inlining challenges and how to infuse profiles with call context, which can be a challenge. I feel more inspired to get type-based alias analysis working in ZJIT. I tracked down Christian later in the courtyard and got to hear about what he’s working on these days. I know very little about ML compilers and ML hardware and things like that so hearing about the challenges was neat. Yannis Smaragdakis joined our little standing table chat and we got to learn about Datalog. Because I had previously written about linear scan register allocation and about liveness analysis with Datalog, I goaded him into pairing with me on writing a full linear scan implementation in Datalog. This ended up taking the rest of the evening and several beers and then a lot of the next day! And after the first bit of code I did not manage to contribute very much at all. I met Hannah Gommerstadt and we got to chatting about bikes and formal methods (separately). Slava walked by and I got to introduce them. Then Jacob too. I continued pairing with Yannis but remained really lost. The only thing I think I contributed was some familiarity with the core algorithm, which he had only really seen in passing before. Eventually he got it fully working, but it needed some deep trickery. More on this soon in its own post. I saw a talk about versioned e-graphs and that got me wondering if their implementation can be used as a persistent e-graph or even just persistent union-find. Sometimes you want to do backtracking, or have undo-redo in your compiler. Then I went to a talk about streaming byte-pair encoding (BPE). BPE is hard to do streaming because it definitionally requires looking over the whole input string. They did some neat trickery to find boundaries in the string that demarcate regions that don’t interfere with one another and thereby tokenize on-the-fly. I didn’t understand it fully but I asked my only question of the conference, which was if this could also be used to implement BPE in parallel. Seems the answer is “maybe” so I should probably reach out and ask further. Slava started showing me and Jacob and Aaron how to implement Knuth-Bendix completion for strings. I had a lot of tiny little bugs which slowed progress. Such is life. The banquet and awards ceremony started so we called it a night and went off to eat dinner. They had good lentils. I ran into Thalia Archibald and John Regehr and we talked about (really, they talked about and I tried to learn something) what it might mean to either port Alive2 to another compiler than LLVM, or build “Alive3”, or “Mini-Alive” for some other IR. John suggested fuzzing the hell out of the thing first, then doing something more formal later… especially if it’s a dynamic language IR where a lot of the opcodes end up being “function call that can do anything”. I had a nice chat with Steven Holtzen and Zach Tatlock about research and grad student life. I got some good advice. I meant to talk to Zach about this thing we keep occasionally chatting about that I call “the big e-graph in the sky”. I talked a bit about it to Max Willsey and he had some good probing questions about what would be slow, challenging, or somehow undefined given my problem statement. I continued struggling to implement Knuth-Bendix with significant assistance from Slava and I think eventually got something working. I had a really nasty bug due to string slicing semantics 2 in Ruby. Aaron went off to learn about deep immutability in Python and then got to chatting with the authors of the paper. We got to compare notes about language and language implementation challenges. It’s been a long time since I was in Python-land. Aaron and Slava and I got enchiladas for dinner. I had no reading material for the flight so I went downtown, intending to buy one book, but got too many books. They barely fit into my bag for the flight home. I started reading Anathem for the second time. It holds up. It’s a damn good book. Actually, it might be my fifth. I just remembered that I attended PLMW in 2020 and also watched a few online talks at wild hours from my living room.  ↩ The semantics in Ruby are probably globally reasonable but did not fit the thing I was trying to do: if we have two strings and , we want to find the index at which they start to overlap, . Then we want to grab the bit of that is to the left of . I had initially written that as . However, if and overlap at the start of , is 0. This generates the range , which means we’ll slice until the end of . Not what we want. Instead, the fix in the commit shows how I had to add a special slice function called that handles the 0 case.  ↩ Actually, it might be my fifth. I just remembered that I attended PLMW in 2020 and also watched a few online talks at wild hours from my living room.  ↩ The semantics in Ruby are probably globally reasonable but did not fit the thing I was trying to do: if we have two strings and , we want to find the index at which they start to overlap, . Then we want to grab the bit of that is to the left of . I had initially written that as . However, if and overlap at the start of , is 0. This generates the range , which means we’ll slice until the end of . Not what we want. Instead, the fix in the commit shows how I had to add a special slice function called that handles the 0 case.  ↩

0 views
Brain Baking 1 weeks ago

Favourites of June 2026

The beginning of this month marks the official end of my own company. After just two years of establishing and owning Brain Baking BV , the notary ended it. There have been no professional activities related to the company since I switched back to education in December so for me it made little sense to keep that door open only for the monthly administrative costs to pile up. I hope to build a bit more stability this time around, both on personal and professional level. My statute as lecturer has been extended for a year: so far, so good! Previous month: May 2026 . A few very short ones and one quite big one that I ended up enjoying very much. DreadXP, the developers behind Dread Delusion , also recorded dev diaries on YouTube: Related topics: / metapost / By Wouter Groeneveld on 3 July 2026.  Reply via email . The Aching —a Sierra On-Line-like adventure game weighing less than that runs on any 8086 machine. It also happens to be good, even though it feels more like an introduction of this horrified world. Serious Sam: The First Encounter —I started replaying this two years ago and finally pushed forward a bit more. After endless complete freezes of my Win98 machine I gave up. AAAAAHHHHHH boom . I remember liking this a lot more: it’s…. bland? Dread Delusion —A weird looking game that I was drawn to the first time I laid eyes on screenshots a few years ago. I remembered it and felt the time was right to crack this one open. It’s one of the best games I’ve played in the last years. I recorded a playthrough log to convince you to drop everything and go play it as well! Lucy Dreaming —A lovely classic nineties adventure game that’s perhaps playing it too safe to try to be an homage to Monkey et al. ? I still enjoyed myself but the abrupt ending was a bit of a letdown. Speaking of The Aching , the developer explains their philosophy behind the Gorgon Engine . Interestingly, Gorgon is designed to be small and able to run on older original hardware, while new adventure games that look and feel old like The Telwynium are made with PowerQuest for Unity and require hundreds of megabytes. Nobody really cares, but I do. This ACM paper on a conceptual model for ownership types in Rust sheds new light on how the borrow checker works from an educational point of view. More Rust-y stuff—even though I have yet to touch the language—Michael Neumann investigated how long it takes to compile Rust from source compared to other languages. Hint: looooooonnnnggg. As in lonngggggggggg. James also printed his blog in book form years before I did! He selected all coffee-related articles to create a lovely personal caffeinated hardcover. Games That I Missed documents progress on their pinball machine projects . That old electronic stuff inside the machines is mesmerising. Phil Gyford laboriously kept track of how much money he spent each year on music for the past 30 years (via ) In a timely manner, Miss Booleana wrote about Claire Dederer’s Monsters: What Do We Do With Great Art By Bad People? . I asked myself the same question recently and added the book to my toread list. Andrew Webster publishes a Great Truth on The Verge: The Nintendo DS is still the best gaming handheld for travel . Yup. Another paper that confirms LLM-driven gender bias in citations in academic work . James Pennebaker confirms what I’ve been thinking and feeling: expressive writing can influence thoughts, feelings, and behaviours . The link is a past event but a good starting point to find publications by Pennebaker. Chris Kirk-Nielsen begs us to start playing indie games . Stop that Assassin’s Creed nonsense: scroll up and watch the Dread Delusion dev diary instead! Nic tringali sometimes feels the creative drudgery . A surprise ending is in it for you if you decide to read it. Jeff Gerstmann finally decided to apply Rigorous Science (TM) to compile an exhaustive (!!) list of the best NES games ever released in USA . Number one is NOT Mario nor Zelda! I particularly enjoyed Erik Hane’s piece in Typebar Magazine on fandom strain and the IP illness killing Magic: the Gathering . The magazine really is “An interesting thing to read on the internet”, as their footer claims. In a post called Cultures of making and relating , Konrad Hinsen brings the recent Cultures of Programming book our attention. It’s been an open browser tab ever since. Memray looks like an interesting memory profiler for Python, if I ever would need one. GentleOS is a friendly hobby OS for 32-bit PCs. The Corporate EU Observatory revealed that Big Tech invested almost 50% more in lobbying throwaway money ( !) compared to 2020. Diablo II has a new class: the Warlock . I really wish it was playable without the remaster though. Warp Point is a curated list of indie video game websites and Jefklak’s Codex is in it.

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
Ahead of AI 2 weeks ago

Using Local Coding Agents

Many people reached out to me in the past asking about my local agent stack as well as how I set up my local agent stack. So, I thought it might be useful to put together a little tutorial on how to set up a local (coding) agent using open-source tools and open-weight LLMs. Figure 1: Overview of the local stack, that is, a coding agent harness that uses a local model hosted through an inference engine / runtime server. This article is a tutorial on setting up a production-ready coding agent with a fully local stack. We will use a locally served LLM together with a local coding harness that can read files, make edits, run commands, and verify changes as shown in the figure above. Here, we can think of the LLM as the engine that provides the reasoning and code generation. And the surrounding harness provides the operating environment that allows the LLM to do meaningful coding work in our local projects. Why local? For many coding workflows, a local setup is an interesting alternative to proprietary services such as GPT in Codex or Opus in Claude Code. The local setup is transparent, inspectable, and free to run apart from hardware and electricity costs. It also stays fully under your control, and you can modify the coding harness in any way you like. Plus, it’s a lot of fun! By the way, in case you want a bit more background information on coding agent harnesses, I covered the core components of coding agents (and building a coding agent from scratch for learning purposes) here: I have to admit that I still primarily alternate between Codex and Claude Code as my daily drivers, for now (and just to keep up with the new tooling and functions that are constantly being added). Also, the plan limits (especially for Codex) are still so generous that I haven’t had to worry about costs so far. However, I’ve been using local solutions for a while, too, to test things and because it somehow gives me joy to have and use a fully local setup (versus proprietary services). Either way, local solutions become more and more attractive each day. One aspect is the costs. If you have the hardware, they are practically free to run. And then there’s, of course, the privacy angle. For example, for organizing and processing my receipts, I’d be more comfortable with a local model ingesting them rather than sending the data over to OpenAI or Anthropic. (Then, if we keep in mind that Anthropic was recently throttling their flagship model’s performance for LLM research , proprietary services may become more restrictive over time, and it’s maybe a good idea to be comfortable with open-weight alternatives as a backup.) And there are many, many additional reasons and use cases like that. Your motivations for using local LLMs and coding harnesses may include: Predictable, fixed costs if you reach your subscription plan limits, and immunity to API price changes. Reproducibility; sometimes it’s nice if a model is upgraded (e.g., GPT 5.4 -> GPT 5.5 -> GPT 5.6) and it solves all your queries more reliably. However, this can also break existing workflows. Offline use in the classic airplane flight scenario with slow or no internet, or when going on a coding/writing retreat in the cabin in the woods w/o a Starlink subscription. And there are probably several others. So, in this article, we will set up and use popular harnesses like Codex and Claude Code with open-weight models and investigate whether using a model-specific harness (like Qwen-Code for Qwen3.6) brings any additional benefits. (Of course, there are many more harnesses like OpenCode, Cline, Pi, and Noumena Code, but I thought that most people already have muscle memory with either Codex or Claude Code, which makes switching to open-weight models a bit smoother). Most coding agent harnesses follow similar principles and have more or less the same features and functionality. However, the implementation details may differ, and certain LLMs have usually been primarily optimized for a specific harness. Of course, many open-weight LLMs like GLM 5.2, for example, would run Claude Code, etc. However, if an LLM developer also develops a coding harness, it is somewhat safe to assume that their model is optimized for their own harness first (while also supporting others). Here, I am primarily going to use Qwen3.6 with the Qwen-Coder coding client. However, I will also go over other options for using a local LLM with other agent harnesses, for example, Claude Code, Codex, and the increasingly popular Cline, but more on that later. The reason why I am primarily using Qwen-Code when working with Qwen models is that: it is open-source, like Codex ( https://github.com/openai/codex ) but unlike Claude Code; Qwen models have been specifically optimized for the Qwen-Code harness (more information below); I can run both Codex (with the latest GPT model) and Qwen-Code with a local Qwen model side by side on the same machine without having to switch manually back and forth between models. Regarding the second point in the list above, that Qwen models work better in Qwen-Code, Nvidia’s Polar: Agentic RL on Any Harness at Scale paper (May 2026) has a benchmark showing that the Qwen3.5-4B base model has the best coding performance in said Qwen-Code harness (both before and after their Polar-RL training), which I included below. Figure 2: Qwen model performance in different coding harnesses via Polar: Agentic RL on Any Harness at Scale ( https://arxiv.org/abs/2605.24220 ) The benchmark in the table above is for an older Qwen3.5 model, and I am assuming that the latest Qwen3.6 models are even further optimized to do well in Qwen-Code specifically. However, Pi ( https://github.com/earendil-works/pi ) also seems to be a very interesting candidate that I need to play around with in the future. By the way, Qwen3.6 35B-A3B is about 22 GB to download, requires roughly 30-40 GB of RAM, and runs pretty swiftly on both a Mac Mini with M4 and a DGX Spark. Based on the recent benchmarks shared by Cohere earlier in June, it is currently the best local model in its size class. Figure 3: Cohere benchmark from North Mini Code report published in June ( https://huggingface.co/blog/CohereLabs/introducing-north-mini-code ) As seen above, Qwen3.6 35B-A3B dominates all but one benchmark in this size class. However, that being said, Qwen Code is a general harness and also supports other types of models. For instance, we could also connect North Mini Code or Gemma 4 in Qwen Code. Figure 4: Yes, Qwen3.6 35B-A3B is a really good model! (Via x.com/pupposandro/status/2064707907489272147/) Architecture-wise, the Qwen3.6 35B-A3B model has hybrid attention similar to Qwen3-Coder and Qwen3.5. I wrote more about it in Beyond Standard LLMs . Figure 5: Qwen3.6 architecture and fact sheet from my LLM gallery . Alternatively, if you don’t want to use Qwen3.6, Cohere’s North Mini Code is probably the most interesting, capable alternative at this size class right now. I will go over this model in the next local LLM setup section as well. Figure 6: North Mini Code architecture and fact sheet from my LLM gallery . No matter what agent harness we use (Qwen-Code, Codex, or Claude Code), we have to set up a local LLM, such as Qwen3.6 35B-A3B, first. There are several options like Ollama, LM Studio, vLLM, SGLang, MLX, etc to serve models locally. You know from my Build A Large Language Model (From Scratch) and Build A Reasoning Model (From Scratch) projects that I like to code these myself. Implementing a model from scratch has the benefits that we understand the whole stack, plus we can modify and further train and fine-tune it. However, here, we just look for a model serving framework that has been super optimized for inference speed and resource needs since we don’t plan to do any training or fine-tuning at this point. (We could, as an extra step, convert and import our own from-scratch fine-tuned model into these efficient serving stacks, but this is out of the scope for this article.) For this tutorial, we will use Ollama as our efficient model serving engine because it’s relatively easy to install and use from the command line across different operating systems (although LM Studio also added a non-GUI client, but I am less familiar with it). By the way, I am not affiliated with any of the tools mentioned in this article, but one nice thing about Ollama is that they also optionally support open-weight models hosted in the cloud, including the currently strongest open-weight model, GLM 5.2, which is too large to run locally on consumer hardware. (The cloud models are not free, of course, but have similar subscription plans as ChatGPT and Claude; it’s still nice though that this option exists to conveniently test the latest state-of-the-art open-weight models “locally.”) Anyways, setting up Ollama is pretty straightforward, and you can find the official macOS/Linux/Windows download instructions on their download page. After installing, I recommend downloading a model for a quick test run. For instance, on macOS, we can use the ollama app to download models directly via the GUI: Figure 7: Using the Ollama app to find and download models Otherwise, this can be done on the command line as well via By the way, the above-mentioned qwen3.6:35b-mlx is a model using Apple’s Metal performance shaders, i.e., optimized for Macs with Apple silicon chips. I highly recommend using *-mlx versions of models working on Macs (if available). Figure 8: Prefer the MLX version when using a Mac (with an Apple Silicon chip). On a Linux machine, use the non-MLX version: Then, to make sure that it works, you can either use the GUI again or launch Ollama from the command line. Figure 9: Running Ollama in the terminal. You can exit this session via the command. As mentioned before, the currently best alternative to this Qwen3.6 35B-A3B model is North Mini Code 1.0 of similar size. Figure 10: North Mini Code 1.0 as an alternative to Qwen3.6 35B A3B. Before deciding on whether to use an LLM as a local coding agent, it’s usually not a bad idea to run a quick speed and quality assessment. Here, for the speed assessment, I would look for tokens/sec performance. Additionally, I’d also make sure this stays stable for (very) long contexts, which is what we are usually dealing with during agentic coding workflows (as opposed to simpler chatbots). Of course, we also don’t want the memory cost to explode either. You could run my ollama_speed_memory_bench.py script to do a quick check. In a nutshell, it sends different prompts (ranging from 1k to 50k words) to an Ollama model and asks it to generate up to 8k tokens by default. It reports simple statistics like prefill speed from Ollama’s prompt evaluation metrics, generation speed from output-token timing, and memory use from the Ollama process plus NVIDIA GPU memory when available. For example, to evaluate the on macOS, if you downloaded or cloned the scripts from https://github.com/rasbt/local-coding-agent-evals , we can run the following, which takes about 5 minutes: On Linux, we can run: Note that this assumes that you already downloaded the respective model as explained in the previous section. Also, depending on your system, if you have less than 30 GB RAM, you may have to use a smaller model like gemma4:e2b, which uses up to about 8 GB RAM on long contexts. Of course, there are also many smaller models, but in my experience, they make pretty bad local coding agents.) Note that for models, the RSS RAM report is not super accurate on macOS (especially for mlx model variants that utilize the Metal backend), and I suggest keeping an eye on the activity monitor’s RAM usage for Ollama during the run as well. In this case, the RAM usage fluctuated between 20 - 29 GB. Anyways, the bottom line is that for 50k contexts, the Qwen3.6 and North Mini Code models use up to 30 GB RAM and generate output with about 40 tok/sec on a recent Mac Mini and 30 tok/sec on a DGX. Below is a visual summary of the different runs. Figure 11: Quick speed comparison of the different models on different systems. Note that the macOS RAM consumption is not super accurate there. Also, note that the Qwen 35B-A3B model is faster on Mac than on the DGX Spark (which is the other way around for the Gemma 4 E2B model) thanks to the optimized MLX version. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals Another interesting question is how Qwen 35B-A3B compares to the similarly-sized Cohere North Mini model? If we take similarly quantized models into account (above, I was using the Qwen3.6 default), they are pretty similar, although North Mini is perhaps slightly ahead overall, as shown below. Figure 12: Q4-quantized Qwen3.6 35B vs North Mini Code. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals Anyway, the bottom line is that, in my opinion, anything faster than 20-30 tok/sec is pretty reasonable for local agent work. This is about the same speed as GPT 5.5 with “high” reasoning . In this case, both models clear the bar easily. By the way, personally, I run my agents almost exclusively on my DGX Spark because I don’t want my Mac Mini to get too hot and I want to have the RAM available for other tasks. Of course, there are always ways to optimize this more with different frameworks (other than Ollama), quantizations, MTP, and so on. However, Ollama is a good plug & play allrounder with minimal setup time that connects easily to various coding agent frameworks and where it’s super simple to swap and try out different models. After checking that the model is fast enough for convenient local work, I recommend doing a quick modeling performance assessment. Sure, there are many standardized benchmarks out there we could take a look at and even run ourselves. Usually, you can find the numbers for relevant benchmarks in the model’s technical report or model hub page. Usually, I also find it useful to look at a relative comparison with other models on https://artificialanalysis.ai/models/ . Figure 13: Benchmark from https://artificialanalysis.ai/models/ . Average performance (top), coding performance (center), agentic performance (bottom). Based on the figure above, we can see that Qwen3 35B-A3B is much more capable than the Gemma 4 E4B and E2B models, for example. Note that the Artificial Intelligence Index numbers keep changing over time as they swap benchmarks and update the weighting, so there are no “absolute” numbers we could use as a reference point for deciding which model is “good enough”. Rather, I would compare a new, interesting model to a model you used before as an anchor or reference point. Beyond standard benchmarks, I would also curate a personal set of tasks that are relevant to you to do a quick check whether this model is even suitable for any type of work that you might want it to perform. Below are the outputs of a reasoning- and code-related set of questions that also test the tool calling capabilities of the models. Here, the model returns the tool call but doesn’t execute the code itself. For instance, we can say that gets the conceptual debugging and security-review tasks right, but still struggles with agentic judgment around “what file/action first” tasks. is usable but not fully reliable for autonomous tool use. But a harness that constrains actions, adds retries, and maybe gives stronger project context could make it pretty usable. On the other hand, failing is a strong signal that it is less suitable for this kind of tool-use reasoning, even if it is fast. Note that the failures are not just formatting issues. It looks like it chooses the wrong tool, asks for clarification when enough context is present, etc. I would probably not use it as a coding-agent model beyond very narrow or heavily constrained tasks. Now, after this lengthy preamble setting up a local LLM, let’s get back to the main topic, the coding agent harness. As mentioned at the beginning of this article, we will use the qwen-code ( https://github.com/QwenLM/qwen-code ) harness, as Qwen models have been optimized for it. Figure 14: Next, we are trying to connect the locally served model to the coding agent harness. If you are familiar with Claude Code, it’s basically the same thing but fully open-source. However, I will also go over how to connect the local Qwen3.6 model to Codex and Claude Code in the next sections. Note that coding harnesses are much more capable than LLMs by themselves. This is where I recommend being more careful about what you are running and where. For instance, when trying new (coding) agents, I like to Do an audit of the (open-source) agent code base first. Run it on separate hardware (e.g., my DGX Spark) or a separate user account and/or virtual environment on my machine at the very least. Regarding the audit, I recommend looking for data sharing/egress and the default blast radius when it comes to file permissions, as well as some baseline robustness to prompt injection. The figure below attempts to summarize the main points. Figure 15: Practical audit checklist before running an installed coding agent harness. Similar concerns apply to the local model serving engine (e.g., Ollama) as well. However, coding agents require even more attention as they can directly read data from your machine and manipulate files. To do a basic audit, I recommend the following: Clone the repo: Ask a trusted agent you used before (like GPT 5.5 in Codex or Opus 4.8 in Claude Code) to review it with a focused prompt. Something like the following: You are auditing ./qwen-code before I install or run the agent on my machine. Focus only on practical local-machine risk from the installed agent and the code paths that create it: install scripts and package lifecycle hooks shell command execution by the agent file read/write boundaries at runtime secret handling and environment-variable inheritance how repo files, project instructions, and tool output can influence the agent MCP, plugin, extension, or tool integrations network calls and telemetry update mechanisms after installation terminal escape/output handling data egress and data residency Ignoring internet downloads that are strictly required for installation, check whether the installed agent can send prompts, files, telemetry, logs, identifiers, or metadata to remote servers when I use a local model through Ollama. Ignore cloud-model configurations. Do not infer risk from the project owner alone. Identify concrete endpoints, SDKs, default providers, environment variables, config defaults, and docs that control network behavior, including any endpoints operated in foreign countries or by third-party companies. Do not do broad style review. Do not refactor. Produce: high-risk findings with file/line references medium-risk concerns network/data-egress findings, including any foreign, third-party, or China-linked endpoints or defaults commands I should avoid running until reviewed settings or environment variables that reduce local-machine risk a short recommendation: safe to test in sandbox, safe to use, or do not run For each item, say whether it is expected behavior for a coding agent or inherently riskier than Codex or Claude Code. Below is a summary of the main findings (because the full report may be a bit boring and too long for this article): Local execution Qwen Code can run shell commands on our machine through its shell tool but there are strict approval controls unless permissive modes such as are enabled. This is expected for a coding agent, and it’s actually what makes it useful in practice. But of course it becomes risky if run unsandboxed or with a full environment containing secrets. Data egress Even with local Ollama, Qwen Code can send usage telemetry and metadata to Alibaba/Aliyun endpoints unless usage statistics and telemetry are disabled (more on that below). This is riskier than a local-only setup because model prompts may stay local, but session IDs, tool metadata, model info, and local base URL metadata can still leave the machine. But again, this is also common among all kinds of tools (yes, Codex and Claude do that as well). File and secret boundaries Workspace files are readable by default, while writes generally require approval and include some overwrite protections. This is good and standard agent practice. Prompt injection surfaces Repo instructions, tool output, MCP tools, extensions, and project config can influence the agent’s behavior. Prompt injection attacks can be reduced via the approval gates mentioned above. This is normal for coding agents, but untrusted repos should be treated as hostile by default because they can steer the agent toward reading files, running commands, or sending data through approved tools. Regarding the main privacy concerns in point 2, most of it is fixable via a custom with the following contents: The setting is a tradeoff. Security fixes will not be installed automatically, but I prefer having explicit control over when updates happen instead of letting the tool pull and apply new code in the background. By the way, cline ( https://github.com/Cline/Cline ), Codex ( https://github.com/openai/codex ), and Claude Code have similar telemetry data sharing defaults that would need to be disabled explicitly. (Note that Claude Code doesn’t have an official open-source version of their codebase, which makes trusting it even trickier, and it does seem to send data to both Anthropic and Datadog.) Either way, overall, it seems Qwen-Code follows standard practices, and as of this writing, there is no particular concern that is non-standard for coding agents. If we accept the reported findings and risks (personally, I didn’t see any red flags), we can now proceed with the installation and hook up our local Qwen3.6-35B-A3B model to Qwen Code (and Codex and Claude Code in the next sections). As mentioned before, I preferably experiment with and run coding agents, which can read and edit local files, on a separate machine (in my case a DGX Spark, but it could also be a separate Mac or Linux workstation). Alternatively, I would run it in a VM or set up a separate macOS or Linux user account as a practical middle ground. (I heard from some friends that they also rent servers for that, like Linode or Heroku, for tinkering purposes. However, instead of the monthly hosting costs for a somewhat capable machine, I would probably rather get a relatively cheap $200-500 hardware box, or even an old retired laptop, and run a local harness and then use a stronger open-weight model hosted in the cloud via Ollama cloud models, OpenRouter, etc if you are looking for alternatives to GPT or Claude.) Anyways, let’s install Qwen-Code. The listed options include, e.g., However, running the commands above assumes that the published artifacts match the code we just reviewed in the GitHub repo. If we are extra careful/paranoid, we can also build it ourselves from the GitHub repo. Be warned, this is more manual/messier though (I recommend executing them one at a time instead of copy & pasting the whole block into the terminal): After completing the installation, we can now launch the Qwen-Code client via the qwen command from the terminal to complete the setup and connect to the locally served LLM. For this, after running the qwen command, we select “Custom Provider”, as shown below. Figure 16: Choose “Custom Provider,” which lets us connect the Ollama LLM. Ollama uses the OpenAI API standard. So, next, we follow the on-screen setup guide and choose the “OpenAI-compatible” option. Figure 17: Since Ollama follows the OpenAI API standard, we choose “OpenAI-compatible” here. Next, we need to provide the API endpoint of the running Ollama application that serves our local LLM. Usually that’s the local address by default. We enter (including the /v1) since that’s the OpenAI-compatible base URL. Figure 18: Configure Qwen Code to use Ollama’s local OpenAI-compatible endpoint, . Next, we enter as our custom provider. Figure 19: Enter as the API key placeholder for the local custom provider. Next, we can select the available models. These are the ones that we downloaded via . You can enter only a single model or multiple ones separated by commas. You can double-check the list of downloaded models via . By the way, you can always add more models easily later (I’ll explain after completing the setup). Figure 20: Select the local Ollama models that Qwen Code should make available through the custom provider. We are almost done! In step 5/6, we of course select “Enable thinking” mode, which will result in higher token usage but the better resulting problem-solving capabilities are worth it. Figure 21: Enable thinking mode for the local model provider. And that’s basically it. Step 6 is basically a review step that we can confirm by pressing “Enter”. Congratulations, you should now have a working fully-local LLM workflow set up. The usage is pretty much similar to Claude Code, where you can use / commands for various functionality. E.g., you can switch models via the command, as shown below. Figure 22: Use to switch models. By the way, as I mentioned before, it’s relatively easy to add new models from ollama. Once you pull a new model via , you can add it as a new entry in . Here, just copy & paste an existing entry into the file and change the “id” and “name” to that of the Ollama model name. Figure 23: We can add new ollama models by editing the config file. Here, is the name of the ollama model name, e.g., . By the way, to update the qwen-code tool once in a while, if we used the git clone & local build route, we can pull a recent GitHub snapshot and update it as follows: Now that we have a fully working, local coding agent, the question is: how well does it perform, and is it actually good enough for my tasks? Of course, there are benchmarks for this, but in my opinion, nothing beats trying it for yourself on some of your workflow. In other words, this basically means using it for a day or two to decide whether it meets your bar. I also recommend compiling a small set of tasks that reflect your common coding agent usage. And if you come upon a particularly challenging one when working on a given project, it may not be a bad idea to add it to this set to evaluate future models. As an example of what I mean, I shared a relatively small, simple, and general set of tasks we can use to test the agents here on GitHub: https://github.com/rasbt/local-coding-agent-evals/tree/main/agent-problem-pack . This is basically an extension of the tasks from the Local LLM Setup section. The details on how to run these are in the GitHub README: https://github.com/rasbt/local-coding-agent-evals/tree/main/agent-problem-pack#quick-start-running-benchmarks-manually . Below is the outcome for the different LLMs tested in Qwen-Code. Figure 24: Small local agent capability benchmark using Qwen-Code. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals As we can see, both the Qwen3.6 and North Mini Code 35B-A3B models solve 4 out of 5 of these problems. Gemma 4 E2B fails a lot. Out of curiosity, I also added the a bit older Nemotron 3 Nano model. It has a similar size and compute performance as the aforementioned Qwen and North models, and it performs similarly well. Figure 25: Nemotron 3 Nano architecture overview from my LLM Gallery After setting up the local coding agent (and the article exceeding 5000 words), this would probably be a reasonable place to stop. However, as a bonus, I also thought it might be interesting to add brief Codex and Claude Code notes for completeness. Unfortunately, as far as I know, the Codex UI does not support non-OpenAI models, but we can use the Codex CLI to run our Ollama models. If you haven’t installed the OpenAI Codex CLI yet, you can get and install it analogously to qwen-code from their open-source GitHub directory: https://github.com/openai/codex (Yes, the Codex CLI is open source!) I will spare you the lengthy listing of the commands and recommend checking the repo’s README instead for the official instructions. (Cloning the repo and running an audit similar to qwen-code is not a bad idea here, as well.) Then, once installed, there are multiple ways to enable local model use. In my opinion, the most convenient way is to set up a separate config (inside the existing folder) with some default options: Figure 26: Set up a separate Ollama profile for Codex for convenience. Then, we can still use to launch the regular “Codex with GPT 5.5” mode and use our Ollama model via . Figure 27: Launch Codex using a local Ollama model. When rerunning the test cases from the Agent Capability Assessment section, to my surprise, Qwen3.6 does actually perform better via Codex compared to its “native” Qwen-Code coding harness, as shown below. Figure 28: Small local agent capability benchmark in Codex. Even though this is just a small set of benchmarks, it suggests that using Codex as the universal coding agent harness may not be such a bad idea after all. Of course, there is also the popular Claude Code agent harness that we could use as a harness around our local LLMs. While very popular and capable, this is probably my least favorite option for local setups because the codebase is proprietary. That also means we cannot readily inspect and/or disable Anthropic’s data logging practices. To set it up, if you don’t have Claude Code already installed on your machine, I suggest checking the official docs for recommended installation commands: https://code.claude.com/docs/en/quickstart . Claude Code itself does not expose the same local-provider configuration path as Codex. However, Ollama provides an integration via : https://docs.ollama.com/integrations/claude-code I.e., we can execute to run the Claude Code harness with an Ollama model. By the way, this also works for codex via , but I personally prefer the route we discussed earlier, as it gives me a bit more insight and control about how things works etc. Figure 29: Claude Code with a local Qwen3.6 model through Ollama. However, as a user, it feels like Claude Code takes much longer to come up with a solution. It probably has a much higher token usage. So, below, I additionally looked at the token usage of all three harnesses. As we can see, Claude Code uses by far the most tokens on average, Codex the least. Figure 30: Average token usage of the three harnesses for different LLMs. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals When it comes to the little agent capability assessment benchmark, the Qwen and North Mini Code models also get 5/5, and even the small Gemma 4 model does ok! Interestingly, we can also see that the token usage is largely driven by the harness, not the LLM itself. I.e., among all three LLMs that are capable of solving (almost) all 5 tasks, they all use the same number of tokens (e.g., Qwen3.6 uses roughly the same number of tokens as North Mini Code and Nemotron 3 Nano when used inside Claude Code). Only Gemma 4 uses fewer tokens, but it also fails almost all tasks, likely because of insufficient tool-calling capabilities where the tasks interrupt early. For reference, below is again the summarized task-success rate. Figure 31: Summarized task success rates. Anyway, the takeaway here is that if more tokens help the model-harness combination to solve more (and more complex) problems, great! But if we have two harnesses that both have an equal task success rate, a harness that uses 50% fewer tokens (e.g., Codex over Claude Code), then this is a huge win, because it will make tasks run twice as fast. However, the big caveat here is that task correctness is a necessary criterion, but it doesn’t measure code quality and readability, which are hard to assess automatically. PS: I tried to analyze why Claude Code uses more tokens, and it seems that the difference mainly comes from input tokens rather than output tokens. In other words, Claude is not writing twice as much. The logs suggest that Claude is repeatedly feeding more context back into the model across turns, including previous messages, tool calls, command outputs, and file contents. For example, one Claude run used about 578k input tokens but only about 4.5k output tokens across 25 turns. So the likely explanation is that Claude’s harness accumulates or accounts for a larger prompt-side history during multi-step agent runs. So far, all the setups we discussed assumed that we were running the local LLM on the same machine as the coding harness. However, what if we developed some trust in the coding agent harness and want to use it on our main Mac while the model itself is hosted on a different machine, e.g., a DGX Spark? In my opinion, the best (or most convenient) setup is an SSH tunnel from the Mac to the DGX. First, I suggest quitting Ollama on the Mac or changing the to something else below. Assuming we quit the Ollama app on the Mac, check that the following returns an empty output to indicate that Ollama is not available: Then run the following command on that Mac in a terminal window on the Mac side: That command means that we open an SSH connection to as user , which you need to adjust to whatever your username and machine name are. Then, the command forwards the Mac’s local port to on the DGX because of . Note that this is the Ollama address. The terminal running will look like it is hanging. That is normal. Keep it open while you use Qwen Code, Codex, or Claude Code. Press to stop the tunnel. So after it is running, use this on your Mac to see if the Mac can indeed access the ollama models from the DGX: If that returns the DGX models, your Mac tools can use the DGX Ollama server as if it were local. Then, just use Qwen Code and Codex just like above. For Claude via , the key is that the Mac-side command must see the tunneled endpoint. If needed: We focused on Qwen Code, Codex, and Claude Code because they are the most direct fit for coding-agent workflows. OpenClaw and Hermes are also capable, but they are broader agent harnesses. They are better suited when you want one agent to coordinate across tools, apps, browsers, terminals, and longer-running workflows. For coding work, I recommend starting with Qwen Code, Codex, or Claude Code first (and there are also many other interesting coding harnesses like OpenCode, Cline, Pi, and Noumena Code). And I would treat OpenClaw and Hermes as interesting follow-up options for things beyond coding rather than the first baseline for this local coding-agent setup. This was a long article with lots of information and configuration. If there are a few main takeaways, I’d say that it’s not the mechanistic setup pipeline but rather the considerations when running coding agents locally. That is, the most important part is not getting one specific tool installed, but understanding the model-serving layer, the agent harness, the permission model, and how to evaluate whether the setup actually solves coding tasks reliably. Of course, GPT 5.5 and Opus 4.8 are currently better than smaller open-weight models that run on a Mac or DGX Spark. But the newer Mixture-of-Experts models in the 30-35B range (such as Qwen3.6, North Mini Code, and Nemotron 3 Nano) are all very, very capable and really sufficient for a lot of tasks. And yes, they run with the same token speed as GPT 5.5 through a Pro subscription, so it should not necessarily slow down your workflows. The main consideration when setting up local agents, besides the model itself, is also which harness we want to use. The common perception is that models are usually optimized more for a specific harness than others (e.g., Qwen3.6 may work better in Qwen Code than Claude Code, for example). Based on the small agent assessment, this may not necessarily be true, though (this is only a very small benchmark, so take it with a big grain of salt). So, if you are more comfortable with a different harness that you have a lot of muscle memory with, like Codex and Claude Code, maybe it’s not a bad idea to just stick the model into that one and give it a try! Anyways, I hope the article was useful, and it got you interested in doing some tinkering with open-weight models. They are becoming more capable by the day, and it’s for some inexplicable reason just fun to run models locally. If you want to try the benchmarks yourself, the code and small evaluation tasks used in this article are available here: https://github.com/rasbt/local-coding-agent-evals Also, my Build a Reasoning Model (From Scratch) book has now gone to print and started shipping. I wanted to post a picture, but it will be 3 more days until it arrives. Build a Reasoning Model (From Scratch) If you liked my previous Build a Large Language Model (From Scratch) book, this is essentially a sequel implementing inference-time scaling techniques and reinforcement learning algorithms from scratch. And if you want to support future long-form articles like this one, consider becoming a paid subscriber . It helps me keep writing these independent deep dives and sharing the accompanying code, figures, and experiments. Figure 1: Overview of the local stack, that is, a coding agent harness that uses a local model hosted through an inference engine / runtime server. This article is a tutorial on setting up a production-ready coding agent with a fully local stack. We will use a locally served LLM together with a local coding harness that can read files, make edits, run commands, and verify changes as shown in the figure above. Here, we can think of the LLM as the engine that provides the reasoning and code generation. And the surrounding harness provides the operating environment that allows the LLM to do meaningful coding work in our local projects. Why local? For many coding workflows, a local setup is an interesting alternative to proprietary services such as GPT in Codex or Opus in Claude Code. The local setup is transparent, inspectable, and free to run apart from hardware and electricity costs. It also stays fully under your control, and you can modify the coding harness in any way you like. Plus, it’s a lot of fun! By the way, in case you want a bit more background information on coding agent harnesses, I covered the core components of coding agents (and building a coding agent from scratch for learning purposes) here: 1. Intro I have to admit that I still primarily alternate between Codex and Claude Code as my daily drivers, for now (and just to keep up with the new tooling and functions that are constantly being added). Also, the plan limits (especially for Codex) are still so generous that I haven’t had to worry about costs so far. However, I’ve been using local solutions for a while, too, to test things and because it somehow gives me joy to have and use a fully local setup (versus proprietary services). Either way, local solutions become more and more attractive each day. One aspect is the costs. If you have the hardware, they are practically free to run. And then there’s, of course, the privacy angle. For example, for organizing and processing my receipts, I’d be more comfortable with a local model ingesting them rather than sending the data over to OpenAI or Anthropic. (Then, if we keep in mind that Anthropic was recently throttling their flagship model’s performance for LLM research , proprietary services may become more restrictive over time, and it’s maybe a good idea to be comfortable with open-weight alternatives as a backup.) And there are many, many additional reasons and use cases like that. Your motivations for using local LLMs and coding harnesses may include: Predictable, fixed costs if you reach your subscription plan limits, and immunity to API price changes. Reproducibility; sometimes it’s nice if a model is upgraded (e.g., GPT 5.4 -> GPT 5.5 -> GPT 5.6) and it solves all your queries more reliably. However, this can also break existing workflows. Offline use in the classic airplane flight scenario with slow or no internet, or when going on a coding/writing retreat in the cabin in the woods w/o a Starlink subscription. it is open-source, like Codex ( https://github.com/openai/codex ) but unlike Claude Code; Qwen models have been specifically optimized for the Qwen-Code harness (more information below); I can run both Codex (with the latest GPT model) and Qwen-Code with a local Qwen model side by side on the same machine without having to switch manually back and forth between models. Figure 3: Cohere benchmark from North Mini Code report published in June ( https://huggingface.co/blog/CohereLabs/introducing-north-mini-code ) As seen above, Qwen3.6 35B-A3B dominates all but one benchmark in this size class. However, that being said, Qwen Code is a general harness and also supports other types of models. For instance, we could also connect North Mini Code or Gemma 4 in Qwen Code. Figure 4: Yes, Qwen3.6 35B-A3B is a really good model! (Via x.com/pupposandro/status/2064707907489272147/) Architecture-wise, the Qwen3.6 35B-A3B model has hybrid attention similar to Qwen3-Coder and Qwen3.5. I wrote more about it in Beyond Standard LLMs . Figure 5: Qwen3.6 architecture and fact sheet from my LLM gallery . Alternatively, if you don’t want to use Qwen3.6, Cohere’s North Mini Code is probably the most interesting, capable alternative at this size class right now. I will go over this model in the next local LLM setup section as well. Figure 6: North Mini Code architecture and fact sheet from my LLM gallery . 3. Local LLM Setup No matter what agent harness we use (Qwen-Code, Codex, or Claude Code), we have to set up a local LLM, such as Qwen3.6 35B-A3B, first. There are several options like Ollama, LM Studio, vLLM, SGLang, MLX, etc to serve models locally. You know from my Build A Large Language Model (From Scratch) and Build A Reasoning Model (From Scratch) projects that I like to code these myself. Implementing a model from scratch has the benefits that we understand the whole stack, plus we can modify and further train and fine-tune it. However, here, we just look for a model serving framework that has been super optimized for inference speed and resource needs since we don’t plan to do any training or fine-tuning at this point. (We could, as an extra step, convert and import our own from-scratch fine-tuned model into these efficient serving stacks, but this is out of the scope for this article.) For this tutorial, we will use Ollama as our efficient model serving engine because it’s relatively easy to install and use from the command line across different operating systems (although LM Studio also added a non-GUI client, but I am less familiar with it). By the way, I am not affiliated with any of the tools mentioned in this article, but one nice thing about Ollama is that they also optionally support open-weight models hosted in the cloud, including the currently strongest open-weight model, GLM 5.2, which is too large to run locally on consumer hardware. (The cloud models are not free, of course, but have similar subscription plans as ChatGPT and Claude; it’s still nice though that this option exists to conveniently test the latest state-of-the-art open-weight models “locally.”) Anyways, setting up Ollama is pretty straightforward, and you can find the official macOS/Linux/Windows download instructions on their download page. After installing, I recommend downloading a model for a quick test run. For instance, on macOS, we can use the ollama app to download models directly via the GUI: Figure 7: Using the Ollama app to find and download models Otherwise, this can be done on the command line as well via By the way, the above-mentioned qwen3.6:35b-mlx is a model using Apple’s Metal performance shaders, i.e., optimized for Macs with Apple silicon chips. I highly recommend using *-mlx versions of models working on Macs (if available). Figure 8: Prefer the MLX version when using a Mac (with an Apple Silicon chip). On a Linux machine, use the non-MLX version: Then, to make sure that it works, you can either use the GUI again or launch Ollama from the command line. Figure 9: Running Ollama in the terminal. You can exit this session via the command. As mentioned before, the currently best alternative to this Qwen3.6 35B-A3B model is North Mini Code 1.0 of similar size. Figure 10: North Mini Code 1.0 as an alternative to Qwen3.6 35B A3B. 4. Simple Speed Performance Assessment Before deciding on whether to use an LLM as a local coding agent, it’s usually not a bad idea to run a quick speed and quality assessment. Here, for the speed assessment, I would look for tokens/sec performance. Additionally, I’d also make sure this stays stable for (very) long contexts, which is what we are usually dealing with during agentic coding workflows (as opposed to simpler chatbots). Of course, we also don’t want the memory cost to explode either. You could run my ollama_speed_memory_bench.py script to do a quick check. In a nutshell, it sends different prompts (ranging from 1k to 50k words) to an Ollama model and asks it to generate up to 8k tokens by default. It reports simple statistics like prefill speed from Ollama’s prompt evaluation metrics, generation speed from output-token timing, and memory use from the Ollama process plus NVIDIA GPU memory when available. For example, to evaluate the on macOS, if you downloaded or cloned the scripts from https://github.com/rasbt/local-coding-agent-evals , we can run the following, which takes about 5 minutes: On Linux, we can run: Note that this assumes that you already downloaded the respective model as explained in the previous section. Also, depending on your system, if you have less than 30 GB RAM, you may have to use a smaller model like gemma4:e2b, which uses up to about 8 GB RAM on long contexts. Of course, there are also many smaller models, but in my experience, they make pretty bad local coding agents.) Note that for models, the RSS RAM report is not super accurate on macOS (especially for mlx model variants that utilize the Metal backend), and I suggest keeping an eye on the activity monitor’s RAM usage for Ollama during the run as well. In this case, the RAM usage fluctuated between 20 - 29 GB. Anyways, the bottom line is that for 50k contexts, the Qwen3.6 and North Mini Code models use up to 30 GB RAM and generate output with about 40 tok/sec on a recent Mac Mini and 30 tok/sec on a DGX. Below is a visual summary of the different runs. Figure 11: Quick speed comparison of the different models on different systems. Note that the macOS RAM consumption is not super accurate there. Also, note that the Qwen 35B-A3B model is faster on Mac than on the DGX Spark (which is the other way around for the Gemma 4 E2B model) thanks to the optimized MLX version. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals Another interesting question is how Qwen 35B-A3B compares to the similarly-sized Cohere North Mini model? If we take similarly quantized models into account (above, I was using the Qwen3.6 default), they are pretty similar, although North Mini is perhaps slightly ahead overall, as shown below. Figure 12: Q4-quantized Qwen3.6 35B vs North Mini Code. Code to reproduce: https://github.com/rasbt/local-coding-agent-evals Anyway, the bottom line is that, in my opinion, anything faster than 20-30 tok/sec is pretty reasonable for local agent work. This is about the same speed as GPT 5.5 with “high” reasoning . In this case, both models clear the bar easily. By the way, personally, I run my agents almost exclusively on my DGX Spark because I don’t want my Mac Mini to get too hot and I want to have the RAM available for other tasks. Of course, there are always ways to optimize this more with different frameworks (other than Ollama), quantizations, MTP, and so on. However, Ollama is a good plug & play allrounder with minimal setup time that connects easily to various coding agent frameworks and where it’s super simple to swap and try out different models. 5. Simple Benchmark Performance Assessment After checking that the model is fast enough for convenient local work, I recommend doing a quick modeling performance assessment. Sure, there are many standardized benchmarks out there we could take a look at and even run ourselves. Usually, you can find the numbers for relevant benchmarks in the model’s technical report or model hub page. Usually, I also find it useful to look at a relative comparison with other models on https://artificialanalysis.ai/models/ . Figure 13: Benchmark from https://artificialanalysis.ai/models/ . Average performance (top), coding performance (center), agentic performance (bottom). Based on the figure above, we can see that Qwen3 35B-A3B is much more capable than the Gemma 4 E4B and E2B models, for example. Note that the Artificial Intelligence Index numbers keep changing over time as they swap benchmarks and update the weighting, so there are no “absolute” numbers we could use as a reference point for deciding which model is “good enough”. Rather, I would compare a new, interesting model to a model you used before as an anchor or reference point. Beyond standard benchmarks, I would also curate a personal set of tasks that are relevant to you to do a quick check whether this model is even suitable for any type of work that you might want it to perform. Below are the outputs of a reasoning- and code-related set of questions that also test the tool calling capabilities of the models. Here, the model returns the tool call but doesn’t execute the code itself. For instance, we can say that gets the conceptual debugging and security-review tasks right, but still struggles with agentic judgment around “what file/action first” tasks. is usable but not fully reliable for autonomous tool use. But a harness that constrains actions, adds retries, and maybe gives stronger project context could make it pretty usable. On the other hand, failing is a strong signal that it is less suitable for this kind of tool-use reasoning, even if it is fast. Note that the failures are not just formatting issues. It looks like it chooses the wrong tool, asks for clarification when enough context is present, etc. I would probably not use it as a coding-agent model beyond very narrow or heavily constrained tasks. 6. Agent Code Base Audit Now, after this lengthy preamble setting up a local LLM, let’s get back to the main topic, the coding agent harness. As mentioned at the beginning of this article, we will use the qwen-code ( https://github.com/QwenLM/qwen-code ) harness, as Qwen models have been optimized for it. Figure 14: Next, we are trying to connect the locally served model to the coding agent harness. If you are familiar with Claude Code, it’s basically the same thing but fully open-source. However, I will also go over how to connect the local Qwen3.6 model to Codex and Claude Code in the next sections. Note that coding harnesses are much more capable than LLMs by themselves. This is where I recommend being more careful about what you are running and where. For instance, when trying new (coding) agents, I like to Do an audit of the (open-source) agent code base first. Run it on separate hardware (e.g., my DGX Spark) or a separate user account and/or virtual environment on my machine at the very least. Figure 15: Practical audit checklist before running an installed coding agent harness. Similar concerns apply to the local model serving engine (e.g., Ollama) as well. However, coding agents require even more attention as they can directly read data from your machine and manipulate files. To do a basic audit, I recommend the following: Clone the repo: Ask a trusted agent you used before (like GPT 5.5 in Codex or Opus 4.8 in Claude Code) to review it with a focused prompt. Something like the following: install scripts and package lifecycle hooks shell command execution by the agent file read/write boundaries at runtime secret handling and environment-variable inheritance how repo files, project instructions, and tool output can influence the agent MCP, plugin, extension, or tool integrations network calls and telemetry update mechanisms after installation terminal escape/output handling data egress and data residency high-risk findings with file/line references medium-risk concerns network/data-egress findings, including any foreign, third-party, or China-linked endpoints or defaults commands I should avoid running until reviewed settings or environment variables that reduce local-machine risk a short recommendation: safe to test in sandbox, safe to use, or do not run Local execution Qwen Code can run shell commands on our machine through its shell tool but there are strict approval controls unless permissive modes such as are enabled. This is expected for a coding agent, and it’s actually what makes it useful in practice. But of course it becomes risky if run unsandboxed or with a full environment containing secrets. Data egress Even with local Ollama, Qwen Code can send usage telemetry and metadata to Alibaba/Aliyun endpoints unless usage statistics and telemetry are disabled (more on that below). This is riskier than a local-only setup because model prompts may stay local, but session IDs, tool metadata, model info, and local base URL metadata can still leave the machine. But again, this is also common among all kinds of tools (yes, Codex and Claude do that as well). File and secret boundaries Workspace files are readable by default, while writes generally require approval and include some overwrite protections. This is good and standard agent practice. Prompt injection surfaces Repo instructions, tool output, MCP tools, extensions, and project config can influence the agent’s behavior. Prompt injection attacks can be reduced via the approval gates mentioned above. This is normal for coding agents, but untrusted repos should be treated as hostile by default because they can steer the agent toward reading files, running commands, or sending data through approved tools.

0 views
Simon Willison 3 weeks ago

Porting the Moebius 0.2B image inpainting model to run in the browser with Claude Code

This morning on Hacker News I saw Moebius: 0.2B Lightweight Image Inpainting Framework with 10B-Level Performance , describing a small but effective inpainting model - a model where you can mark regions of an image to remove and the model imagines what should fill the space. The released model required PyTorch and NVIDIA CUDA , but since it described itself as 0.2B I decided to try and get it running using WebGPU in a browser. TL;DR: I got it working, and you can try the demo at simonw.github.io/moebius-web/ . Read on for the details. Here's a video demo of the finished tool: You can open any image in it (non-square images get letterboxed), highlight areas to remove, click the "Run inpaint" button and wait for the model to do its magic. My main project for today was landing a major feature in Datasette: a UI for creating and altering tables, as a follow-up to the insert and edit rows feature I released last week. I was working on that in Codex Desktop (here's the PR ) and often found myself spending 5-10 minutes spinning my fingers waiting for it to complete a mid-sized refactor or add the finishing touches to a change to the UI. (An amusing thing about coding agents is that the harder a problem is the more time you have to get distracted while you wait for them to finish crunching!) So I decided to spin up Claude Code in a terminal window and see how far I could get at porting Moebius to the web. My first step was to ask regular Claude about the feasibility of this project. In Claude.ai , which has the ability to clone repos from GitHub: (I hadn't spotted the link to the weights yet, that's tucked away in the "News" section.) I like telling models to "muse on X", it's the shortest way I've found of expressing that I want them to contemplate a problem for me without providing them with a concrete goal. Here's that chat transcript . I copied out the last answer and saved it as research.md for Claude Code to read later. Claude suggested using ONNX Runtime Web on the WebGPU backend - the layer below the Transformers.js library I had suggested. That was enough to convince me it was worth setting Claude Code loose and seeing how far it could get. I usually start projects like this by gathering as much information as the coding agent might need as possible. Since I didn't expect this project to actually work I did everything in my folder: I created a directory for the rest of the project and ran in that so Claude could start committing code notes: I fired up a instance in the folder, the level above all of the research materials I had prepared for it. I prompted: As it started to work I dropped in this follow-up (typos included): I often ask agents to keep notes like this - the end result is often interesting, both for myself and for the next agent session that touches the same project. Here's what that notes.md file looked like at the end of the project. I kicked it off and went back to my main project, checking in occasionally to see how Claude was doing. When it looked like it might have something that worked I prompted: Then I tried it out in Chrome and pasted some errors (and screenshots of errors) back into Claude Code. After a few rounds of this we had something that appeared to work! Time to put it on the internet so other people could use it. Claude Code knows how to use the CLI tool, so I created a model repo on Hugging Face , then created a token that could write to that repo and dropped it into a file so Claude could use it. It published the 1.24GB of converted ONNX weights to huggingface.co/simonw/Moebius-ONNX for me. I'd seen other demos load weights into the browser from Hugging Face before, so I knew it was possible. I decided to host my own frontend code on GitHub Pages, so I said: Telling it the final URL was important in case it needed to fix the URLs in the demos that it was building so they would work when deployed to production. After a few more rounds of iteration, in between working on my main project, we got to a working, deployed version! Except... each time I reloaded the page it seemed to download ~1.3GB of model weights. Browser caching seemed pretty important for this! I knew that Transformers.js projects could handle this properly, so I grabbed a copy of the Whisper Web demo, dropped it into and said: That project was entirely obfuscated, built JavaScript files so I figured using a subagent would avoid spending the rest of my top-level token context deciphering those files. Claude figured out that it was using - the CacheStorage API - and added that to our project . I've shared the full Claude Code transcript for this project (published using my claude-code-transcripts tool). This definitely counts as vibe coding: I didn't look at a single line of code from the project, restricting my input to testing, suggesting small feature improvements (like a progress bar for the large file downloads) and pointing the model in the direction of examples of how I wanted things to work. Since I didn't write any code the amount I learned about the underlying technologies - WebGPU, ONNX, and the Moebius model itself - was very limited. As is usually the case with this kind of project the most important things I learned concerned what was possible : I felt like I should probably try and learn a little more about my project. I fired up Claude.ai and prompted: Here's the transcript and the understanding.md Markdown file it created, which I've now added to the GitHub repo. I found the explanation of ONNX particularly enlightening: ONNX (Open Neural Network Exchange) is a portable, framework-neutral file format for neural networks. An file is essentially two things bundled together: Crucially, ONNX describes what to compute , abstractly, without saying how or on what hardware . The operator set is versioned by an opset number (this repo uses opset 18 ), which pins down exactly which operators exist and what their semantics are. It turns out PyTorch has built in mechanisms for exporting to ONNX, as seen here in export_onnx.py : Claude also included a handy glossary and an only-slightly-broken ASCII-art diagram showing how the model pipeline fits together. 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 . Claude Opus 4.8 is capable of converting a PyTorch model to ONNX, publishing the result to Hugging Face and then building out a web application and interface that can load and execute that model. Chrome, Firefox and Safari are all now capable of running this kind of model - I tried it in all three. The CacheStorage API works with ~1.3GB model files. ... which means we can have inpainting as a feature of a client-only web application! (If our users can tolerate the 1.3GB download.) A computation graph — a directed graph of nodes , where each node is an operator ( , , , , , , , …) wired together by named tensors flowing between them. This is the "recipe" for the forward pass. The weights — the learned parameter tensors (the convolution kernels, the embedding table, etc.), stored as initializers in that same graph.

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
Farid Zakaria 3 weeks ago

Nix needs relocatable binaries

This is my problem statement and proposal for a TacoSprint 2026 project 🏄. Nix, or store-based systems , are a class of package managers that use a well-defined prefix to store all packages. This can be for Nix or for Guix. This is simple. It makes rewriting paths to binaries or libraries easy. Derivations only need to the strings with the full store-path; becomes for instance. What if you wanted a different path, one not prefixed at the root ? This could be desirable if you don’t have Nix installed already or are missing necessary permissions – “rootless Nix”. Well, Nix already lets you specify a different store-path today but there is a catch! Let’s take a look at a simple example. We can build two different ways. The first command builds and installs at and the second at using and mount namespaces. Notice both have the same hash . This is important. By keeping the hash the same, we can leverage the precomputed derivations from binary substituters like https://cache.nixos.org . Ok, so what’s missing? If you are using tools like Bazel or Buck2 they likely already employ their own sandboxing via namespacing for builds. Integrating Nix into these ecosystems becomes incredibly impractical because we run into nested user namespace and mount restrictions. We can ask to use an alternate store prefix, without chroot and mount namespaces but it has a big gap. The hash is now 😭 It’s even more disastrous. Changing this simple string cascade-invalidates the entire dependency graph. You are now waiting 4 hours for GCC to compile just so you can print “Hello World” from a different folder. 🫠 This means we cannot leverage the public cache. This gap is called out by the Nix documentation today. Does it have to be that way? What if we could install Nix binaries anywhere , without using namespacing or . Can we have our cake and eat it too? 🍰 Nix needs relocatable binaries . The problem is that the store-prefix is part of the derivation itself so it affects the hash calculation. We don’t have to specify the full store-prefix everywhere. What if we used relative paths ? 🤔 Let’s look at one place the full paths are written today in the binary via . When this program runs, the dynamic linker looks at to find its shared dependencies. The loader in Linux however natively supports the variable which translates to “the directory containing the executable.” [ ref ] We could instead write the to be . If we did that then changing the store would cause no hashes to change. No recompilation. 🥳 Okay, so are we done? Well, like most things the devil is in the details. 😈 Before the dynamic linker can read the to find the necessary libraries, the Linux kernel has to load the dynamic linker itself. This path is stored in a different ELF header called (Program Interpreter). Unfortunately, the Linux Kernel does not support in this field as of today . We run into the exact same kernel limitation with the shebang line in scripts as well. When we execute a script, the kernel parses the (shebang) and expects an absolute path. Support for is also lacking as as of today . We cannot use relative paths reliably here unless they are relative to the current working directory, which breaks the moment you run the script from anywhere else. To achieve true relocatable binaries, we need to bypass these kernel limitations. historically would never make sense for in the Linux kernel because “Why would you want your dynamic linker to be found relative to the file!?”. Nix has changed that assessment. There are a few ways we could attack this: I believe augmenting support in the Linux kernel is the right approach. The beauty of Nix is we can even patch the kernel today in any NixOS machine for this support. As a final cherry on top, we can include additional metadata on every derivation whether it’s relocatable . 🍒 We could patch the Linux kernel so that is supported in and the shebang. We wrap every binary with a small static binary that computes its own location and then invokes the dynamic linker. We need to replace file locations to also leverage language-specific features for relative paths. For instance, in Python we can leverage to access files relative to itself similar to .

0 views
Giles's blog 4 weeks ago

Flax debugging: making a hash of things

I was debugging an issue with a JAX/Flax NNX training loop the other day, and found a neat little trick to help debug it. Specifically, I wanted to see if the issue was with my model, my loss function, my optimiser settings, or the "plumbing" of the training loop itself -- were gradients actually coming through and being applied to the parameters? I could print out the loss and the gradients, but printing out the parameters to see if they were changing was unhelpful -- any given update might only change a small number of parameters, or might change them such a small amount that I'd not notice -- especially given that the model had 77 million of them! Let's take a look. I am building an LLM from scratch in JAX and Flax NNX, and at this stage I'm trying to get the training loop right. As a simple test, I've just implemented the "shell" of the LLM -- the token embeddings on the input side, and the final linear layer for an output head, wired directly together. My plan was to train that so that given a sequence, instead of predicting next tokens for each position, it would "predict" the sequence itself -- that is, I might train it with the input ...and the target ...rather than the normal setup for an LLM, where you feed it ...and give it targets of So, in LLM terms, I'd be training a model to project from vocab space to a learned embedding space where each token had a distinct-enough embedding for the output head to be able to reliably project back to logits in vocab space. There's a bit of background here if that was all Greek to you . Here's the core part of the code I was working with, the function, which seems to be the traditional JAX name for the JITted part of your code that does the forward pass through the model, works out the gradients, and then applies them to update the model: I'd based it on the "Basic Usage" example that's currently right there on the front page of the Flax site. Seasoned Flax veterans will probably spot the issue right away, but it wasn't obvious to me -- so it was time to dig in. The problem was that loss was not dropping -- indeed, taken to two decimal places, it was stuck at 10.82. The digits to the right of that changed for each batch, but the first four did not. Now, this model was using the GPT-2 tokeniser, and 10.82 is exactly the loss that you'd expect if the model was essentially guessing randomly -- if you convert it to perplexity by calculating e 10.82 , you get about 50,011 -- which is very close to the GPT-2 vocab size of 50,257. Perplexity is, loosely, the number of tokens that the model was trying to choose between for a typical input -- so a perplexity equal to the vocab size is what you'd expect of a random model that is getting it right about one in 50,257 times. That said, getting that loss consistently was a solid validation of my loss function! It's vanishingly unlikely that it would have been getting that specific number so consistently if I'd made a mess of that. The tiny variations I was seeing in the third and subsequent decimal places would make sense, as they could easily be due to the variations in the contents of the different batches. So was it that the gradients were somehow zero, or NaNs, or something else that couldn't be usefully applied to the model by the optimiser? I printed them out in the function (removing the decorator, as otherwise the s would only get executed in the initial JIT pass through the function to compile it -- not when it had actual data 1 ). The result was values like this: Those looked plausible enough -- pretty small, but not so tiny that I'd expect them to have no effect at all with my learning rate of 0.0014. It was time to dig into the training loop's plumbing. The obvious suspect was the update step -- was that call to actually changing the parameters at all? Flax's NNX API is a bit odd compared to the normal JAX functional way of doing things . In vanilla JAX code you would expect to do something like this to apply gradients: That is, you get the new parameters by applying a transformation to the old ones. NNX, by contrast, is more PyTorch-flavoured. It updates the parameters in-place, using a function with a side effect of mutating one of its parameters: ...rather than something more functional like this imaginary API: I could easily imagine that I'd got something wrong that would break that in-place update, as it has the feel of something that would have to be quite delicately implemented on top of a functional system like JAX. But how could I see whether the parameters were changing, when there were 77 million of them and they would be being updated (based on gradients like -2.6879393e-06 and a learning rate of 1.4e-3) in the ninth decimal place or beyond? Printing the arrays out was a non-starter! After a little thought, I realised that the solution was to use hashes. Even tiny changes in the parameters' values would change their hashes drastically. So if the parameters were not being updated, as I suspected, I'd see constant hashes. If they were being updated, even by a minuscule amount, then the hashes would change. This GitHub discussion pointed me in the right direction: if I could get the parameters as pure JAX arrays, I could do this: ...where is just . That would produce a hash that was stable for the life of this run -- the same parameters would always have the same hash, and different ones would differ, just as we want. It could vary from run to run (Python uses different hash seeds in each new interpreter), but that wouldn't matter for this kind of debugging. I wasn't sure what the structure of my Flax model's parameters was, but printing them out in the training loop told me: So, guided by that, I added these lines to the training loop: Obviously copying the arrays around and converting them like that would slow things down, but for debugging purposes, it looked solid. I kicked off the training loop, and the problem was clear: ...and so on. The hashes were not changing, so the model's parameters were not being updated, even by a tiny amount. Gotcha! The problem turned out, as I had suspected, to be related to the in-place updates that NNX does. Like I said earlier, I'd based my training loop on the "Basic Usage" example on the Flax site -- but I'd messed up one important thing. I had this: ...and they had this: You can see a number of differences -- for example, they're baking the inputs and targets into the lambda they're using for the loss function through a lexical closure, and that means that they're only passing in the model to the version of it wrapped in . But none of that matters! The real difference is actually nicely highlighted with a comment, but I'd completely managed to miss it. Right at the start, where I had , they had this: It 100% makes sense that in order to support this kind of non-functional, in-place updating of the model's parameters, you have to have a modified version of the JIT decorator. And I was just using the standard, functional pure-JAX one. Fixing that fixed the problem: The hashes were changing! And even better, if you scroll to the right you'll see that loss was slowly dropping. After 10k or so iterations, I was seeing 0.000: I had my do-nothing "LLM" working. A satisfying debugging journey -- and while I don't think I'll make this specific mistake in the future, I think that the parameter-hashing trick is actually a really useful trick for the toolbox. If you're uncertain as to whether your parameters are being updated, just looking at them probably won't help. But looking at their hashes can help you find out whether anything is changing. And I think that the pattern that I used to zoom in on it is a useful one, too. I always track loss, so it's a good starting point (indeed, seeing that it wasn't falling was what told me that something was going wrong). But checking that it has a sane -- or ideally, as in this case, a meaningful -- value is a nice sanity check that we have a working loss function and a model that isn't doing something completely pathological. Moving on from there to checking that some kind of gradients are flowing through is a solid next move (and might become increasingly interesting with deeper models where they can vanish or explode ). Then finally we can check the parameters -- in particular, are they changing? 2 Let's see how many new tricks I pick up as I work through this LLM project. I always forget that exists -- I could have used that instead, and kept the JIT.  ↩ Something's slightly broken in my brain and I keep reading that as "is our parameters changing" in George W. Bush's voice . Maybe I can stop that from happening by inflicting it on my readers instead. You're welcome.  ↩ I always forget that exists -- I could have used that instead, and kept the JIT.  ↩ Something's slightly broken in my brain and I keep reading that as "is our parameters changing" in George W. Bush's voice . Maybe I can stop that from happening by inflicting it on my readers instead. You're welcome.  ↩

0 views

Plugins case study: Pluggy

Recently I came upon Pluggy , a Python library for developing plugin systems. It was originally developed as part of the pytest project - known for its rich plugin ecosystem - and later extracted into a standalone library. You're supposed to reach out for Pluggy if you want to add a plugin system to your tool or library and want to use something proven rather than rolling your own. In this post I will share some notes on how Pluggy works, and will then review how it aligns with the fundamental concepts of plugin infrastructures . Pluggy is built around the concept of hooks : functions that host applications or tools (from here on, just "hosts") expose and plugins implement. A host exposes hooks by using a decorator returned from pluggy.HookspecMarker and a plugin implements this hook using a decorator returned from pluggy.HookimplMarker . Pluggy's documentation explains this fairly well; in this post, I'll show how to implement the htmlize tool with some plugins, introduced in the original article in my plugin series . As a reminder, htmlize is a toy tool that takes markup notation similar to reStructuredText, and converts it to to HTML. It supports plugins to handle custom "roles" like: As well as plugins that do arbitrary processing on the entire text. Out host defines two hooks: A hook is created by calling HookspecMarker with the project's name. This project name has to match between the host and its plugins. Pluggy is permissive about what hooks accept as parameters and what they return; for maximal flexibility and to stay true to the original htmlize example, our hooks return functions. To accompany this HookspecMarker , the host also defines a HookimplMarker with the same name: This is used by plugins to attach to hooks when they're loaded. The host's main function loads plugins at startup as follows: hookspecs is our Python module containing the hooks shown above. load_setuptools_entrypoints is Pluggy's helper for loading plugins that were pip -installed into the same environment and registered as setuptools entry points . It's a way to signal - in one's setup.py or pyproject.toml file - some metadata that projects can review at runtime. In our project, the plugins register themselves with this section in the pyproject.toml file: This says "for entry point htmlize , define a new entry named tt ". Pluggy's load_setuptools_entrypoints then uses importlib.metadata to access this information. Note that Pluggy doesn't require using this mechanism. Hosts can implement any plugin discovery method they want, and add plugins directly to their PluginManager with the register method. But this is the mechanism used for pytest and many other projects; it makes it very easy to automatically discover and register plugins that are installed with pip and equivalent tools. Once PluginManager loads the plugins, invoking them is straightforward; here's how htmlize invokes the contents hooks [1] : Generally, hook invocations return a list of all the hooks attached to by different plugins (a single host application can have multiple plugins installed and attaching to the same hook). When the host invokes the hook as shown above, the default order is LIFO, but plugins can affect this with hook options like tryfirst and trylast . Here's our entire narcissist plugin that's attaching to the contents hook: Some notes: Let's see how this case study of Pluggy measures against the Fundamental plugin concepts that were covered several times on this blog . It's important to remember that Pluggy is not a specific host application with a bespoke plugin system; rather, it's a reusable library for creating such plugin systems. Therefore, this is more of a meta case study. Generally, Pluggy leaves discovery logic to the user's discretion. Its PluginManager has a register method for adding plugins, and these can be discovered in any way the application chooses. That said, Pluggy comes with one discovery mechanism built in - through the entry points process of Python packaging, as shown above. This is hugely convenient for a large number of applications, as long as both the application and its plugins are installed via standard Python packaging tools (which is a very reasonable assumption in the Python ecosystem). In the entry point process, plugins register themselves by adding a [project.entry-points.<HOST-ID>] section in their pyproject.toml file. Otherwise - as in the previous section - users are free to devise their own registration schemes. This one is easy, since it's called hooks in Pluggy parlance as well! Pluggy's implementation of hooks is rather elegant, with function decorators available for plugins to set. We've seen an example of this above with @htmlize.hookimpl decorating htmlize_contents . Since Pluggy is designed for Python hosts and Python plugins, this one is fairly straightforward. The plugins typically assume the host project is already installed in the Python environment and its modules can be imported. In our example, hookimpl is imported from htmlize by the plugin to accomplish this. It also shows how host data is passed to the plugin - the post and db parameters. These are APIs exposed by the host for the plugins' use. In footnote 2 of my original fundamental concepts of plugin infrastructures post, I wrote [2] : I still believe my statement is true - plugin frameworks are very easy to create, and the functionality they provide is relatively small compared to their large surface area. In other words, this is a shallow API . That said, Pluggy does provide some nice functionality for the more advanced uses of plugins: Are these worthwhile for your project? It really depends on the project, and it's always worth keeping the tradeoff between dependencies and project effort in mind. The full code repository for this post is available here . It expects htmlize to be installed; as discussed previously, we rely on Pluggy's default install-based approach where both the host and plugins are installed into the same Python environment and can thus find each other. However, Pluggy supports any custom discovery method. It uses the hookimpl exported value shown earlier. It returns a function that acts on contents; this is the htmlize -specific contract (ABI, if you will) we've discussed before. Automatic entry point registration mechanism - if you need it Signature validation Consistent plugin result collection across multiple hook attachments in a single plugin and across many plugins Plugin ordering with firstresult , tryfirst , trylast , etc. Hook "wrappers" for some special use cases

0 views
Simon Willison 1 months ago

Publishing WASM wheels to PyPI for use with Pyodide

The Pyodide 314.0 release announcement (via Hacker News ) includes news I've been looking forward to for a long time: You can now publish Python packages built for Pyodide (or any Python runtime compatible with the PyEmscripten platform defined in PEP 783 ) directly to PyPI and install them at runtime. Previously, the Pyodide maintainers had to maintain, build, and host over 300 packages ourselves. This created a significant burden on our maintainers and became a major bottleneck for the community, as every new package required manual review. Moving forward, package maintainers can simply build and publish Pyodide wheels to PyPI, just as they do for native wheels on Linux, macOS, or Windows. Here's the PR to PyPI itself supporting this , which landed on April 21st. I adore Pyodide , and have been frustrated in the past by this limitation. It's possible to compile C or Rust extensions to WASM in a wheel file, but before now there was no easy way to distribute them. Thanks to the efforts of a whole lot of people, that's now been fixed! I decided to celebrate by finding something I could package. I have quite a few experimental Pyodide projects lying around, but the best fit for this looked to be my Luau WebAssembly research spike from 9th March. Luau is a "small, fast, and embeddable programming language based on Lua with a gradual type system", developed by Roblox and released under an MIT license. It's written in C++. I already knew it was possible to compile it to WebAssembly and get it running inside of Pyodide, so I set Codex + GPT-5.5 xhigh the task of packaging my experiment up and publishing it to PyPI using GitHub Actions. It took some iteration, but here's the result: luau-wasm is a brand new PyPI package which publishes a 276KB file which can be used in Pyodide like this: You can run that code in the Pyodide REPL demo to see it in action. The GitHub repo for luau-wasm includes all of the build and deploy scripts (using the latest cibuildwheel ) and also deploys an HTML demo page which loads Pyodide, installs and provides an interface for trying it out: https://simonw.github.io/luau-wasm/ I was curious to see how many packages are currently publishing wheels for this platform. After some tinkering with ChatGPT I got to this BigQuery SQL which I ran against PyPI's public dataset on BigQuery . Here's the raw JSON of query results and here's a SQLite SQL query in Datasette Lite which dedupes packages by most recent upload date. If the query is right, there are currently 28 PyPI packages publishing with the new tags: luau-wasm , uuid7-rs , cmm-16bit , pyOpenTTDAdmin , imgui-bundle , numbertoolkit , bashkit , geoarrow-rust-core , arro3-io , arro3-core , arro3-compute , onnx , powerfit-em , tcod , chonkie-core , tokie , robotraconteur , pydantic_core , yaml-rs , cadquery-ocp-novtk-OCP.wasm , uuid_utils , base64_utils , pycdfpp , lib3mf-OCP.wasm , typst , toml-rs , onnx-weekly , dummy-pyodide-ext-test Here's hoping we see a whole lot more of those showing up over the coming months and years. 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

Designing Software for Software Factories

Since my AI-powered Software Engineering (2024) post, the overton window has shifted from whether AI can even aid software development in any way to what parts of it even remain human. At work, we’ve been building out what I call our “software factory”, and this post I wanted to chat through how it’s come together, the hardest parts we’ve run into, and what actually works. While it's becoming a bit of a buzzword with different definitions depending on who you ask — I define it as an AI-driven system and the organization that surrounds it that solutions, designs, builds, tests, and deploys software products. If you've read The Transposed Organization , a "software factory" is just one of many 'loops' a modern AI native company must develop as a core part of EPD (eng, product, design) operations. The software factory absorbs the raw, unscoped stream of customer requests and resolves it into shipped software — with the human entering (For now) at exactly one point. Style inspired by background-agents.com. IMO a full Software Factory must: Be able to operate on the raw distribution of customer generated RFEs and bug reports as input. It does not count if a PM needs to scope every ticket or an engineer needs to break the solution into smaller pieces. Only require humans for off-ramping (pressing the big red stop button) and review at certain stages. It does not count if there’s an explicit “pairing” step anywhere in the loop or if the system runs on an individual’s laptop . Feed every review back into the system such that the review gate deprecates itself over time. It does not count (or work well) if reviews only apply to a single instance of software generation. Be able to run many requests through the loop concurrently, not one ticket at a time. It does not count if requests must be serialized because stages share mutable state (one test/staging env, one branch, one deploy slot) or if throughput is capped by a human-owned resource rather than by spend. You measure the first order 1 efficacy of a software factory typically via: Cycle time — The wall time for customer request to deploy (or per stage). Review volume — How much feedback is given across stages on AI-drafted outputs (per stage). Off-ramps — How often a request gives up and falls back to a human-in-the-loop development process (per stage). This can also be reframed as %-factory applicable. Thanks for reading Shrivu’s Substack! Subscribe for free to receive new posts and support my work. At the risk of being less useful, I'm going to focus this post on high level tips given you already have some semblance of a software factory setup. Exactly how one works, how end-to-end it goes, whether it's home grown or purchased is really going to vary company by company. I've mostly seen two buckets: AI-native startups Typically have a lot more room to build an AI-friendly tech stack and the contractual and compliance risks are typically lower. The downside is they can't afford to have a dedicated AI dev ops team to build any sort of "software factory platform". Recommendation : If Claude picked your stack, it's very likely you can actually just find something to buy as like a factory-as-a-service. It's also critical to set the expectation up front: with no central platform team, every engineer is the software-factory architect for the systems they own. Enterprise software companies (me irl) Moving to AI-friendly stacks becomes quite a large migration and the appetite for risk (often around service stability) is low to none. They do and have started software factory platform like teams. Recommendation : Today at least, it's likely easier to build than buy and do this via one more dedicated AI-readiness platform teams. A common failure mode is buying something with a low ceiling that actually can't get you to the same compliance and level of testing needed to actually ship a "real" feature. Another option, for those bold enough, is to fork all new products onto an AI-native stack but this only really pays off if it's isolated enough to not share the same compliance and stability risks. Another perhaps counterintuitive observation is that software factories work best when there's patterns, contracts, and scaffolding to match against. Pure greenfield projects don't have this and pre-maturely factorifying has led to code-bloat and a reduced ability to understand the project as it evolves. Another risk is just lack of data for how the project evolves and will evolve — a factory works best when the system itself is "roadmap aware". Brownfield projects on the flip side are often complex, unintuitive, with significant undocumented behavior (at least from a coding agents POV). With or without AI, a well-designed brownfield project is actually the ideal place to apply a factory. If you are just starting a project (greenfield), I’d build the first 3-10k LoC pairing with a coding agent and same with the first few E2E features. At modern development time scales this phase should take around ~1-3 weeks. Product-prototype loops likely take longer and I consider those out of scope — for the most part those are just vibe coded demos that become an input to the actual production project. If you are working with a mega-complex enterprise service (very brownfield), I'd apply these strategies: (1) Make all changes testable by an agent. To the deepest extent physically possible, an AI, with no human in the loop, should be able to tell you if a given PR breaks service functionality. (2) In a manual loop, prompt a coding agent to build out an upcoming feature (don’t steer, just let it both design, implement, and test e2e). Adjust the code organization and/or sprinkle markdown files until this works. If (1) or (2) are infeasible at scale you should just rebuild or decompose and rebuild a subcomponent of the service to make those both true. I realize that's non-trivial but I'll make the claim that often [# eng hours to do this - eng hours reduced by having a factory] « [# of eng hours to continue building features with engs in the loop] You can absolutely build and understand tens of thousands of lines of code shipped a day provided you have the right "contracts" for how those projects should be developed. Contracts can take a variety of forms (schemas, typing, etc) but typically my focus is on markdown ones (often AGENTS.md or .md files co-located and referenced with the code). A good markdown contract: Makes predictions about how the future of the project will evolve (what I mean by “roadmap” aware) Makes it clear how to validate changes and what can’t be validated Does not change 90% of the time feature to feature (e.g. % of future PRs that modify this file is very low) Establishes clear complexity and risk boundaries (e.g. x are examples of things that are easy, y are examples of things that are hard or risky) Is forward-tested on roadmap tasks (e.g. throwaway vibe code, with no steering, the next 3 items on the roadmap, if they are way off then the contract is bad) Defines key context an agent won’t see (e.g. often product and audience context, ~ a cache of “what should the agent know that’s not in the codebase”) The “ I wrote a markdown file and my agent still sucks ” crowd often include 0 of 6 of these because often the easiest (but wrong) thing to do is to just use the markdown as a summary of the project or as a storage for one-off tech specs. Here’s some snippets from util skills I have used for writing these 2 : An interesting question to think about — I would argue that often SWE1-2 or even SWE3 don’t actually have the full context to actually answer and validate correctness on all 6 bullets (some parts of this being experience-related but more so as an organizational artifact). In that case who writes the contract? It has to be owned one of two ways: by a group (a contract design review meeting?), or by a single “ transposed product engineer” who owns the contract end to end. Agents being able to run E2E tests is still extremely underrated among engineers. I think a lot of folks still think the ROI of agents being able to run tests comes from the fact that it saves them as the human from just running the test command and so given the priorities of "make agents run my test" vs "build new feature and I run the test command" it doesn't feel like it has to happen this sprint. The reality is that today's models can write surprisingly correct and complex software given a well-designed test harness. I like to think it's somewhat analogous to "reward" functions in reinforcement learning — define a function that maps a solution to a score and let the agent hillclimb that until it maximises it. The crux then becomes how do you actually define both: What does it mean, holistically, to be correct? From unit tests, to integration tests, to play tests, to alignment with the project “contract”. When not correct, how do we provide enough signal to the agent to actually work towards a higher score efficiently? From stack traces, to well crafted subagent judges, to browser-use screenshot verification. In other words, in this new world of AI native factory development, the manual eng effort to build any given feature should actually come from the effort to setup a test harness and contract rather than build the feature itself. If you are doing it right, your roadmap should look like: The effort inversion: human work front-loads into the harness and contract, then features become mostly tokens. Testing in tiers My mental model is often: Linting, typing Security and compliance scanning Single service integration tests (mock/shim everything else) Multi-service integration tests Multi-service integration tests from a product surface (click buttons on the actual UI) Rollout monitoring (post code-review if production, verifying logs and metrics against expected set) You can implement each tier as an MCP or CLI the agent runs after completing the tier before 3 . It’s critical the agent controls the full testing loop — a common design mistake I see is making these tiers human-gated stages (e.g. a human is required for a test deploy) because this completely collapses the autonomous cycle time of the factory. Another common mistake is making the tests too rigid (e.g. hardcoded playwright test) vs giving the agent enough flexibility to dynamically determine the test plan and actually change the test plan through the test loop. The goal is to define a set of test tiers that if passes (along with some contract review subagent signoff) — you have high confidence the factory produced a high-quality compliant feature. Three additional recommendations: Give your agents a dumping ground for verification scripts (e.g. every project gets a scripts/ folder). Just let it dump stuff there and let it decide when a script no longer works and when to just write a new one. Let your testing agent be a customer. Tests don’t have to be purely “did x thing work”, they can also be “would this be a delightful experience for x audience”. As models get better you can often get away with more abstract and end-user meaningful definitions of correctness. Give your agents the ability to build test environments. We have started using an interesting strategy (worthy of its own blog post) for multi-service integration tests. Instead of trying to maintain or bootstrap entire test/staging instances of our platform (which is both hard and extremely costly), we let the agents all access a shared test env with the ability to respawn certain services dynamically and re-route their own test’s traffic to those. If AuthAPI is broken in staging because of another agent, our agent will spawn a new node from a previous Git SHA and alias its tests against that. Don't rebuild the world. When a shared service breaks, an agent spawns one node from an old SHA and reroutes only its own traffic. Off-ramps, observability, and feedback loops Factories won't be able to cover 100% of all feature requests nor will they always produce perfect code but when they don't it's fundamental that those failures feedback into the system. I see typically two algorithms for this depending on whether a human is gating a stage (for QA and/or compliance reasons). Human-reviewer (e.g. post-code generation and testing, pre-merge 4 ) If the implementation is completely off, provide the option to either divert completely to manual-mode or re-run the previous stage with some additional clarification (”let’s not touch auth.py, do this using the xyz framework”). The review rarely occurs on the raw artifact (the full diff) but on curated should-review highlights derived from the project contract and test-loop artifacts. Store (agent traces, feedback) somewhere In batch (on the order of a week), an agent pushes changes to markdown contracts and code to prevent that failure again. Often these are also human reviewed and aggregated over all feedback and failure modes. In batch (on the order of rolling weeks to months), an agent pushes changes to markdown contracts and code with context on second order effects: Factory stage costs and latency Incidents, post-mortems, rollbacks Online system logs and metrics Product feedback and outcomes No human-reviewer (e.g. post-merge, rollout verification) Store agent traces somewhere In batch (on the order of rolling weeks to months), an agent pushes changes to markdown contracts and code with context on second order effects: Factory stage costs and latency Incidents, post-mortems, rollbacks Online system logs and metrics Product feedback and outcomes Both paths share the long second-order loop; only the human-reviewer path adds immediate feedback encoding. And in practice, the second-order feedback PRs themselves always get detailed human reviews regardless of path, by the same person who wrote them originally. My attempts to get autonomous second order feedback factories working have so far resulted in positive feedback slop loops . The hardest parts here often come down to managing reviewer cognitive load and making feedback sticky (i.e. converting feedback given on a factory instance to a systematic pattern to adopt). I don’t really have a great answer to that besides “get good at writing contracts that make changes easy to review most of the time”. Overheard (quote + my take): “AI isn’t good enough for <insert factory stage that isn’t writing code: product decisions, designing tech specs from those decisions, complex trustable testing>” At this point it’s mostly a skill, context, or stack issue — and those are within your team’s control. The models and harnesses are good enough for E2E SWE factories. “We don’t need human review in the software factory even for QA because we can apply more agents to fix the bugs they shipped super quickly” I’m a big believer in “shift-left” philosophy which means problems are solved and mitigated as early in the loop or ideally out of the loop as much as possible. Shipping broken changes (even momentarily) is a recipe for getting out AI-engineered by a company that used AI to ship good changes faster and for getting stuck in situations where no one understands the system at any level of depth to know the difference between a good and a bad change. Thanks for reading Shrivu’s Substack! Subscribe for free to receive new posts and support my work. The first-order metrics are fairly easy to measure but they are not the full picture. Incidents, customer feedback, factory output related code reversions, etc. both help convert production into business outcomes. You can also just drop this entire blog post into claude to figure out next steps. Testing MCPs interfaces can actually be quite simple — exec_bash_in_environment(). The complexity comes from doing this with the right data governance and security guarantees. Pull request review and merge is often a very natural checkpoint in a software factory for human review. It's both what gates correctness ("if code is merged it is deemed correct") and is used by compliance teams as a controls surface (e.g. SOC compliance). The artifact of this is that I do think we'll need to break the "changes should be of a certain size" contract as the PR becomes a unit of factory work over anything else. The software factory absorbs the raw, unscoped stream of customer requests and resolves it into shipped software — with the human entering (For now) at exactly one point. Style inspired by background-agents.com. IMO a full Software Factory must: Be able to operate on the raw distribution of customer generated RFEs and bug reports as input. It does not count if a PM needs to scope every ticket or an engineer needs to break the solution into smaller pieces. Only require humans for off-ramping (pressing the big red stop button) and review at certain stages. It does not count if there’s an explicit “pairing” step anywhere in the loop or if the system runs on an individual’s laptop . Feed every review back into the system such that the review gate deprecates itself over time. It does not count (or work well) if reviews only apply to a single instance of software generation. Be able to run many requests through the loop concurrently, not one ticket at a time. It does not count if requests must be serialized because stages share mutable state (one test/staging env, one branch, one deploy slot) or if throughput is capped by a human-owned resource rather than by spend. Cycle time — The wall time for customer request to deploy (or per stage). Review volume — How much feedback is given across stages on AI-drafted outputs (per stage). Off-ramps — How often a request gives up and falls back to a human-in-the-loop development process (per stage). This can also be reframed as %-factory applicable. AI-native startups Typically have a lot more room to build an AI-friendly tech stack and the contractual and compliance risks are typically lower. The downside is they can't afford to have a dedicated AI dev ops team to build any sort of "software factory platform". Recommendation : If Claude picked your stack, it's very likely you can actually just find something to buy as like a factory-as-a-service. It's also critical to set the expectation up front: with no central platform team, every engineer is the software-factory architect for the systems they own. Enterprise software companies (me irl) Moving to AI-friendly stacks becomes quite a large migration and the appetite for risk (often around service stability) is low to none. They do and have started software factory platform like teams. Recommendation : Today at least, it's likely easier to build than buy and do this via one more dedicated AI-readiness platform teams. A common failure mode is buying something with a low ceiling that actually can't get you to the same compliance and level of testing needed to actually ship a "real" feature. Another option, for those bold enough, is to fork all new products onto an AI-native stack but this only really pays off if it's isolated enough to not share the same compliance and stability risks. If you are just starting a project (greenfield), I’d build the first 3-10k LoC pairing with a coding agent and same with the first few E2E features. At modern development time scales this phase should take around ~1-3 weeks. Product-prototype loops likely take longer and I consider those out of scope — for the most part those are just vibe coded demos that become an input to the actual production project. If you are working with a mega-complex enterprise service (very brownfield), I'd apply these strategies: (1) Make all changes testable by an agent. To the deepest extent physically possible, an AI, with no human in the loop, should be able to tell you if a given PR breaks service functionality. (2) In a manual loop, prompt a coding agent to build out an upcoming feature (don’t steer, just let it both design, implement, and test e2e). Adjust the code organization and/or sprinkle markdown files until this works. If (1) or (2) are infeasible at scale you should just rebuild or decompose and rebuild a subcomponent of the service to make those both true. I realize that's non-trivial but I'll make the claim that often [# eng hours to do this - eng hours reduced by having a factory] « [# of eng hours to continue building features with engs in the loop] Makes predictions about how the future of the project will evolve (what I mean by “roadmap” aware) Makes it clear how to validate changes and what can’t be validated Does not change 90% of the time feature to feature (e.g. % of future PRs that modify this file is very low) Establishes clear complexity and risk boundaries (e.g. x are examples of things that are easy, y are examples of things that are hard or risky) Is forward-tested on roadmap tasks (e.g. throwaway vibe code, with no steering, the next 3 items on the roadmap, if they are way off then the contract is bad) Defines key context an agent won’t see (e.g. often product and audience context, ~ a cache of “what should the agent know that’s not in the codebase”) What does it mean, holistically, to be correct? From unit tests, to integration tests, to play tests, to alignment with the project “contract”. When not correct, how do we provide enough signal to the agent to actually work towards a higher score efficiently? From stack traces, to well crafted subagent judges, to browser-use screenshot verification. The effort inversion: human work front-loads into the harness and contract, then features become mostly tokens. Testing in tiers My mental model is often: Linting, typing Security and compliance scanning Single service integration tests (mock/shim everything else) Multi-service integration tests Multi-service integration tests from a product surface (click buttons on the actual UI) Rollout monitoring (post code-review if production, verifying logs and metrics against expected set) Give your agents a dumping ground for verification scripts (e.g. every project gets a scripts/ folder). Just let it dump stuff there and let it decide when a script no longer works and when to just write a new one. Let your testing agent be a customer. Tests don’t have to be purely “did x thing work”, they can also be “would this be a delightful experience for x audience”. As models get better you can often get away with more abstract and end-user meaningful definitions of correctness. Give your agents the ability to build test environments. We have started using an interesting strategy (worthy of its own blog post) for multi-service integration tests. Instead of trying to maintain or bootstrap entire test/staging instances of our platform (which is both hard and extremely costly), we let the agents all access a shared test env with the ability to respawn certain services dynamically and re-route their own test’s traffic to those. If AuthAPI is broken in staging because of another agent, our agent will spawn a new node from a previous Git SHA and alias its tests against that. Don't rebuild the world. When a shared service breaks, an agent spawns one node from an old SHA and reroutes only its own traffic. Off-ramps, observability, and feedback loops Factories won't be able to cover 100% of all feature requests nor will they always produce perfect code but when they don't it's fundamental that those failures feedback into the system. I see typically two algorithms for this depending on whether a human is gating a stage (for QA and/or compliance reasons). Human-reviewer (e.g. post-code generation and testing, pre-merge 4 ) If the implementation is completely off, provide the option to either divert completely to manual-mode or re-run the previous stage with some additional clarification (”let’s not touch auth.py, do this using the xyz framework”). The review rarely occurs on the raw artifact (the full diff) but on curated should-review highlights derived from the project contract and test-loop artifacts. Store (agent traces, feedback) somewhere In batch (on the order of a week), an agent pushes changes to markdown contracts and code to prevent that failure again. Often these are also human reviewed and aggregated over all feedback and failure modes. In batch (on the order of rolling weeks to months), an agent pushes changes to markdown contracts and code with context on second order effects: Factory stage costs and latency Incidents, post-mortems, rollbacks Online system logs and metrics Product feedback and outcomes Store agent traces somewhere In batch (on the order of rolling weeks to months), an agent pushes changes to markdown contracts and code with context on second order effects: Factory stage costs and latency Incidents, post-mortems, rollbacks Online system logs and metrics Product feedback and outcomes

0 views