Posts in Json (20 found)
flowtwo.io 3 days ago

SWE Interview Prep in 2026

Interviewing for software engineering roles in 2026 is a challenging, fast-moving landscape. Some companies are abandoning traditional interview formats in favour of more AI-native assessments, but some aren't. Which means for you, the interviewee, you still have to be prepared for all of it. Here's what worked well for me as I did interview prep over the last few months. I primarily use leetcode for practicing coding problems. I know there's alternatives out there, but I think leetcode's question bank is the most challenging and diverse. There's also some useful features included in the premium subscription, if you're willing to pay for it. Here's some tips to optimize your preparation for "manual" coding interviews: it's a directed acyclic graph...of course When preparing for a specific company's interview, use leetcode's company filter to do problems that are actually asked by that company. There's a good chance you could be asked the same or a similar question in your interview. * *IMPORTANT** Practice doing "Design" problems on leetcode. In my experience, the questions you get asked in interviews are usually more like this type of question, compared to a straight up algorithm/data structure type question. These are invaluable practice AND are genuinely more interesting problems to solve. Examples include building an LRU cache, making a log aggregator, making a stack calculator. When solving questions like these you generally have to make tradeoffs in the read / write portions of the API. This is a more realistic problem that companies use to assess design thinking and how well you reason about tradeoffs. Make sure you review how to do common "practical" operations in your language of choice. Operations like reading and writing to the terminal, File IO, Network IO, serialization, CSV and JSON parsing, and concurrency primitives. These are all critical parts of programming interviews that you might not do often at work, and you'll definitely forget to practice if you only rip leetcode questions. I've tripped up in past interviews when part of the question required doing one of these basic operations (like parsing a CSV file) and I didn't know how to do it off the top of my head. Looking up the API and/or libraries will waste time, so it's super helpful if you have an idea about how to do it already. System design is usually the most intimidating part of the engineering interview loop. It's how companies decide on levelling and team fit. It's also one of the hardest to prepare for because it's more about experience and generic best practices. But here are some tips anyway. There's a lot of system design preparation services out there. I've found HelloInterview to be the best bang for your buck. They have a great practice platform with AI judging and feedback. Do as many as possible so you develop a muscle memory for thinking through these types of problems. They also have in-depth written and video guides that are up-to-date and balance technical/practical knowledge pretty well. When you get tired of just talking with AI, try out Pramp for doing actual mock interviews with other people who are also studying. It's free, and there's nothing better than live practice with a complete stranger judging you (just like an interview). I started an LLM Wiki using Obsidian + Claude specifically for system design knowledge. The LLM Wiki concept was introduced by Andrej Karpathy earlier this year. I use this Claude plugin to streamline the process. The wiki was great for providing structure to my research and learnings. Over time it became a useful reference for concepts and case studies I'd read already. You can you Claude to query it, or even get it to quiz you on specific things you've learnt. I recommend reading the engineering blogs for well-known tech companies to get insight into how the biggest software organizations in the world are solving scalability and distributed systems issues. Lots of excellent reads out there. Behavioural might be the least practiced part of the interview loop. It's easy to assume you'll be able to talk about your past projects and work impact, especially if it's recent. But it's still important to practice so you know what you should highlight in your stories and what questions will likely be asked when you finish. Otherwise you end up just blabbing about unimportant details and it will be hard to understand. Everyone talks about the STAR format for behavioural questions, but I prefer the CARL format . It puts focus on ending stories with what you learnt , instead of just what happened . This shows the interviewer you reflect and look to improve on your past experiences. It also sounds more natural in a conversation, instead of just "we shipped the new feature and it improved user session length by 30%". In addition to using CARL, make sure you begin each story with a 1-line summary of the whole experience, including the outcome. Only after do you start diving in to more details—being flexible enough to pivot based on the conversation with the interviewer. Structuring your responses this way shows professionalism and leadership qualities. You provide the headline and then continue with the details. If this sort of dialogue doesn't come naturally to you, as is the case with me, I highly encourage you to practice. If you can practice with a friend or colleague, that's great. Another option now is practicing with AI. I got in the habit of going on walks and having ChatGPT run through mock questions with me via voice input. Nice way to get your steps in and also prepare for that upcoming interview. That's all I got. These strategies helped me land a new gig last month after about 3 months of preparing and interviewing. My final tip—be patient. Getting an offer is a combination of preparation, timing, and luck. So if something doesn't work out don't get discouraged...the right opportunity will come. Good luck with your interviews! Don't do random questions if you're just starting to study. You'll waste a ton of time slamming your head against most problems and eventually have to give up and look at the solution. It's much more helpful to do systematic practice and learning of the concepts. Eventually you'll develop an intuition for how to model a given problem when you see it (e.g. as a graph traversal, dynamic programming, greedy etc...) I recommend using this learning path on leetcode that combines quick lessons with practice problems. Each lesson builds on the concepts from the previous lessons. When preparing for a specific company's interview, use leetcode's company filter to do problems that are actually asked by that company. There's a good chance you could be asked the same or a similar question in your interview. * *IMPORTANT** Practice doing "Design" problems on leetcode. In my experience, the questions you get asked in interviews are usually more like this type of question, compared to a straight up algorithm/data structure type question. These are invaluable practice AND are genuinely more interesting problems to solve. Examples include building an LRU cache, making a log aggregator, making a stack calculator. When solving questions like these you generally have to make tradeoffs in the read / write portions of the API. This is a more realistic problem that companies use to assess design thinking and how well you reason about tradeoffs. Make sure you review how to do common "practical" operations in your language of choice. Operations like reading and writing to the terminal, File IO, Network IO, serialization, CSV and JSON parsing, and concurrency primitives. These are all critical parts of programming interviews that you might not do often at work, and you'll definitely forget to practice if you only rip leetcode questions. I've tripped up in past interviews when part of the question required doing one of these basic operations (like parsing a CSV file) and I didn't know how to do it off the top of my head. Looking up the API and/or libraries will waste time, so it's super helpful if you have an idea about how to do it already. There's a lot of system design preparation services out there. I've found HelloInterview to be the best bang for your buck. They have a great practice platform with AI judging and feedback. Do as many as possible so you develop a muscle memory for thinking through these types of problems. They also have in-depth written and video guides that are up-to-date and balance technical/practical knowledge pretty well. When you get tired of just talking with AI, try out Pramp for doing actual mock interviews with other people who are also studying. It's free, and there's nothing better than live practice with a complete stranger judging you (just like an interview). I started an LLM Wiki using Obsidian + Claude specifically for system design knowledge. The LLM Wiki concept was introduced by Andrej Karpathy earlier this year. I use this Claude plugin to streamline the process. The wiki was great for providing structure to my research and learnings. Over time it became a useful reference for concepts and case studies I'd read already. You can you Claude to query it, or even get it to quiz you on specific things you've learnt. I recommend reading the engineering blogs for well-known tech companies to get insight into how the biggest software organizations in the world are solving scalability and distributed systems issues. Lots of excellent reads out there. Everyone talks about the STAR format for behavioural questions, but I prefer the CARL format . It puts focus on ending stories with what you learnt , instead of just what happened . This shows the interviewer you reflect and look to improve on your past experiences. It also sounds more natural in a conversation, instead of just "we shipped the new feature and it improved user session length by 30%". In addition to using CARL, make sure you begin each story with a 1-line summary of the whole experience, including the outcome. Only after do you start diving in to more details—being flexible enough to pivot based on the conversation with the interviewer. Structuring your responses this way shows professionalism and leadership qualities. You provide the headline and then continue with the details. If this sort of dialogue doesn't come naturally to you, as is the case with me, I highly encourage you to practice. If you can practice with a friend or colleague, that's great. Another option now is practicing with AI. I got in the habit of going on walks and having ChatGPT run through mock questions with me via voice input. Nice way to get your steps in and also prepare for that upcoming interview.

0 views

Type-safe HTML templating in Zig

I’ve done a lot of web development over the years. I started around the year 2000, just after PHP 3.0 was released and started taking over the web world. I’ve used all kinds of approaches for generating HTML: raw PHP, string concatenation in Python, Smarty templates in PHP, Template Toolkit in Perl, and Jinja/Django templates in Python. Then, as the world moved on to SPAs and JavaScript frameworks, I stopped working on frontends. Two years ago, I picked up Zig, and I’d never have imagined I’d be considering web development in such a low-level language. But as I was building my own ecosystem with zio and Dusty , I realized it’s not that far-fetched. I built these libraries for API backends, where the output is JSON or MessagePack, but then I saw that with proper use of arena allocators, even web development is actually manageable, since you don’t need to remember to deallocate all the strings that are typically involved. And with Zig’s comptime, you can build very high-level interfaces without losing the performance benefits. And with the help of htmx or Datastar , you can even build SPAs without touching JavaScript/TypeScript, which I promised myself I’d never do. That said, one thing was still missing: templating. String concatenation was not an option, I didn’t want to go back to the pre-2000 CGI days. I had a few requirements for a templating library: I couldn’t find any solution that would satisfy all these requirements. Then I saw Ziex and I was amazed. It never occurred to me that the JSX approach could be done outside of JavaScript. The developer experience Nurul Huda managed to build in Zig was just unimaginable to me. It didn’t fit my needs directly, because I didn’t want a WASM framework, but the syntax and his approach to integrating Zig code into templates gave me some ideas. Then, after further research, I discovered Templ for Go, and it was pretty obvious to me what I wanted to build. So, let me present zt (I’m not good at naming, sorry) . It’s an adaptation of Templ to Zig. The templates are parsed and transpiled to Zig code during . That means you can import them directly from your app code, but also, perhaps even more importantly, the templates can import your app code. As a result, all parameters can be fully typed. If something can’t be rendered, the Zig compiler will tell you so. The generated code is fairly simple, all it does is write to a . You can write directly to a socket or accumulate the output in a buffer, your choice. Here is what a template looks like: And here is how you would use it from Zig code: If you want to use it inside Dusty to render an HTML page, you just do this: As a side note, the snippet above has a very non-obvious bug in error handling if you use the same pattern somewhere cancellation matters. I’ll probably write a blog post about it, but if you are interested, check out this Ziggit thread . The function in Dusty that wraps the handling solves it. All dynamic values are HTML-escaped by default, both in text and in attributes, and values in URL attributes like or are also checked for dangerous schemes. If you really need to output raw HTML, you have to ask for it explicitly with , so it’s easy to spot in the template. The parser also requires all elements to be properly closed, so you can’t accidentally produce broken markup. It won’t protect you from everything, but it removes the most common sources of XSS. What I like the most is that template errors are just Zig compiler errors. If you make a typo in the template above and write instead of , will tell you: The error points to the generated code, but each line has a comment with the location in the original template. The same applies to the calling side, if you try to pass a string where expects a , it won’t compile. Beyond the basics shown here, you can build reusable components and pass them around as values, and with you can build layouts that wrap other templates, which covers what you’d use template inheritance for in other languages. Control flow is just Zig, so , and work as you’d expect, including payload captures. All of it still compiles down to a sequence of writes, without any allocations. You can have a look at the TODO example in Dusty. It’s a simple htmx-based web app that showcases this library, plus other parts of Dusty that are needed for building a dynamic SPA. One part of the syntax is still open. Block-level control flow is written as plain Zig, following the example of Templ, so a line starting with , or is parsed as code. That means text like “if you forgot your password…” on its own line is a parse error, and you have to wrap it in an element or write it as a string expression. I’m considering switching to , and , similar to Razor or Twirl, which would remove the ambiguity, at the cost of slightly noisier templates. If you have an opinion on this, please leave a comment in this GitHub issue . Although multiple people are already using it, the project is not very mature yet, so I welcome any feedback. You can open a GitHub issue if you find any problems, or ping me on the Zig Discord. It has to be type-safe, with all inputs typed and checked at compile time. I’ve dealt with dynamic languages enough in my life, and I don’t want to deal with runtime template errors anymore. It has to be fast. No allocations, no intermediate state, just write the output to directly. It needs to have safe defaults. I don’t want to deal with XSS anymore, so all inputs have to be escaped by default. It should still feel like writing HTML. I didn’t want a Zig-based DSL I’d have to fight constantly.

0 views
Giles's blog 6 days ago

Fun with low-rank vocab matrices (and a bonus test loss reduction?)

I was nerdsniped ! On the HF discussion page for one of the models I created for my previous post , asked if I'd considered trying out factorised embeddings -- something he's using for his model, Maba, and which was previously used in some other models, like ALBERT . It's a really nifty idea; you use a similar trick to LoRA as a way of reducing the number of parameters used for your embeddings and your output head. And for small models, those can be a disproportionate number of the total. For example, with my 163 million parameter GPT-2 small-style models, the embeddings are about 39 million of them ; the output head is the same size, so with those two taken together, that's half the model just getting stuff in and out rather than actually doing the thinking. Even if you use weight tying, like the original GPT-2 models did, you'll wind up spending 30% of your "budget" on the single matrix shared between embeddings and the output head. Similarly, while larger models have a smaller percentage spent that way, even medium-sized MoE models can wind up spending a lot of the active parameters on embeddings; I calculated that for Qwen 3.6 35B MoE, with 3B active parameters, there were about 1B of them used across both the input embeddings and the output head. So anything that can reduce that -- so long as it doesn't come at a high cost in terms of the model's capabilities -- is worth considering. You save on your parameter count, which either means smaller models and potentially quicker training, or allows you to "invest" the savings in more thinking parameters -- that is, a wider network with a higher embedding dimensionality, or a deeper one with more layers. Andrew reported that the impact of using this trick was minimal: At rank 128 on a 50k vocabulary, the penalty on validation cross-entropy is negligible (typically within +0.02 to +0.04 loss, or <0.5 perplexity delta). Now, that suggests that he's getting a loss of around 2.5 (that being the point where a loss increase of 0.04 implies a perplexity change of 0.5), which is much lower than I typically get with my GPT-2 architecture (3.5 is more like it for me), but still, perhaps I'd get good numbers too? I decided to train a few models to see what happened. The results were interesting! I found that using this trick on both the embeddings and the output head caused a somewhat larger increase in loss than Andrew described -- about 0.07 in my first test, 0.10 in the second, and 0.14 in the third. Those are relatively large, though potentially recoverable from further training or larger models. But more surprisingly, I found that using the trick on the input embeddings only seemed to reduce test loss. Before I'd started, I'd expected it to be less harmful on the input side than it was on the output side, but seeing an improvement was certainly unexpected. Getting loss down by reducing your number of parameters is not what normally happens! So, it's definitely worth digging in a bit. Let's start with the theory: what are these factorised embeddings, and how do they work? Like LoRA, they rely on low-rank factors, so firstly I'll define those. Our models are made up of a large set of matrices -- embeddings, output heads, attention weights, FFN linear layers, and so on. Making them smaller obviously reduces the number of parameters for the model, though you would expect it to come at a cost. The idea behind low-rank factors is that you can replace a larger matrix with two smaller ones that will contain most of the important information. Imagine that you've got a matrix of size m × n . Now, from matrix multiplication, we know that if you multiply an m × r matrix by an r × n one, you'll get a result that is m × n . If we do that, then we've factorised the matrix (in the same way as we might factorise 12 into 3 and 4 because 3 × 4 = 12 ), and the value r is referred to (logically enough) as the rank of the factorisation. If r is sufficiently smaller than m and n , then these two matrices -- the low-rank factors -- combined will contain fewer parameters than the full matrix. Let's make this specific, using the output head of a GPT-2 style model without weight tying. It takes in the embeddings that result from the Transformer layers (after normalisation), and converts them into logits across the vocabulary. For the GPT-2 small size, our embeddings are 768-dimensional, and the vocab size is 50,257 tokens. So when we use a linear layer to do that mapping, it has 768 × 50257 = 38597376 parameters. If we were to replace it with two matrices -- say, one of 768 × 128 and one of 128 × 50257 , then combined they would take up 768 × 128 + 50257 × 128 = 98304 + 6432896 = 6531200 parameters. That is almost six times smaller! So: the idea is that instead of creating our model with an m × n matrix, we create it with a pair, m × r and r × n , and train those instead. If all goes well, that pair will be almost as capable of learning what we want as the full one would have been, so we'll get results that are close enough. The maths and the implementation work out simply, too. For a linear layer with no bias, we can write the matrix multiplication that takes our inputs X and a weight matrix W , and produces an output Z , like this: 1 Now, if we're using a pair of low-rank factor matrices A and B instead of a full one, W , we can say the "virtual" weights we want to use for the calculation are W ′ such that: So that means that our calculation for this neural network is this: Matrix multiplication is associative, which means that you can rewrite that as: ...which hopefully you can see is the same as feeding X through a layer using A as its weights, and then feeding the result through a second one using B . In code, you've taken something like this: ...and replaced it with this: It's probably intuitively obvious, though, that this comes at a cost. If you have fewer parameters then you can store less information, so this part of your model is "dumber". If it's not clear, though, imagine that in the code above was one. You would be taking the (for GPT-2 small) 768-dimensional embedding, converting it to a single number, and then expanding that single number out to a 50,257-dimensional set of logits. Stuff is going to get lost -- the idea behind low-rank factors is that, so long as you choose an appropriate value for r (which is often referred to as the width of the low-rank bottleneck ), you won't lose the important stuff. This works surprisingly well in many cases -- in particular, LoRA, which allows you to fine-tune models that are too hard to fully train on your hardware, or to fine-tune them faster, uses it to easily train something like low rank factor "diffs" to your weight matrices 2 . But for this post, we'll try the simplest version: what if we replace both the input embeddings and the output head -- the vocabulary matrices -- in their entirety with low-rank bottlenecks? Low-rank factorisation of the vocabulary matrices is a bit of a mouthful, so based on the name "LoRA", I decided to call this trick LoRE, for low-rank embeddings. It's not strictly accurate (after all, we're doing it both to the embedding matrix at the start of the LLM and to the output head at the end), and I don't expect it to take off, but I rather like it and will use it in this post :-) The argument behind it is that the embeddings are just a simple lookup table, so are exactly the kind of place you'd expect to be able to make savings by only considering the important parts of our huge matrix; the same might apply to the output head at the end, though personally I was less convinced by this part. It's worth looking into that asymmetry a bit. My intuition was that while embeddings really are a lookup table, the output head is doing something a bit more subtle. It's projecting from the continuous embeddings that come out of our Transformer layers into logits, which we interpret (via softmax) as a probability distribution over possible next tokens. That's a significantly less simple job than mapping from "cat" to the appropriate embedding: as a (simplified) example, if you have an embedding that means something adjacent to "cat", "dog", "gerbil", "household pet" and so on, then projecting that to the appropriate values for the next token is non-trivial. 3 Still, implementation-wise, it was really simple. I decided to extend the PyTorch code that I had from Sebastian Raschka 's book " Build a Large Language Model (from Scratch) ", by adding an optional section to the model config JSON. This would allow us to switch on LoRE mode for the input embeddings and the output head independently, and would specify the rank -- that is, the r in the section above, which says how wide the low-rank bottleneck between matrices A and B would be. So, in code, my first cut changed this: ...to this: ...and this: ...became this: That in itself was a perfectly reasonable implementation of the underlying concept. But it had a problem. I wanted to compare the loss with and without LoRE, and also find out what the effects of input embedding-only and output-head-only LoRE would be. But I've found in other experiments that the initial random weights that my models start with can have a significant effect on the loss that the resulting model gets. The code above would give quite different random weights with different configurations. The fix was simple, though: With that code, so long as there is any LoRE config, we create the same weights regardless of whether it's used in the input embeddings or the output head. I would need to train a baseline which had config for LoRE but just had both and set to there -- that would actually be a normal, non-LoRE model but would have the same initial random weights in any parts that it shared with an equivalent model that did use LoRE. Additionally, Andrew had said that we need to be careful about initialisation: The main failure mode to watch for during training is early gradient spikes: scaling the bottleneck projection initialization to 1 / sqrt(r) keeps variance uniform and ensures training dynamics match the full-rank baseline from step 1. ...and later : Keep standard GPT-2 init on the embedding table, but scale the inner linear projection matrices by std = 1.0 / math.sqrt(128). (The 128 in there is because we were talking about a setup with r = 128 , which is actually what I wound up using.) Now, I was using PyTorch's default initialisation rather than the classic GPT-2 setup, but I went ahead and added a flag to handle the scaling as Andrew described: if it was set, then right at the end of the model creation I applied the initialisation to the low rank factor matrices that were innermost in my model (as in, closest to the Transformer layers): Even though that was not run for every configuration, because it happened right at the end of the model creation, it would not affect the other weights, so I figured that it would be safe. You can see the full diff from the normal GPT-2 code here . Those changes looked correct to me, and I asked GPT-6 Astra (on "extra high" thinking) to confirm that it looked right to it in the context of the discussion, which it did. That done, I double-checked with Andrew , as I really wanted this to be a clean attempt at a repro of his results. He confirmed, so it was time to train some models! I decided that I wanted five training runs to check this out thoroughly: To save time, I decided to train them in the cloud: the first three, and then the last two, in two batches of parallel runs. I used 8x A100 machines with 40 GiB VRAM per GPU on Lambda . I'll break with my normal tradition of giving detailed runthroughs of each training run, as there are (spoiler) quite a lot of them in this post! However, for reproducibility, the commands I ran for each one looked like this: ...with the run ID, , replaced with the appropriate one for the given run settings . Here are the results from the first three runs: the baseline, and the ones with LoRE at both ends, with and without smart initialisation. The model name links go to the uploaded models on Hugging Face. You can see that I had some issues with the last run; there were shortages of available instances, and I wound up starting one in Japan. Connectivity to it from my home in Portugal was really slow and unreliable; when I tried to download the model after the training run, was predicting 10 hours (which at the machine's cost of US$15.92/hour would have been insane). However, I was able to copy it to my PythonAnywhere account quickly, perhaps because that is in a US Amazon datacenter and had better connectivity to Japan, and then I could copy it from there to my machine, again quite quickly. Unfortunately while scrabbling around with that I didn't capture all of the output. Still, the important data at this stage was in the test loss column. As expected, using LoRE made the test loss worse. And smart initialisation did seem to help reduce that penalty. The loss delta, at +0.07, was worse than the +0.02 to +0.04 that Andrew described in his message, but not wildly different -- certainly small enough that it seemed plausible that you could recover it, and perhaps more, by investing the 64M parameter difference in a deeper or wider model. Promising! The cost and time savings were real, too: the run in Japan had its cost bumped up by all of the woes I had copying the model down, so the one is the best indication: US$38.55 rather than US$43.96, so a saving of more than 10%. And while going down from a bit more than 2h30m to 2h14m might not seem like much, with a larger model or a longer training run, an almost 14% reduction could be valuable in and of itself. (And that's not even considering the alternative of "reinvesting" the saved parameters in larger models.) So that was pretty good news! It was time to do the ablation runs to see whether having LoRE just on the token embeddings, or just on the output head, made things any different. I switched the setting for the remaining two models to , and kicked them off. Now, there's a bunch of interesting stuff in there, but let's focus on the test loss. My lab notes for this bit say "well, that's a bit of a shocker!" Quite amazingly -- at least to me -- we got lower test loss with the input-only LoRE, with 3.526610, than we did with the no-LoRE baseline, which got 3.599611. Things get even more interesting when you compare the different versions (excluding the non-smart-init) against each other, in terms of their difference from the no-LoRE baseline (which I'll call the delta for conciseness below). Rounding to 3dps: If you add the two single-LoRE loss deltas together -- the 0.138 and the -0.073 -- you get 0.065, which is surprisingly close to the delta of 0.072 for the dual-LoRE option, just 0.007 away. Could we be looking at two almost independent effects, one for each option of where to apply the LoRE? And was that apparent benefit of the input-only LoRE a real thing? Or was it due to chance -- perhaps we had "good luck" when initialising the LoRE weights, and "bad luck" when initialising the non-LoRE ones. If there was an overlap between the best loss you could get with LoRE and the worst loss you could get without, then this result could be within the noise. I decided I was going to do a second batch of training runs to see if I could get evidence one way or the other. But before we get into that, it's worth taking a look at some of the other numbers in those two tables. Firstly, let's take a look at the training time. The training machines looked like they were running at close to 100% during all of the runs, so tentatively let's assume that time taken is proportional to compute. Unsurprisingly, the baseline run came in with the longest time, at 9,339s. And equally unsurprisingly, the full-LoRE run that we have timing for was much faster, at 8,057s. It certainly makes sense that replacing big matrices with pairs of (much) smaller low-rank factors will mean fewer calculations on both the forward and the backward pass, meaning that the training run is faster. There was something that initially surprised me with the single-LoRE runs. Using LoRE on the output head sped things up quite a lot, but input embedding LoRE had much less effect. However, after thinking about it a bit, it became clear what was going on. An embedding module in a network is actually really quick to run, both on the forward and backward pass. While I like to think of embeddings as conceptually being a projection of a one-hot vector in vocab space into embedding space via a matrix multiplication, and I still think that's an excellent model of interpreting what's going on, in practice it's just something much more like an array lookup, with almost zero cost. Likewise on the backward pass, we only need to work out gradients for the chosen embedding, so that should be faster too. Indeed, with that all in mind, it might not have been surprising if input-only LoRE had turned out to be a bit slower than the baseline, as it, at least, needed to do forward and backward passes through actual matrices, even though they were fairly small. Doing another of those delta tables, the time difference in seconds (using the "ordinary init" run for the both-on number): You can see that if you add the delta of the single-LoRE options, you get -1,251, which is pleasingly close to the delta of the dual-LoRE one. And that is much less surprising than the loss result -- with training compute, you really would expect improvements like this to be additive. Finally, there was something interesting in the initial training loss. Because my training code prints out the loss it got on the first global step, I decided to note it down. There's something in this data that will become important later: in both of the training runs with LoRE on the output head and smart initialisation, it went up noticeably. Normally, the initial training loss on my GPT-2-style models is a bit less than 11. This fits in well with the architecture; you'd expect that a model that was predicting tokens purely randomly (well, strictly, uniformly) would have a perplexity roughly equal to the vocab size; the vocab size for the GPT-2 tokeniser is 50,257, and loss is the natural logarithm of perplexity, so that implies a loss of roughly 10.82. But both of those smart-init models with LoRE on the output head had an initial loss about one point higher, at 11.865 and 11.817, meaning that they were significantly worse than random. What might be going on here? We'll find out later :-) Anyway, for now, that was an interesting set of results. I wanted to dig in more; in particular, would that surprising improvement of test loss for the input-only LoRE model reproduce if I tried again with a different random seed? I had two working hypotheses at this point; either A solid and simple test to try to distinguish between these was just to train another set of models, with a different random seed. If I got the same kind of results, input LoRE beating non-LoRE, that would be evidence in favour of (2) rather than (1), whereas if things changed, it would work the other way. I decided not to bother with a non-smart-init model this time, so I set things up for four new models. My training code uses a random seed of 42 by default, but allows it to be overridden in the config file, so I decided to use 123 for these, and created: Once I had the config together, it was time to train the models -- but it was hard to get available instances on Lambda. I wound up having to use my script to start things up, but after a few hours I got an alert on my phone that I had instances running, and could kick things off. The second training run with input-only LoRE came in with a test loss that once again beat the baseline, so I did the full set of runs, and here are the results: Another matrix of deltas against the baseline: So, once again, input-only LoRE improved test loss (though at -0.013, it was a smaller effect than the -0.073 the previous set of models had). And once again, the sum of the input-only and output-only LoRE deltas was close to the combined-LoRE model's -- indeed it was even closer, 0.102 being just 0.001 away from 0.101. The training time results and the higher initial training loss for models with LoRE on the output head also held up, which was good to see. So at this point it was time to wrap things up -- or was it? While I was doing the second batch of training runs, I started writing up this experiment. As I normally do , I ran a draft past various AI models, and Claude Opus 5.5 on max thinking spotted something. Andrew had written : Keep standard GPT-2 init on the embedding table, but scale the inner linear projection matrices by std = 1.0 / math.sqrt(128). I'd interpreted that "inner" as meaning that the "innermost" matrices in our low rank factors -- the ones that were closest to the Transformer layers -- should be initialised as he said, with values drawn from a normal distribution with a standard deviation of 1 / r , and a mean of zero. So that implied the second matrix in the embeddings, and the first one in the output head: Claude felt that it should be the second low-rank factor in both cases -- that is, we should change It made a very good case for doing things that way, and digging into that is useful because it changes this "smart" initialisation from being a blindly-implemented "secret sauce" into something actually meaningful. Let's imagine that we're not using LoRE, and consider the output head only. It is a PyTorch , which means that: ...the values are initialized from 𝒰 ( − k , k ) , where k = 1 in_features So, we have a matrix, which we can call W , that is full of numbers drawn from that probability distribution -- a uniform (flat) distribution within the range from − k to k . For LoRE, we're replacing W with two separate matrices, which we'll call A and B : Let's say that W is shaped m × n , which means that A is m × r , and B is r × n . We want the combined effect of those two matrices W ′ = A B to start off with random numbers that look like -- in terms of their randomness -- the full matrix W . But what do the numbers actually look like in our "virtual" matrix W ′ with the code above? On the face of it, it sounds unlikely to be anything like the probability distribution for W . After all, W was initialised based on a formula that used in_features . You can see that A will have been initialised using the same distribution, as it has the same number of input features, but the number of input features for B is r , so its random numbers will have been based on that instead. Their combination into W ′ will presumably blend something from each of those two different probability distributions. We can actually work out what that will be. Maths incoming: click here to skip . Per Wikipedia , the variance of the product of two independent random scalar variables (so, not matrices, but we'll come back to that) X and Y is this: ...where σ X 2 is X 's variance, μ X is its mean, and likewise for Y . Now, let's say that the mean is zero for both X and Y (like it is for PyTorch's distribution for the initial weights, 𝒰 ( − k , k ) ). That simplifies the above: How does that apply to our matrices? Well, let's write out how we calculate W ′ by multiplying A and B , assuming that m = 3 , n = 4 , and r = 2 : By normal matrix maths, the value at position 0 , 0 in the result will be the dot product of the zeroth row in the first matrix (taken as a vector) and the zeroth column in the second: We can rewrite that for an arbitrary low rank bottleneck r like this: ...and even more generally, we can define all of the items in W ′ like this: So, every element is the sum of r products, where each product multiplies a number from whatever distribution was used to initialise A 's elements by a number from whatever was used for B 's. Using the notation from above, A 's elements have variance σ A 2 and B 's have variance σ B 2 . So that means that the variance of each component of that sum is σ A 2 · σ B 2 . There are r of them; if you add together independent variables, their variances add, so that means that the variance of the elements of our resulting W ′ is: Now, let's remember that our matrix A actually already was initialised in the way we would have liked our non-LoRE version W to have been done. The distribution of the random initial weights for an is dependent entirely on the number of input features -- and of course A has the same number of input features as W . So how can we make Var ( W ′ ) be σ A 2 ? We need to make σ B 2 = 1 / r , and then the equation above drops out like this: Now, let's look at the code (with Claude's correction in there): We're setting the weights on our B matrix to be drawn from a distribution with a standard deviation of 1 / r . SD is, of course, the square root of the variance (which is why we've been using things like σ B 2 with that squaring in there to represent variance -- σ on its own is used for standard deviation). So the variance of the matrix after we've called that on it will be 1 / r as desired, and W ′ will have random initial weights with the same variance as W would have done if we'd created it directly. That's rather satisfying :-) So that's the output head. How about the input embeddings? They're s, which are initialised somewhat differently: initialized from 𝒩 ( 0 , 1 ) That's a normal distribution, with a mean of 0 and a variance (and thus also a standard deviation) of 1. The maths above applied to any distribution with a mean of zero -- so the smart initialisation should work for those too. Our A matrix would have the correct variance, 1, and a mean of zero, and therefore so would A B , given that (for embeddings) B already had the smart initialisation. So does that mean that our W ′ "virtual" matrix created by multiplying A and B is identical in terms of randomness to the matrix W that it's replacing? Well, not quite. Remember that the output head had numbers drawn from a uniform distribution, and the embeddings from a normal distribution. We're multiplying the A matrices that are the "input" low rank factor matrices by B s that are initialised from a normal distribution. The variance of the A matrices is maintained by the smart initialisation, as we've shown, and the mean (being zero) is going to come through cleanly too. But the shape will change. I did a bit of digging around and it started to feel like a bit of a rabbit hole, but the one thing I was able to be certain of is that a variable drawn from a uniform distribution multiplied by another from a normal distribution gives a result that is neither uniform nor normal, and that normal times normal gives something called a normal product distribution (or also a product-normal distribution), which is also neither uniform nor normal. Remember that each element of our W ′ matrix is defined by this: So each one is a sum of r numbers, each of which is drawn from a product normal distribution (for embeddings) or from whatever the uniform × normal distribution is called. What shape would that be? Again: rabbit hole, but the central limit theorem states ( per Wolfram MathWorld ): the normalized sum of independent random variates with finite variances approaches a normal distribution So, for large r , we can say that the result is going to be close to a normal distribution. 4 What the smart initialisation does is make sure that our "virtual" weights, W ′ = A B , have the same mean and variance as the ones we're trying to approximate, W . But it won't keep the same shape for the distribution that they're drawn from -- certainly not for the output head, which goes from uniform to normal-ish. The embeddings are less impacted (though not completely untouched) because they were normal and are still almost so. Still, it's a neat trick! Was it what Andrew had actually meant? Having convinced myself that the smart initialisation code I'd been using was wrong, I decided to check with Andrew. My first thought was that perhaps he was using weight tying -- remember, with weight tying, the embedding matrix is just re-used in transposed form for the output head. That would mean that the embeddings would be W ′ = A B , and you'd use the smart initialisation on B , but the output head -- being that transpose -- would use W ′ T = B T A T for the same B and A , so the smart initialisation would be the other way around. But as it turned out, it was just a miscommunication . He said that he keeps his weights untied, and had been using the word "inner" in "inner linear projection matrices" to mean something different to what I'd interpreted it as. He confirmed that the real smart init code should always be applied to the second matrix, as the maths (and Claude) suggested. So that was a simple change. You can see the diff here , but in short: Now, the good news (both for my own sanity and my wallet) was that testing this fix was not going to require another four training runs. My training code is essentially deterministic -- no dropout or any other randomness is used after the model is created 5 . Because the smart initialisation code was at the end of the model creation, and smart initialisation of the output head was right at the end, that meant that changing it would not affect any other code that used randomness. So if I did two training runs -- with LoRE switched on for the output head only, and with it switched on for both embeddings and output -- then those would be "compatible" with earlier training runs that had no LoRE at all, and that only had it on the embeddings. You can see the configs here (with no explicit random seed, so they'd use my default of 42): Here are the results; I've put them into a table with their counterparts from the original seed-42 training run: Before we look at the test loss, check out that "Initial training loss" column. The weirdness that I'd noticed earlier, where that value was much worse than uniform, is gone! That actually makes some kind of sense. With the smart initialisation on the wrong part of the low-rank output head, our variance was completely wrong, and so the model was initially producing particularly bad results. 6 So that was promising! But the test loss results were even more interesting. In our original seed-42 results with the incorrect init, the output-head-LoRE-only model got a test loss of 3.737128, but here we got 3.785209. It was actually worse with the corrected smart initialisation! And that carried through to the result for models with LoRE on both input and output; the original model with the incorrect init got 3.671585, and here we got 3.741209 -- an even bigger worsening. Combining those two together, an image I like in terms of the loss landscape is that previously we were starting on a mountain that was near a deep valley -- we started with high loss, but there was a low-loss place nearby. But with the smart initialisation fixed, we were now starting on a hill with a less-deep valley nearby. So what happens if we do one of our test loss delta tables showing how each model performed against the baseline? If we add up the effect of the input-only test loss delta of -0.073 and the output-only delta of 0.186, we get 0.113 -- quite different to the delta of 0.142 that we actually got with them both. That nice "additive" property that we had, where the delta from input LoRE only plus the delta from output LoRE only summed to almost exactly the same as the delta for LoRE on both ends, appears to have gone away :-( Still, an interesting set of results! Let's bring everything together. Here are all of the training runs together in one table. I've removed the columns for the cost, the end training loss, and the training time to keep things manageable, and I've sorted it by the test loss. A few things stand out: So what does that all tell us? The numbers above suggest three things -- with important caveats below: Even more caveated, we might say: Neither the larger of the two apparent improvements from input-only LoRE nor the worsening from output-only were small -- when I was trying various interventions into my GPT-2 models (like removing dropout, scheduling the learning rate, gradient clipping and so on), many of them had a similar or smaller level of effect. Similarly, training a comparable non-LoRE model on double the number of Chinchilla-optimal tokens (ie. on 40 per parameter rather than 20) improved loss by 0.09, while investing the same compute resources to do a Chinchilla-optimal training run on a larger model improved it by roughly 0.13 over the baseline (see this post for the raw numbers). So, is input-only LoRE One Weird Trick To Improve Your Test Loss? Of course not. Are output-only LoRE and both-ends LoRE Bad Things? Not necessarily. The first thing to remember is that this is a really limited set of experiments. Although I wound up spending about US$450 on training runs (I really should be investing in building my machine up as a better training box to save on Lambda costs), I was only able to test: Even in order to say anything definitive about whether LoRE works for this specific architecture, it would be necessary to do tests over a much larger number of random seeds and different lengths (in terms of number of tokens) of training run: To get solid results on whether smart initialisation is a good thing, you'd need to try a number of seeds to see whether the poor performance of the "correct" smart initialisation against the "incorrect" one worked out as a general result. And if it did, why might that be? Beyond that: the apparent improvement in the loss from input-only LoRE is something that came up in these experiments. Maybe it would turn out to be a real thing, maybe not, but the real benefit that Andrew mentioned in his original message was about something quite different: LoRE saves a lot of parameters and only (as he said later) costs a small amount of loss. If you reinvest those saved parameters in a deeper network with more layers, you can hopefully gain back the loss that you, um, lost, and then some -- and wind up with a more capable model. So important further tests (which I think I will do, but later on) would be: It's worth noting that in my Chinchilla experiments, I scaled up the model by 2 . That's about a 41% increase, which is quite close to the 39% we save by using LoRE on both ends. So if the loss improvements carried over, perhaps it might work? On the other hand, those models were also trained on 41% more tokens, so... So there's a huge number of further experiments that would be needed to build out these results -- and that's even before we move beyond this specific architecture. Andrew reports solid results with his setup, and his model Maba does appear to be quite different. But would it extend to a Qwen-style one? Or Mistral? Next, there's what happens with scaling. LoRE is valuable in small models because embeddings and the output head are such a large proportion of the total parameter count. My guess is that it would become increasingly unhelpful as models got larger. To take an extreme case, looking at the Kimi K3 paper , it has a vocab size of 160k, and its hidden dimension (which I'll assume is what the embeddings are using) is 7,168. That means that the embedding layer uses something like 1,146,880,000 parameters, as does the output head, for a total of ~2.3B parameters (assuming they're not using weight tying, which seems likely for a monster like this). It has 104.2B active parameters per token, so if it were to use LoRE you'd save something of the order of 2% of the active parameter count. But for purposes of "reinvesting" in further layers, the real comparison point is the total number of parameters, not the active ones, and with about 2.8T total, the number used for embeddings is less than 0.1%. It doesn't sound like it would help much. Unless, of course, the benefit of the input-only LoRE loss improvement actually turned out to be a real thing, and didn't weaken as the model was scaled... But anyway, that's speculation. I think a good set of experiments for that would be to try the same training runs as before, but for larger models. Maybe just trying GPT-2 medium and large sizes would be a good start? Lots of work. If I had access to Google's resources then I think I'd be kicking off a bunch of training runs. OTOH if I had access to Google's resources I probably wouldn't be blogging about this... I think that there's one thing I'm going to take away from this. LoRE is an interesting intervention -- and I think is well worth trying out on any new model where vocab matrices are a significant fraction of the parameters, just to see if it helps. I'll be doing that in future. And if anyone else out there wants to try LoRE-style training runs, I'd love to hear from you. Any results, positive or negative, would be really interesting. Thanks for reading! And many, many thanks to Andrew for suggesting this as an idea to play around with -- it's been fun :-) This is often written Z = X W T to reflect the fact that weights are generally kept in d out × d in format, at least in PyTorch, but that's really an implementation detail .  ↩ I'll write more about LoRA in the future -- I've been playing with it a bit -- but essentially, what you do is: For each matrix that you want to train (which might not be all of them -- for fine-tuning, people often only train a subset), you add on a low-rank adapter , which is a pair of matrices like our A and B above, and which sits "beside" the existing one, like this: Gradient tracking is enabled on the matrices in the low-rank adapter, and its results are added to the results of the original weights. The idea is that the adapter pair learns to create a "diff" to the results of the original weights, which allows a certain amount of training of that part of your model. And it works surprisingly well. Again: I'll write about this in more depth in the future.  ↩ When I was discussing this with an LLM -- sadly I've forgotten which -- the softmax bottleneck came up here, as it did in later correspondence with Andrew. That's a reference to this paper . I'll need to work through it carefully at some later point, but the relevant point appears to be that the rank (which we can loosely read as how many independent ways the predictions can vary from one context to another) of the values coming out of softmax is limited by the rank of what is going into it. If we force our (in my GPT-2 small case) 768-dimensional embeddings through a 128-dimensional bottleneck, we're reducing their inherent rank, and it's that reduced rank that will drive softmax's, so the results will be worse than what you'd get with the original 768. Seems logical enough. Linkrot-proof reference: Yang, Z., Dai, Z., Salakhutdinov, R., & Cohen, W. W. (2018). Breaking the softmax bottleneck: A high-rank RNN language model. In International Conference on Learning Representations (ICLR) .  ↩ Thanks to Claude Opus 5.5 for pointing me at the central limit theorem. I definitely need to brush up on my statistics, and I'm sure I knew about it at some point back in the 90s...  ↩ Modulo CUDA jitter of various flavours -- though even then, a while back I did two training runs on 8x A100 machines with the same config, months apart, and the Safetensors files were identical bit-for-bit. So I think the effect of that in my setup is minuscule.  ↩ The temptation to dig into the details of why, and exactly what the mistaken smart initialisation did to the variance of the "virtual" weights W ′ , is strong. But I will resist.  ↩ When I ran a draft of this post past my editorial board of LLMs , Grok made the point that perhaps the apparent benefit of having LoRE only on the input embeddings might have been due to that model seeing significantly more tokens per parameter. It's an interesting thought! If we do the maths, the input-only and output-only LoRE models were trained on ~25 tokens per parameter and the both-ends ones were trained on about 33, vs 20 for the non-LoRE ones. However, in my Chinchilla experiments, I only got 0.09 improvement in loss by training on 40 tokens per parameter rather than 20. Now, I'm sure that gain mostly happened early in the run -- overtraining is subject to diminishing returns -- but the result of 0.073 for input-only LoRE would imply that almost all of it happened in the first extra 5 tokens per parameter, which seems dubious. Those are different training runs with different seeds, and were using my JAX code, which has a better baseline loss than my PyTorch code, so we can't compare them directly. But it feels unlikely that the effect would be drastically different. Also, if we use reductio ad absurdum on Grok's logic, it does sound a bit weak -- if we get rid of parameters but train on the same number of tokens, the higher tokens per parameter will not in general counteract the damage from the smaller model. Imagine, for example, removing 32M parameters worth of Transformer layers and training on the Chinchilla-optimal tokens for the size of the model before you did that. I'm not going to run the experiment, but I'd be... somewhat more than surprised if it improved the model.  ↩ A baseline. As I said earlier, the LoRE stuff would have an effect on the randomness used for the initial weights, so I wanted to train one model with LoRE "enabled" but not actually used. Config: , . A model trained with LoRE enabled on both the input embeddings and the output head, but without the smart initialisation of the low-rank matrices. This was simply to confirm to myself that it was required. Config: , . A model with LoRE on both ends, with the smart initialisation -- essentially, what Andrew had described. Config: , . A model with LoRE on the input embeddings only, using smart initialisation if that had turned out to be necessary, and not using it if it had not. I deliberately made the config invalid, using instead of or for the flag, so that I'd be reminded to fill those in later. Config: , . A model with LoRE on the output head only, with smart initialisation the same as with (4). Config: , . What we were seeing was due to luck of the draw with the random initialisation of the weights -- good luck with LoRE, bad luck without. There really was an effect here: input-only LoRE actually improved test loss in the resulting model. A new baseline with no LoRE. Config: , . An input-only LoRE. I wanted to run this one as the first non-baseline one, because if it got worse (higher) loss than the baseline, then it would look rather like the effect I'd previously seen was due to random weight initialisation, so I might want to consider stopping there. Config: , . If I decided to continue, the next one would be LoRE on both the input and output sides. Config: , . Finally, I'd do an output head LoRE only one. Config: , . I changed the config parameter so that instead of just being or , it could be , for the original incorrect interpretation, or for the fixed one. I updated all of the existing training configurations that had set to to be . I added on yet more config for some more training runs to test this, setting to the new option. Both: , . Output-only: , . The input-only LoRE models lead the pack -- though it's a close-run thing, and the gap between and could well be within the noise at 0.013. In that post, I trained three models with different weight initialisation seeds, and got models that differed by up to ~0.017. The two models with the corrected smart initialisation are the worst -- even worse than the model with no extra initialisation, . LoRE can reduce the number of parameters in these models dramatically even if only applied to the input embeddings, and that comes with potentially an improvement in test loss (0.073 in one test, 0.013 in a second). When applied to the output head, it has the same size of reduction in the parameter count, and causes a worsening in test loss (over three tests, 0.115, 0.138 and 0.186). The results with LoRE on both ends seemed to come in somewhere around (in two of the three cases, very close to) the sum of the input-only and output-only deltas, though it doesn't seem to be a strictly additive relationship. The mathematically cleaner implementation of what I've been calling "smart initialisation" actually worked worse than the mistaken version I used originally, though this was only tested with one seed, so the evidence is much weaker. Against my own GPT-2 style model (important differences from the original: no weight tying or bias on the QKV weights, no dropout, and PyTorch default weight initialisation). With two seeds. With two options for smart initialisation, and one of them only with one seed. Over a number of tokens chosen to be Chinchilla-optimal for the baseline non-LoRE model. 7 Maybe the effect was just limited to the seeds I tested? Two is better than one, but three would be better still, four even better, and so on. Maybe the effect disappears in longer training runs? Perhaps LoRE causes some kind of loss in capacity that stops models from improving past some point. Intuitively that could make sense -- if you imagine training as being in some sense compressing information from the training data into the parameters, having fewer parameters is clearly going to limit your capacity in some sense. Isoparameter tests: Input-only LoRE saves 32M parameters, so what happens if you "spend" that on extra layers? Or alternatively make the model wider (in terms of its embedding dimension)? Or some combination of both? Output-only LoRE likewise. And, of course, both input and output. Isocompute tests. Notably, input-only LoRE doesn't save you much compute, as I mentioned earlier -- embedding layers are cheap in terms of compute, if not in terms of parameters. So here perhaps it would be more interesting to see what happens if you did output-only LoRE, but trained the model on more data so that the total training compute budget matched. Would you gain back the lost loss? This is often written Z = X W T to reflect the fact that weights are generally kept in d out × d in format, at least in PyTorch, but that's really an implementation detail .  ↩ I'll write more about LoRA in the future -- I've been playing with it a bit -- but essentially, what you do is: Freeze all of the matrices that make up your parameters (eg. in PyTorch, set to ). For each matrix that you want to train (which might not be all of them -- for fine-tuning, people often only train a subset), you add on a low-rank adapter , which is a pair of matrices like our A and B above, and which sits "beside" the existing one, like this: Gradient tracking is enabled on the matrices in the low-rank adapter, and its results are added to the results of the original weights. When I was discussing this with an LLM -- sadly I've forgotten which -- the softmax bottleneck came up here, as it did in later correspondence with Andrew. That's a reference to this paper . I'll need to work through it carefully at some later point, but the relevant point appears to be that the rank (which we can loosely read as how many independent ways the predictions can vary from one context to another) of the values coming out of softmax is limited by the rank of what is going into it. If we force our (in my GPT-2 small case) 768-dimensional embeddings through a 128-dimensional bottleneck, we're reducing their inherent rank, and it's that reduced rank that will drive softmax's, so the results will be worse than what you'd get with the original 768. Seems logical enough. Linkrot-proof reference: Yang, Z., Dai, Z., Salakhutdinov, R., & Cohen, W. W. (2018). Breaking the softmax bottleneck: A high-rank RNN language model. In International Conference on Learning Representations (ICLR) .  ↩ Thanks to Claude Opus 5.5 for pointing me at the central limit theorem. I definitely need to brush up on my statistics, and I'm sure I knew about it at some point back in the 90s...  ↩ Modulo CUDA jitter of various flavours -- though even then, a while back I did two training runs on 8x A100 machines with the same config, months apart, and the Safetensors files were identical bit-for-bit. So I think the effect of that in my setup is minuscule.  ↩ The temptation to dig into the details of why, and exactly what the mistaken smart initialisation did to the variance of the "virtual" weights W ′ , is strong. But I will resist.  ↩ When I ran a draft of this post past my editorial board of LLMs , Grok made the point that perhaps the apparent benefit of having LoRE only on the input embeddings might have been due to that model seeing significantly more tokens per parameter. It's an interesting thought! If we do the maths, the input-only and output-only LoRE models were trained on ~25 tokens per parameter and the both-ends ones were trained on about 33, vs 20 for the non-LoRE ones. However, in my Chinchilla experiments, I only got 0.09 improvement in loss by training on 40 tokens per parameter rather than 20. Now, I'm sure that gain mostly happened early in the run -- overtraining is subject to diminishing returns -- but the result of 0.073 for input-only LoRE would imply that almost all of it happened in the first extra 5 tokens per parameter, which seems dubious. Those are different training runs with different seeds, and were using my JAX code, which has a better baseline loss than my PyTorch code, so we can't compare them directly. But it feels unlikely that the effect would be drastically different. Also, if we use reductio ad absurdum on Grok's logic, it does sound a bit weak -- if we get rid of parameters but train on the same number of tokens, the higher tokens per parameter will not in general counteract the damage from the smaller model. Imagine, for example, removing 32M parameters worth of Transformer layers and training on the Chinchilla-optimal tokens for the size of the model before you did that. I'm not going to run the experiment, but I'd be... somewhat more than surprised if it improved the model.  ↩

0 views
Armin Ronacher 1 weeks ago

What is Codemode

More than a year ago I wrote a few posts here that recommended people not to load custom tools into their context (or MCP servers) but to just use more scripts. Most importantly I wrote that Code Is All You Need and I wrote about that MCP needs code . With Pi 1.0 we now added MCP support via Codemode which in some ways is a long time coming, but then also maybe somewhat surprising to some. So I want to share some updated thoughts on this blog on what this all means. When a harness like Pi provides tools for an LLM to call, it does so by supplying some tool definitions which then translate into some token structure on the server side. Whether a model is encouraged to call a tool is the result of the reinforcement learning process. Something I wrote about before if you want to learn more. One of the reasons we strongly lean towards CLI and bash is because it allows easy composition of calls, and because the model also learns how the file system works when it’s trained. So when it invokes a tool like the model also learns that after that tool call, there is now a file called in . However bash has one fundamental limitation which is that it can only compose programs that run. And there are some things, which are not programs, but native tools to the LLM and they sort of have to be. The most obvious example here is or . If a multimodal model needs to read an image, it cannot use for that because the harness needs to inject the actual image payload into the protocol of the LLM. Another quite vivid example are sub agents. In order to spawn and orchestrate sub agents, it’s tricky to avoid tools that are provided by the harness. While in theory the agent could provide a CLI tool that talks to the outer harness via environment variables and Unix sockets, it’s a rather crude process. It however has another issue, and that is where the code runs. To better understand that, it’s important to think a bit more about where all the bits and pieces run. There really usually are two different systems involved. The first is the brain, the harness: it runs on one machine. It’s trusted. The second is often the same machine, but it’s really where the tools are executing: the hands. In Pi we now call this the execution environment, but you can think of it as the target of all the operations. Crucially what is important for us, is that there is a dividing line between the harness brain and the target environment that runs bash and executes the tools. And splitting this in half has some really important consequences. For a start it means that they are running on different file systems and they have different levels of trust. If you for instance use a sandboxing solution like Gondolin your bash stuff will be sandboxed just fine, but the harness itself will not be. Which brings us to what Codemode really does: it’s a way for the LLM to express and orchestrate complex operations on the harness side, but not the execution environment side. Codemode runs in the harness, in its own sandbox. In case of Pi it’s running in QuickJS within a WASM runtime with intentional limitations: no network, no file system, no timers, limited RAM. The only way is to call more tools. You could also imagine that Codemode could run Scheme or some other language as well. If you are not familiar with Codemode, it’s basically just a way to issue tool calls from within some language, in our case JavaScript. That allows you to compose those calls without necessarily going through the LLM’s context. Credit for naming goes to our friends at Cloudflare who coined it . For instance if you issue a bash call as a regular tool call in the LLM, then we only throw the trailing 2000 lines into the context and if the agent wants more, it needs to look at the overflow file itself. If however the agent issues that invocation via Codemode, then the Codemode side gets larger outputs sent structurally. Most importantly, because Codemode is JavaScript the agent can express concurrent operations and basic workflows. A common way in which you see agents now use this, is to first probe at 5-10 items from some tool response to see what it looks like, and to then write a Codemode script that processes the next n items. Codemode also allows you to throw state into the transcript! That means that one Codemode invocation can stash away data, that the next call in the session can load again. And remember: this is on the harness host, not the sandbox. In case of Pi, Codemode also allows you to issue calls that naturally do not make any sense in Pi’s traditional interface. For instance if you want to generate images with an image model or you want to classify some text with a one shot classifier model, those Pi APIs are exposed via Codemode, but not via regular tools where they would just waste context. So now that we talked a bunch about it, it’s probably worth being a bit more explicit about it. Let’s walk ourselves through some invocations of Codemode of recent Pi sessions of mine. Note that none of this code is human written. It’s from real sessions of Pi, just re-indented for your viewing pleasure. The agent starts using Codemode automatically either because it’s a task where the model already naturally picks up that tool, or because a user asked it to. Note that Codemode is by default only enabled in Pi when MCP is enabled, but you can turn it on with in the settings. Just ask Pi to enable it for you. Let’s start simple with image generation. Image generation is a feature that Pi supports in the AI SDK core, but it’s not a tool that the agent can use. In the past the only way to use image models has been to write a bespoke extension or to have the agent run node itself and use the internal image APIs. However because we expose quite a few of the internal model APIs within Codemode, it means that the agent can use it: Note that the call to sends the image back as image content to the LLM. On the harness side it feeds it directly into both the agent, as well as onto disk as a temporary artifact in case the agent wants to be able to pass that image back to bash. Similar things apply to classifier models such as Jev . They also do not fit well into the workflows of an agent through the typical tools. But rather than making a bespoke tool available, Codemode just allows the agent to reach into the AI SDK and invoke those directly. Here you can see how Jev is used to mass process GitHub issues for a quick sentiment analysis: Note how in that above example we also call which dumps the result of that execution into the session transcript. A future invocation of Codemode can thus read back that result if it wants to. The here is fine, because Pi limits the total number of concurrent tool executions itself to four and maintains a queue for the rest. A more adventurous example is to use Jev to drive a game engine for debugging purposes: Here it knows about my command and it built itself quickly a minimal harness around it to drive a game loop to assist a user with debugging a problem. Note how it built a 30 step loop in which each step goes back to both the game engine to get a text dump of what’s going on, and then to Jev to determine what to do next: Lastly, Codemode obviously is great for calling MCP servers. And because we do not actually expose any of the MCP tools to the LLM, the agent first uses provided APIs to issue a tool search within Codemode to discover what it might be able to do with the connected servers. This form of progressive discovery makes the whole MCP business work well enough for a lot of use cases today. Here for instance you can see the agent reach for the Sentry MCP straight away, even without discovering the tools, presumably because it has learned during the RL process already about what the Sentry MCP looks like. But it learns from what we inject into the system prompt, that the Sentry server is available to begin with. It’s not completely guessing here. I really don’t want to talk too much about MCP here, but MCP is in fact a protocol that greatly benefits from Codemode. The problem in parts is that MCP in practice often targets harnesses that do not (yet?) use Codemode. But the tide is shifting. In the meantime, a temporary crutch has been to do what Cloudflare did, and do Codemode within the MCP server. But now we have Codemode in Codemode which is pretty bad. It means double JSON escaping, easy for smaller models to get confused by and the inner code cannot call the outer tools. So if you for instance use the Cloudflare MCP servers in Pi, the agent needs to write JavaScript and funnel it through more JavaScript. This is really not optimal, but it’s also understandable that this is happening: So to end things off: how well does Codemode work with MCP today? Well … not amazingly well. That’s because MCP servers are not really targeting harnesses that use Codemode yet (though at this point I think most harnesses support it). For this to work well some recommendations: So where does this leave us? Is this a reversal of what I wrote a year ago where I encouraged CLIs? I don’t think so. In fact, the MCP ecosystem from my perspective picked up on exactly what we pointed out a year ago works: code. But Codemode goes beyond MCP in that it can act as a capable mechanism within the harness to express more freedom for the agent. There are however also some things that we still need to figure out. For one, durability with Codemode is trickier. We might have to adopt some ideas from durable workflow engines here to snapshot invocations. Or maybe, something like Starlark is a better composition language than JavaScript given its deterministic nature. Images, binary data and just the inability of this pattern to work with smaller models is also something that needs to be fleshed out. So it’s for sure not a perfect solution yet, but it’s quite a useful pattern that I expect us to leverage more. Structured content: Codemode wants calls to return some nicely formatted JSON. So that needs to come back from the server, and many don’t do that yet. The system in MCP is great for that. Consistent results: an interesting failure case is when an MCP server does not return consistent data. For instance because it tries to token optimize things depending on how many items are in the result set. This can cause an initial probe with 5 items to succeed, but then fail when the server returns the maximum batch size. Large binary data: today MCP does not yet support large binary data so quite a few use cases that are really interesting do not work well at all yet. You end up with all kinds of weird workarounds such as pre-signed URLs to allow file uploads then to happen through non MCP channels. Composable tool search: the MCP server might know better than the MCP client which tool is appropriate for a task. But there is no good mechanism today that allows a harness to fan out tool searches across multiple MCP servers. It’s all emergent behavior and it does not scale well to multiple active servers.

0 views
matduggan.com 1 weeks ago

Make tmux the OS

I recently watched the talk by Scott Jenson titled "Are we really going to use the same Desktop UX forever?" https://www.youtube.com/watch?v=V7AfAcQwLW0&t=445s . He's a great presenter, really articulate and concise. The kind of speaker that you'd gladly listen to for 3+ hours if given the chance. Which for a talk about window management is quite the compliment. The overall point of the talk was "Apple and Microsoft aren't going to innovate anymore in the desktop OS space, so it is up to us all to decide what the desktop of the future is going to look like". I would argue that the actual situation to more nuanced than that, they're trying ideas they are just extremely conservative. As Jenson points out, a lot of what we treat as "how computers work" was a workaround for hardware we stopped having years ago. So why are we still accepting those tradeoffs? To be clear, I'm not a UI/UX designer. I don't know what I'm doing and I lack the skill to make this. I'm making this mostly as a thought exercise and hopefully a prompt to get other people to think about this same problem. I have, however, suffered through others bad choices, which turns out to be most of the qualifications required. TL;DR: The thing I want is "tmux is the OS", but I want tmux where a normal person could use it. I use it all the time, it's great, but is there a way to take the amazing experience of a scrollable, session-persisted, detachable, task-oriented system to normal people? I think you can break the problem down to the following components: My screen real estate is constantly changing and I have a lot more of it. I am going from my laptop screen to my desktop monitor back to the laptop screen about 6 times a day. When I'm on my big monitor or multiple monitors, overlapping windows don't make sense because I have too much space as it is. Different people use windows totally differently. We have three distinct groups of people that we are trying to design around, while most modern OS optimizes only for the first use case. Browsers are mini operating systems. In 2026 everybody quietly agrees that most of your software runs in a browser tab. These web applications will cover a wide variety of use-cases that have historically been owned by local applications. My desktop OS treats a browser just like a normal application, when in fact it is closer to a virtualized OS running inside of my host OS. The concept of "filesystem" is an increasingly weak concept. My notes in Apple Notes belong to Apple Notes, not my filesystem. My texts live in iMessage's database, not my Documents folder. Teams and Slack content lives inside those applications unless I manually "bring it out," and when I do, I'm making a copy. Firefox will happily show me a PDF, but the PDF doesn't live with macOS unless I take an action to make it so. So I'm not the first person to see this problem. Some of it got solved, but in a different direction than I want. Some of it got solved, but not for normal people. I started with the document that I kept seeing everyone else cite to. Henderson, D. Austin and Stuart K. Card. “Rooms: the use of multiple virtual workspaces to reduce space contention in a window-based graphical user interface.” ACM Transactions on Graphics (TOG) 5 (1986): 211 - 243. Interesting that even back in the 80s there was a pretty clear understanding that the current system for managing windows wasn't very good. The design they were talking about looked something like this, which is pretty advanced compared to where we are. Henderson and Card measured window use the way operating systems people measured memory. The screen is RAM. A closed window is a page swapped out to disk. And windows, like memory pages, don't get touched at random: you sit inside a small set of them, two to ten, and that set is the task. Programs spend about 98% of their time inside one of these sets, and roughly half the cost of running happens during the 2% of time spent switching between them. Rooms' whole design was preloading the next set before you ask. They described this as reducing "knowledge faulting in the user," which is the best phrase in the literature and I intend to use it until someone stops me. Just try it out in a corporate meeting: "we need to reduce knowledge faulting in the user". One of the more interesting side papers I found was "No Task Left Behind? Examining the Nature of Fragmented Work". Link . Mostly because it confirmed something I've long suspected, which is the single task for a long time focus on modern widowing systems isn't actually how people work. The title of the companion paper is a real participant quote: "Constant, constant, multi-tasking craziness." People were juggling around ten "working spheres" a day, minutes at a time. The closest to what I wanted is from the paper WindowScape: A Task Oriented Window Manager. Link . WindowScape dropped explicit grouping entirely so now every time you changed the arrangement, it took a photograph, and you went back into photographs instead of filling containers. I love their one-line diagnosis of every system before them: "requiring windows to be in a single group forces users to decide ahead of time where a new window belongs." The photograph metaphor also cracks a problem I'll get to with browser tabs: one window can appear in many photos, because, as they put it, "people understand that there can be several photos of an object with there being only one underlying object." The catch is that the photos evaporated. That's the gap that I think you'd want to solve. For the first problem, we have more or less already solved for overlapping windows. A tiling window manager ensures that you are maximizing your screen real estate in such a way that you can switch between different layouts with no need to manually modify the windows. There are basically 2 problems with tiling window managers as they exist now. Browsers as mini-operating systems: people have tried to solve this, but in the wrong direction. They made the browser more of an operating system instead of making its contents first-class citizens of the one you already have. They also basically "took over" the concept of windows from the OS. The diagrams above and a good write-up of Arc is available here: https://blakecrosley.com/guides/design/arc . All of this makes sense from the perspective of "the browser is now the operating system", but I think this is a fundamentally flawed idea. If a web application is operating as an application, it should be its own window. If it is complimentary to another application, it should be a window associated with another application and be allowed to contain many tabs, to reflect the idea of the browser as the portal for all research and lookup. I was shocked to go through the historical progress of people trying to solve this problem. I'm not the first person to try to solve this problem, I might not even be the 10,000th person. So clearly we understand there's a problem. Why haven't these taken off like wildfire? I think there's a couple of different problems here. Timeline needed apps to opt in to reporting activity, and most never did. It also logged everything, which read as creepy (which is a problem that my idea would have too), and the UI surfaced Edge features nobody wanted, so it felt like a browser push more than a feature. Sets died somewhere between internal strategy and app-compatibility chaos where the interesting question is why nobody demanded it loudly enough to save it. Stage Manager tried to be universal and pleased nobody. KDE Activities is opt-in, and opt-in means the people who need it most never configure it. The pattern in the graveyard: the ideas aren't wrong, they're just not defaults, or they're not done at the OS level where they can see all apps. Everything I want has to be structural. Filesystems as a weak abstraction. I have opened, downloaded and created dozens of documents since September 23rd (I'm writing this on September 28th). I don't have a single fucking clue how MacOS populates this window. Maybe its broken. Maybe its working as intended through some criteria I don't understand. Maybe it's personal. But the idea is here. The ideal would be "across my entire computer and applications, what are the recent files I have interacted with" and expand that out to include emails and Teams/Slacks and everything. I should be able to see everything going on with my machine, search through it and not care if its stored inside of Slack or iCloud or whatever. Single pane of glass. Microsoft tried it, people didn't like it, but I think the concept makes sense. So what has changed? Why might we be able to crack this problem now when before it was too complicated? This is where I think a local LLM might make sense, if you can figure out a way to do it where it doesn't cause more problems than it solves. So what are we looking at here. The concept would be organizing it around the idea of tasks. When you define a task, this allows you to organize all the windows together, including specific browser tabs detached and associated with the task, not with the concept of "browser". You still get get the flexibility of defining glanceables that exist outside of the strict tiling view. The basic window flow would look like this: The desktop is one infinite canvas; each physical display is a viewport onto it. You would end up with the following "transition contract" to handle my initial problem of "what about many monitors/new monitors". The OS finally learns which monitor is focal. The display receiving keystrokes ~90% of the time is focal; windows that stay visible but rarely receive focus are glanceables. That's inferable from focus telemetry and requires no eye tracking or config. And when the laptop screen is small, glanceables demote to a thin strip rather than full tiles which is, note, exactly what tmux already does. One substrate that covers our three user types. Remember the maximizers, near-maximizers, and coordinators? On the canvas they're just column counts. A maximizer is one full-width column. A near-maximizer is one column plus the glance strip. A coordinator is N columns. Nobody gets forced into anything. This is why the design can be a default where KDE Activities was a preference. It's because the substrate doesn't impose a style, it just stops punishing whichever style you already have. Privacy becomes a first-class flag, driven by machine state. Private is a property of a window or tab, not a region of the screen. Private content renders occluded unless the machine can vouch for safety. The rule I landed on, at the cost of my favorite bad idea (story below): derive privacy state from machine-observable facts like connected displays, or active capture sessions and never from inferred human states like attention or idleness. Connect to a novel display and it gets a "ready to present" screen by default. You explicitly aim it: this task, this window, or extend the canvas. Known displays skip the dance. Screen sharing is the same event with no cable. You don't need the model to have discipline if the door won't open, and you don't need the user to have memory if the default can't leak. None of this is exotic. Apple's Keynote's presenter view has been shipping for twenty years plus with slides on the projector, notes on your screen. Apple never promoted the pattern to the OS even though it makes obvious sense. I'm asking for presenter view as a desktop primitive. Browser tabs detach into real windows and join whatever task they belong to. The "browser" stops being a place windows live. This all makes sense until you get to "how do you organize this stuff around tasks". I think for more expert users they're going to be able to do this stuff, but part of the problem is that we don't want them to ever have to drop back down into a configuration file. A mouse and dragging stuff around is too clunky for how this would work. Ideally I should be able to ask something that has access to what information is contained inside of each one of these browser tabs and window and help me organize it in some logical flow. That's where the local LLM comes in. I'm calling it a "quake overlay" because I'm old and the idea of a text input that drops in from the top with a universal shortcut has always been the quake terminal dropdown to me. Younger people know the same pattern from the Discord overlay, which I have decided not to be mad about. However in the previous diagram its shown as a more friendly "Ask" box. But the basic flow would be that you ask the LLM to assist you with organizing, it would show you what it's thinking by querying the information through a constrained MCP server with a set verb list and then gives you a preview of what the layout is going to look like. The model never touches the window manager directly. It proposes and you press y. People rarely pick up completely novel tasks: a graphic designer spends their life in "open files from network storage, make changes, save output, paste into chat, repeat." People rarely sit down at novel display contexts: laptop-only, the desk, the conference room. People rarely attach novel displays. Novelty is rare everywhere, so the expensive one-time machinery of classify, propose, preview, confirm only runs rarely, and everything in between is deterministic replay. You organize a limited series of tasks once and reuse them forever. Check the tickets, open Vim and a browser, work the ticket, write the update, open the PR, post it for review, next ticket. You could make the config a nightmare of JSON and stop caring, because the only intended reader is a model that doesn't mind. The config file stops being an interface. The biggest issue here would be spatial memory. People understand where things are in relationship to each other on their computer and they don't like it if they are changed. Think of it like "if I came and messed up your physical desk by moving stuff around and adjusting your chair". So I suspect you would need to enforce a strict "append, don't replace" model. There's a name for this actually. Kirsh and Maglio call it epistemic action, where Tetris players rotate pieces more than the game requires, because rotating is how they think about the piece. Link Your window layout is thought in progress. A helper that "optimizes" it mid-thought is interrupting you. This is a problem I encountered with just trying this idea out though. Modern LLMs are actually not very good at "touch this stuff, never touch that stuff". So unclear if this is a realistic idea or not. One boring fix: the LLM proposes, dumb deterministic code executes, and the executor refuses to touch anything pinned. You don't need the model to have discipline if the door won't open. You'd still need a high level UI element for users and this is where I think the more inclusive concept of filesystem could come in. Tasks become roughly comparable to Directories now. But everything flows from the initial high level concept of "Tasks" and then flows out to individual things. One application is never the task. It's "some chat window, a browser, maybe Preview." Can we try to build it? So my hope was that here I would be able to post a Linux desktop running an example. But as it turns out (unsurprisingly) it is insanely complicated to make something like this run at all. Some of the underlying ideas work reasonably well (global terminal shortcut dropdown from the top, scroll-able tiles), but it's still pretty clunky. I'm gonna keep working on it and see if I can get something as a demo running, so if you are interested either add me to an RSS reader or just....wait I guess. In the interest of full disclosure I think this is gonna take many weekends of work to even get a functional demo running. So I'll do my best, but be patient. So in attempting to get this up and running in a Linux VM, I immediately ran into serious logical issues with my design. I think failures are often more interesting to read about than successes, so let's talk about them. I don't know if this underlying idea is a good idea, but it is liberating to stop waiting for MacOS and Windows to do something better or interesting in this space and decide to try it yourself. The graveyard up there is full of good ideas that died of distribution. We're overdue for a radical experiment that ships as a default. The most eye-opening part of this entire experience was how much the original overlapping window design was a hack to get around fundamental hardware limitations and how soon after it was launched did people clearly see the problems. I'm surprised how often that happens in technology, where you see a problem, search for academic papers about the problem and find just a massive wealth of information from people saying "oh yeah this is 100% a problem and one we should solve soon". Maybe this post inspires someone smarter than me to solve it finally. First, one big monitor is a a different experience entirely compared to 2 smaller monitors. I don't use the two monitors as one large unbroken screen, I tend to use one of them for "less important static content" like a ToDo list or my work chat tool and my email and then the "primary" monitor for my terminal/tmux/browser which is how I do all my actual work. But windows "belong" to one or they "belong" to another. Grudin found in 2001 that people use a second monitor exactly this way, where focal work on one, peripheral glances on the other. Twenty-five years ago. The OS still doesn't know which monitor is doing one role and which the other. And when I pull the laptop out of the dock, everything collapses onto one screen and I manually drag and resize windows to claw back enough real estate to keep working. I don't know how much time this actually wastes for people, but it feels like it wastes a bunch. The OS treats a display change as a surprise, instead of as a scheduled event that happens six times a day. Some people are window maximizers, where every window takes up most of the screen and then they use Command + Tab or the Dock or something else to switch between the bigger windows. The value of more screen real estate is mostly that this one big window can be even bigger. Some people are near maximizers, where one window takes up almost all of the screen real estate, but they'll have one or more smaller windows where they glance at things like status or chat or whatever. Finally there are people who carefully coordinate all of the windows on their screen. Almost everyone has "private windows" and "public windows". You are fine with your public windows being lined up and persisting, but you want to hide specific information in other windows from people walking by. The people I complain about are also the people I present quarterly numbers to. I never remember that when I connect my laptop to a display they're going to see my entire screen. I assumed this was just me with private vs public. But it was measured in 2004, in "Revisiting Display Space Management: Understanding Current Practice to Inform Next-generation Design." Same three types, same public/private split, twenty years ago. See how absolutely none of this is a new idea? Link . Tabs inside of a browser and windows of that browser contain the same level of complexity as my other applications. Tabs are associated with streams of work alongside my conventional applications. I'm writing Terraform in Vim in my terminal while referencing the Terraform docs for that provider. But the relationship between tabs and work is messy: the same docs tab pulls duty while I write the code and again while I write the ticket update. Whether a tab should be allowed to belong to two tasks at once, or whether something cheaper is going on, is the exact spot where I break with the research. I'll come back to it once you've met WindowScape. Now a lot of engineers are going to read that and think "well you cannot force all applications to use the same storage system for all of their files, are you a lunatic?!?". I'm not suggesting that, in fact the weak filesystem might be a perk. There is a CHI paper from 2004 called, and I am not making this up, "Stuff goes into the computer and doesn't come out." That was 2004 and we have the exact same problem with no real solution. Link . Here's why this is a windowing problem. Since Windows 95 and System 7 (which is basically as old as my memory goes back to), the machine has worked as a chain: something writes a file, the user opens an app on it, the app writes it back, the file gets sent to someone else, repeat. Every link in that chain assumed the file lived somewhere the OS could see. Every link is now broken in all the modern OS. They're way too hard to use. Like an order of magnitude too hard for normal people to use. Basically if you need to start a sentence with "just open up the configuration file" shut it down the thing is over. The average tiling window manager tutorial asks you to clone a repository before it asks you to open a window. We need a scrollable tiling window manager. So basically I should be able to set up specific window configurations over here, leave it alone, then scroll to the right and do something else different, like shoving the mess into the back of a drawer. It should be infinite space to work without needing to subdivide the windows into different macOS Spaces. The scrolling also solves the private vs public window problem. I put my private stuff on the far left and then my public stuff on the right. If I want to look at the private stuff, scroll to the left as far as it will go. Thankfully this already exists with Niri: https://github.com/niri-wm/niri . It just has to be easier to use. But the tough design elements more or less already work. The best two examples of this are the Arc browser and Chrome OS. But I think Arc is actually the more interesting of the two experiments. Their first innovation was to break the idea of tabs at the top and instead move it to be a sidebar model. Windows Timeline (2017–2019): All of your activity across all of your apps sorted and organized for you. Windows Sets (2018–19, canceled): apps and webpages in shared tabbed sets. Note what this actually was: the operating system grouping apps and web content into tasks, which makes it the closest thing anyone has ever shipped to what I'm about to propose. macOS Stage Manager (2022): automatic task-based window grouping, widely ignored. The reason I think Stage Manager didn't scratch this itch for people is that its super designed around the iPad style flow of "there is one thing you are using at a time and you need to be able to quickly switch between them". On iPad the model is "one thing at a time, switch fast." On desktop, two or three things have to work together . It compromised toward the iPad and hit for neither. KDE Activities : Everything I'm writing here is old news for KDE. They've been doing task-scoped desktop state for over a decade. Honestly this does most of what I'm writing about, it's just hyper manual and requires you to manage and set it all up. Also I love the KDE design docs, amazing stuff, worth reading. https://community.kde.org/Get_Involved/design PWA install: already gives web apps their own window and no browser chrome, which is clunky but at least makes them "real" applications. Operating systems have tried to solve this, but because there's no requirement that you use the user OS filesystem to store documents, there's no consistency. MacOS has recent files, but "recent" doesn't mean anything (these are not the most recent files I have downloaded to my computer). As far as I can tell this functionality is completely broken or implemented in a way that makes zero sense to me. Never Change items: horizontal order of windows, scroll position, input focus, task membership. Reflows Deterministically: column widths, like a responsive layout. When the shelf narrows, the books stay in order, the rightmost ones fall off the edge of the viewport into the scroll region Is state, replayed on redock : viewport aims. Two monitors means two viewports aimed at two regions with one at your focal task, one at the glanceables region. Undock: second viewport disappears; its region is one scroll gesture away.Redock: it re-aims where it was. The privacy-minded design runs on surveillance-shaped data. There's an irony I sat with for a while. The design promises the OS will finally know which monitor is focal which is the thing Grudin measured twenty-five years ago and the mechanism is input-focus telemetry. Watch which display gets the keystrokes. Watch which windows never receive focus. Store that over time. The privacy-first window manager wants to know more about what you're doing than your current OS does. Keep it local, keep it ephemeral but it's still a lot of behavioral data to drive this system. Just the observation that every mechanism in this post is hungry for data that has historically derailed ideas like this. My favorite privacy feature was a joke. The original plan was elegant: private stuff far left, public to the right, and when you go idle the viewport drifts back to public. Like a friend changing the subject for you. Then I ran it, and the first thing I learned is that reading is an idle state. You stop typing to read the thing, and the system starts scrolling the thing away from you. Meanwhile, a person walking by sees everything, because a "private" window on a wide monitor isn't hidden it's just further left. The feature hides content from its owner and shows it to the threat. Privacy by occlusion needs to know when a threat exists, and the threat is a pedestrian, which is undetectable. Nothing in this design ever ages out. The canvas is infinite, the rule is append-only, spatial memory is sacred so the mess grows monotonically. I have solved the electronic messy desk by buying it an infinite desk. When I first read WindowScape, I called their evaporating photographs "the gap to fix." I've changed my mind because evaporation was quietly doing the job of a garbage collector. Something needs to go dormant without moving but deciding what goes dormant is a reaping decision, and reaping is exactly what append-only forbids. It didn't take long for the infinite desktop to feel overwhelming when I tried it.

0 views
Jason Scheirer 1 weeks ago

Oh, Goddammit: I'm Back on C++ in 2026

The title for my autobiography would probably be I Thought I Was a Child Prodigy But I Am Actually a Late Bloomer . Going from being the youngest guy in the room to the oldest guy in the room happens suddenly, surprisingly, and permanently. As such, the programming languages and technologies I learned long ago are esoteric knowledge but the systems they run on still exist. Everything’s moved up a level of abstraction or six and there’s a lot of low-hanging value in plain old systems programming again. So for the first time since 2014 or so I found myself writing C++ starting March 2026 and carrying over to today in late 2026, both dates in 2026. 2026. From the start, quite simply: Windows APIs. Relatively obscure ones. They come with C++ headers and opaque files and COM interfaces that fall well outside of the coverage footprint of . I’m writing RDP plugins , which with some gymnastics I could do in Rust but it would be a lot more work to do the cooler thing. It’s less friction to just say “okay” and do it the way they tell you to do it. Spend those innovation tokens on cooler shit than this. And I’m doing Citrix channels , which are straight up C++ or Go Fuck Yourself (I believe the SDK docs explicitly use this language). Also, there are old features that aren’t exposed to moderns stacks that give us easy wins that exist on users’ systems without additional upgrades or installs. The RDP client bridge binary I wrote is under 50k. It builds in 3 seconds. It runs on every Windows machine. Hot fuck on balls, that beats the 50 minute Typescript BDSM sessions I engage with on CI at work. After the C++ APIs on Windows, doing systems programming at the lowest common denominator let us bend the computer to our own will with less supply chain debt. We are superpowered by C++ in a world where nobody knows C/C++ anymore. I’m writing little tiny bits of NodeJS C++ libraries to integrate new features Electron lacks, and this has worked well. I can add things Electron Just Doesn’t Have. The process is a lot quicker than asking someone on the Electron project to write it for me, or writing it myself and spending my nights and weekends shepherding the itch-scratch PR through the bureaucracy of Electron. I can add value now, goddammit and make our Electron app something that should be an Electron app, because it does actual desktop shit . Here are some good things about C++: LLMs I guess. Fear? Nothing good or defensible as a reason. “Oh it’s dangerous” DRIVING IS DANGEROUS. EATING FOOD IS DANGEROUS. GOING OUT IN PUBLIC IS DANGEROUS. LIFE IS DANGER. You can easily link in legacy C and C++ code There is a lot of C and C++ code you can integrate quickly and well. I chose miniaudio as a lowest common denominator for audio processing (and miniaudio is not ’legacy’, it is a modern project on a conservative stack). Cross platform means it took 2 hours to get a working Linux build out of the code I had originally written for Windows. Notion on Linux works better than Electron would have done the job on Linux because I accidentally got cross-platform by default by writing my code like this. It’s easy to write node.js bridges in it: I even use Objective-C++ for my macOS integrations “Modern” C++ has a lot of features that make it less awful to use – I’m pinned on C++20 and and gives me structural pattern matching. is one of the cleanest JSON libraries I have used in any language and it’s just a header . Compile times are blink-of-an-eye fast. Build stacks are just there on every system. It’s fun to spite people who uncritically adopt shiny things like Rust.

0 views
Armin Ronacher 2 weeks ago

Deser: Rethinking Rust Serialization

Serde is an amazing serialization library for Rust and it has been a huge reason why I felt productive with it for years. However already while at Sentry I got quite frustrated with some of the limitations with it but actually replacing Serde is tricky because of the might that it has in the ecosystem. Also because it’s quite hard to actually do better without also making some potentially painful compromises. Here are three examples of Serde corner cases that show poor interactions of Serde features or unexpected limitations: An internally tagged enum, with ‘s feature turned on: Serde’s data model has no place for arbitrary precision numbers, so uses in-band signalling with a map with a magic key. The enum has to buffer the fields until it has seen the tag, and the buffer does not know about the magic key. Because Cargo features are unified, it’s enough for any crate in your dependency graph to turn the feature on. on its own parses just fine. JSON keys are always strings, and only turns them into integers if the type asks for one. However once buffers the value, is just a string. The error also points at the end of the document rather than at the key. A function cannot be passed as a type parameter, so there is no way to apply to the inside of an , a or a map. You write another function for every wrapper, and once you have the field is no longer optional unless you also remember to add . None of these are bugs that are easy to fix in Serde. They fall out of its design, and that design is protected by Serde’s stability guarantees. Back in 2022 I started an experiment called Deser . It’s a serialization library for Rust that takes the user experience of Serde and puts it on top of a completely different architecture inspired by miniserde . I never really finished it and it sat around for a few years. I picked it back up, and it has now reached a point where I think it’s worth looking at. Even just to inspire others to see if they want to explore the space. The name is Serde with its two halves swapped. Deser is Serde but the other way around. In Serde, a type drives the deserialization process: a impl asks the deserializer for the kind of value it expects, the format calls back into a visitor. Every nested value is handled by recursion which makes Serde deserialization inherently grow the stack with each level of nesting. Deser on the other hand turns this around and the format tells the type of the next value and pushes events into a sink. When a sink hits the start of a nested value, it doesn’t call into it but hands back a new sink to a driver, which keeps all state on the heap (in fact, in an arena). On the way out, emitters return their nested values instead of recursing into them. That also means that Deser cannot support formats like protobuf that are not self describing. They are in fact quite intentionally left out of the design entirely. Which is one way to say: if you want to “fix” Serde, you need to make some other compromises. Most of the reasons for Deser’s ideas go back to Sentry Relay , which processes enormous amounts of untrusted JSON. Over the years when I was at Sentry we ran into the same set of problems again and again, and many of them are not really bugs in Serde but consequences of its design. Serde’s stability guarantees mean that a lot of them cannot be fixed without breaking every format and every hand written implementation. Most of these problems come from three decisions: One set of traits for all formats. Serde serves both self describing formats (JSON, YAML, TOML, …) and formats where the reader has to know the type upfront (postcard, bincode, protobuf, …). That is incredibly useful, but it means that some features only work with some formats, and you find out at runtime. In case of Serde it also has some odd wrinkles where a derived struct quietly accepts an array in place of an object in JSON for instance. A fixed data model that loses information when buffering. Internally tagged enums, untagged enums and need to buffer values before they know what to do with them. The buffer can’t hold everything the format knew, errors lose their location and extensions to the ecosystem rely on in-band signalling to express things such as arbitrary precision numbers. Recursion on the call stack. Every level of nesting uses stack space. Formats protect against this with a recursion limit, but the moment you go through a code path that doesn’t have one (writing, dynamic values), deeply nested data can take down your process. It also means that a deserialization cannot be paused while you wait for more input. Many of the corresponding Serde issues have been open for years, and I wrote about abusing Serde before. People have tried different angles on this over the years. Some went minimal and dropped most features to get fast compiles and no recursion. dtolnay’s own miniserde is the best example of that, and deser’s trait design was originally modelled after it. Other recent attempts went for runtime reflection, or for a new data model with a focus on binary formats. If you want to read up on all of the collected challenges with Serde’s design, I maintain a lengthy list here . First of all I don’t think it’s likely that one can replace Serde. The orphan rule entrenches Serde incredibly well in the ecosystem. But some things are within the reach of a crate author’s control. In case of Deser it’s completeness. Deser today implements all important self describing formats from YAML, JSON, TOML, CBOR, JSON5 and the likes, but also XML and plist to really close the gap. XML in particular is something Serde has declined to support, and it shows (more on that below). At the very least format support should not be the reason not to use Deser. The second problem usually is that actually solving Serde’s issues comes at a significant cost in compile time and/or runtime performance. Deser is no different. While Deser’s compile times are a bit better than Serde’s, the binary bloat is quite a bit worse and the runtime performance is mixed. It’s roughly comparable if you look at the numbers but depending on the format structure you are losing significantly from some of the tradeoffs. That said, it’s now in a state where it’s at least in principle a drop-in replacement where the tradeoffs might work well for users. Deser does not try to be significantly different than Serde on the surface level. For most uses you derive and and then start using it with your format implementing crate of choice. Most attributes are very similar, though they are taking Rust expressions instead of strings. The difference in the design would become more apparent if you implement a serializer or deserializer yourself. Instead of visitors that call into each other recursively, deserializing a type creates a sink which receives events that are directly emitted by the parser, and serializing produces emitters that hand out values. Nested sinks and emitters are handed back to a driver, which keeps them on the heap. This design, which is entirely stolen from miniserde, gives some interesting consequences: On top of that are a lot of things that I just wanted to have: Here is a small configuration type that shows a few of these together: Adapters are types, so can go inside a , and inside a inside an . Validators are adapters too, so decodes the keys and then checks that there is at least one. The catch-all variant keeps the tag and a recording of everything else in case someone wants to process it later. Errors are something I care a lot about, so here is what happens when a value is wrong: Note that here the tag of the internally tagged enum comes last which means that the values have to be buffered until the tag is known. In Serde this is tricky and we would lose the location if we used some tricks to add it. With Deser however, with the path layer enabled Deser you where in the structure the problem is: Deser really wants to be extensible, and XML is a more extreme example of the differences between Deser and Serde. Here is an Atom entry that mixes in Dublin Core for the authors: XML uses namespaces which means that names need to be matched by their namespace, not by the prefix the document happens to use. Here the document says and the type says . is just the string , which works because attributes are expressions. The two creators are collected into one even though there is another element between them, and the text of goes straight into a datetime. Because the enum is untagged, the entry has to be buffered before a variant is picked, and deser’s buffer keeps both creators. So the result is an with John and Jane. quick-xml, the most popular XML crate for Serde, drops the prefixes and ignores namespaces entirely, so a from some other namespace is happily accepted as the title of the entry. The split list part though is considerably worse. A plain fails with a duplicate field error for , unless you turn on the feature (which, remember, is a global additive flag that any crate could set). That feature makes quick-xml read ahead to the end of the element and buffer everything in between, without a limit unless you set one. But the feature only helps when quick-xml is hooked up to the struct directly and no buffering is taking place. Wrap the struct in the untagged enum and Serde buffers the entry itself. Read from that buffer, sees twice and fails again. The fallback is a map, which keeps only the last , and there is no error. With or without the feature you get this: Notice how John is gone. Format specific extension types such as TOML datetimes are another case. TOML has them natively, Serde’s data model does not, so the crate passes them on as a map with a magic key. In Deser a datetime is an extension value, which formats that know it keep and all others write as a string: The same with gives you , and reading the value into a fails outright with . So now that you know Deser is at least in theory cool, at what cost? It is not free. The design relies on dynamic dispatch and on sinks and emitters that live on the heap, and that has considerable runtime overhead. In my own measurements for JSON, Deser reads somewhere between 33% faster and 60% slower than on the data. On average it’s about 10% slower for reading. Writes are between three times as fast and 70% slower and a wash on average. For YAML and TOML it’s noticeably faster than the Serde based crates, but that is more about the format implementations than the architecture. Compile times slightly are better, but not dramatically so. Because it doesn’t monomorphize everything, release builds of derived code are about 2.3 times as fast as with Serde and that get a tiny bit better in practice for your own code as less recompilation is necessary. To make Deser’s design work at all, it also uses internally. Most of this is to keep the chain of borrowed sinks on the heap. I feel like this is fine in the days of Miri and agents, but I know it makes some folks uneasy. And well, the biggest cost is that it’s just not Serde. Quite a lot actually which might be surprising. In addition to the core there is support for derive . It supports all flavorts of JSON you can think of: JSON , JSONC , JSON5 and HJSON . (Fun fact here: they are all generated out of one shared parser template ) For binary handling it supports CBOR and MessagePack . Additionally it does YAML 1.1 and 1.2, TOML , XML and all three flavors of Apple’s plist as well as CSV/TSV , urlencoded data and environment variables . For more crazy contraptions you can attach path info or capture location data as well as support for debug printing . You can perform validation as you parse, opt into different binary encodings in addition to base64, you can bridge to serde or capture dynamic values , transcode between formats or hook it up with tokio . For documentation see docs.rs/deser and the code itself is on GitHub alongside many examples . One set of traits for all formats. Serde serves both self describing formats (JSON, YAML, TOML, …) and formats where the reader has to know the type upfront (postcard, bincode, protobuf, …). That is incredibly useful, but it means that some features only work with some formats, and you find out at runtime. In case of Serde it also has some odd wrinkles where a derived struct quietly accepts an array in place of an object in JSON for instance. A fixed data model that loses information when buffering. Internally tagged enums, untagged enums and need to buffer values before they know what to do with them. The buffer can’t hold everything the format knew, errors lose their location and extensions to the ecosystem rely on in-band signalling to express things such as arbitrary precision numbers. Recursion on the call stack. Every level of nesting uses stack space. Formats protect against this with a recursion limit, but the moment you go through a code path that doesn’t have one (writing, dynamic values), deeply nested data can take down your process. It also means that a deserialization cannot be paused while you wait for more input. No stack overflows. You can arbitrarily nest structures without issues. For untrusted input you set limits with a layer, and you pick the number that you are comfortable with, which is independent of your stack space. Suspendable. Because the state lives in the driver, a deserialization can be fed input as it arrives. It’s also , so it can move between threads while you wait on IO which makes it much nicer to use with tokio. Formats like JSON, CBOR and MessagePack can be parsed as a stream if you so desire. An extensible data model. The core data model is small and made of atoms, maps and sequences. For all else, there are extension values ( , , etc.) that also all carry a fallback for formats that don’t understand them. Unlike Serde this means it does not rely on in-band signalling of objects with magic keys to smuggle values through. Lossless buffering. When a value has to be buffered (for instance because the tag of an internally tagged enum comes last), Deser records the events together with everything the format knew about them. Protocol specific extension types or error locations all survive. Layers are a middleware system that sit between the format and your types and can track things like paths, enforce safety limits, rename keys or redact values without having to touch specific code paths. Native flattening that doesn’t buffer at all. Allow enum tags to be of any type, not just strings Adapters that compose ( ) Enabling validation as an adapter derive attributes that are real Rust expressions instead of strings bytes as a core functionality in the data model duplicate keys rejected by default and errors that point at the problem

0 views

Rusty thoughts on "Parse, don't validate"

Like many programmers, I find Alexis King's Parse, don't validate article fascinating, because it gives a name to an idiom that seems familiar and important - one I've observed and used in the past without naming it explicitly. This post is a review of the "Parse, don't validate" pattern applied to the Rust programming language (the original post uses Haskell). I was particularly interested in finding educational examples of this pattern in the Rust standard library and other well-known projects. Without repeating the original article (please read it first!), here's the gist of it. Consider the venerable Vec ; its first method returns Option<&T> . Why? Because a vector is not guaranteed to have any elements in it, so what to do if first is invoked on an empty one? Returning an Option in this case is idiomatic in Rust [1] , with convenient syntax sugar for accepting the result of functions that return Option and deciding what to do next. So what's the issue? Imagine we have a function to read some configuration paths from an env var, while enforcing the invariant that the list can't be empty: So far, so good. Now let's take a typical usage of this function: Once get_configuration_directories returns a successful result, we are guaranteed that the vector isn't empty. And yet, if we want to get the first element of this vector, we have to use the first method that returns Option<&T> . We are therefore forced - again - to handle a potentially empty case (where the option is None ). As the original article states, this has a number of problems with code clarity, potential performance implications and a ticking time bomb if the invariant is ever changed in get_configuration_directories . The core issue is that Vec is fundamentally a type that can be empty; we can carry along a "This one can't be empty, pinky promise!" comment on all the relevant code, but it's not formally checked by anything. The solution is leveraging the type system to enforce a newly established invariant. We can use a separate type for "a vector that cannot be empty"; in fact, such types already exist in several Rust crates - for example nonempty : This type has no constructor that permits "no elements"; its new takes one element, and its first method returns &T without an Option : The rest of the crate deals with making NonEmpty behave as close as possible to a normal Vec , by implementing many useful traits, as well as conversions like: Let's see how our get_configuration_directories function would look if it returned a NonEmpty instead of a plain Vec : Note the use of NonEmpty::from_vec here - this is where the invariant is established. Now a successful result is NonEmpty , not just Vec . The client code looks like: There's no need to check if the returned value is empty again; this is enforced by the type system! This is where the parse vs. validate terminology of the original article comes from. When get_configuration_directories returned a Vec , it simply validated it. But when it returns a NonEmpty - the vector is transformed into another entity which carries additional meaning. If we treat the concept of parsing in the most generic sense - "transforming data from one format to another", this fits. To mention a less artificial example, the Rust rewrite of core POSIX utilities uses NonEmpty in several places [2] . For example, when constructing a shell pipeline: The command parser's code: A valid Pipeline is only returned if there are some commands in the parsed AST. Otherwise, it just returns None . Once this is done, the client code can use commands.first() without having to worry about the possibility of it returning None . A somewhat more interesting example can be found in the source code of rust-analyzer . This project has a type that represents an absolute filesystem path: Instead of carrying around a regular path, the absoluteness is recorded in the type once the initial parsing and validation is done: Subsequent code doesn't have to validate the the path is absolute. The type enforces it. Note also that AbsPathBuf wraps Utf8PathBuf , not PathBuf . Utf8PathBuf is itself a custom, "parsed" type refinement from the camino crate . Regular paths in the Rust standard library aren't guaranteed to be valid UTF-8, so they cannot be easily converted to a String (which has to be valid UTF-8 in Rust); camino::Utf8PathBuf establishes validity on construction, and can then be converted to a string with just: So we have an example of gradual parsing and type refinement here: Rust has a generic type called NonZero , to describe unsigned numeric quantities that are known to be non-zero. For example, thread::available_parallelism is defined as: If the call is successful, it returns a NonZero<usize> , which is like a normal usize with the restriction that it's not zero. Client code doesn't have to keep checking whether the parallelism is 0 - it's enshrined in the type system. Rust defines the division operator with NonZero<usize> in the denominator as an operation that "cannot panic". NonZero has an additional advantage: zero is an invalid value for the type, so Rust can use the zero bit pattern to represent None . Consequently, Option<NonZeroUsize> is guaranteed to have the same size and alignment as NonZeroUsize itself (and as usize ). This can avoid the extra storage that an Option<usize> would generally require. A common example of the "parse, don't validate" idiom appears in deserializing data from a JSON string. Rust's serde crate enables us to do the parsing, with validated decisions encoded into the type system, e.g.: And then later: There is a lot happening behind the scenes: We take code like this for granted these days, but it's still a great example of the pattern discussed in this post. Once the parser converted mode into the Mode enum, no further validation is required. In dynamic languages like Python and JS, the process is usually much more manual. Python's json.loads gives us a dictionary, and it's up to the user to validate its contents. Libraries like Pydantic permit an approach closer to Rust's, but they're not universally used. The types of all fields are enforced (e.g. "name" cannot be an array). The mode is validated to be one of the enum values of Mode . workers is validated to be a non-zero integer, because of the NonZeroUsize field type.

0 views
David Bushell 2 weeks ago

Removing the fancy button

My blog is standard.site ready but I’m pausing full integration for now. I desperately wanted the fancy button but after its novelty wore off I’ve accepted the truth. The Bluesky button is bad UX. This has been widely complained about from day one. The problem is obvious for Bluesky users. Clicking the fancy “View publication” button opens the homepage and not the actual article. This subverts the UI design pattern for ‘card’ components which is long established, evident by unhappy feedback. I keep clicking the button myself despite knowing better. I’ve seen Bluesky posts from Bluesky people suggesting this will change. It was a design assumption for v1 that turned out to be … less than correct. We’re hard at work on v2. @jimray.bsky.team It sure is taking a while! Ideally they’d benefit by pushing a quick fix for this alone. Maybe that’s not feasible for a project like Bluesky. It’s frustrating because it gives standard.site a bad smell that’ll be hard to shake off. The easiest way to temporarily remove the button is to remove the element. This is the one for individual pages. If I share my homepage I still get a button which uses the variation. Unfortunately removing the metadata means my website is now not fully standard.site compliant. Although I am still publishing atproto records. It’s possible Bluesky’s metadata extractor can be identified server-side and I could dynamically strip the element by user agent. I’m hosted on Bunny CDN so my only option would be to use a JavaScript worker. Bunny’s “edge scripting” add tens of milliseconds of latency to every page request which I’m not willing to entertain. Let’s hope Bluesky fixes this soon because I reckon there’s something to atproto and standard.site . It may be yet another standard ( obligatory link ) but spicy JSON has a certain quality that holds my interest. Thanks for reading! Follow me on Mastodon and Bluesky . Subscribe to my Blog and Notes or Combined feeds.

0 views
Farid Zakaria 2 weeks ago

Every package is already installed

tl;dr; omnibin is a FUSE filesystem that puts every binary nixpkgs ever shipped on your . Nothing is installed. Nothing needs building. 0 bytes on disk until something actually reads a file. 😈 It’s 2026, why am I still installing packages individually? 1 Why must I go through the ritual of adding a package to my , running or succumb to the hellscape of . Nix gives us the power of having packages installed side-by-side without conflict. Why do I have to pick which ones I want to install? Why can’t I just have them all? What if the machine just had all of them? That is over fifty thousand 2 top-level binaries available on my , from 2013 to 2026 built by Nixpkgs , available on-demand, without installing anything. This is the magic 🧙‍♂️ of Nix , but it’s not restricted to Nix. Everyone seems to still love Docker and OCI , why am I still picking which base image to use? Why can’t I just have them all? Is this the ultimate agent harness? It’s a container with everything in it, right from the start. Try it at fmzakari/omnibin . Of course, I cannot forget our NixOS friends. You no longer have to curate your or , you can just have them all. What is “package management” if every package is already installed? Turns out that Hydra writes a file next to every single narinfo on cache.nixos.org that describes the contents of the archive as JSON: That metadata turns out to be the perfect index for a FUSE filesystem that can lazily fetch the NARs from the cache and unpack them on-demand. 🤓 None of this would mean anything without nixpkgs-multiverse , which already resolves any in nixpkgs history to the store path Hydra built for it on cache.nixos.org . When you combine the two, you get a filesystem that can answer the question “where is ” and then fetch it from the cache and unpack it for you, all without ever having to install it. I crawled all of it the files in under twelve minutes. 🤯 Once you have that, the filesystem writes itself: That is CPython 3.6.2, from 2017. That downloaded nothing , it is answered from the pre-crawled index. Agents are “a thing”. Making them useful is a thing. Making them useful without installing anything is a thing. If your agent tried to and stat every single entry, it would have a really bad time. There are 881,933 binaries in the tree, and it would take a long time to stat them all. To help the agents out a bit, lists only the bare names, one per executable, each resolving to the newest package that provides it. The versioned forms all resolve, but they are not listed. For example, resolves to the latest Python 3, which is 3.14.6 at the time of writing, but resolves to the 2017 version. For everything else there is the index, which is sitting right there in the mount: That UX is a little rough, so you can also use the CLI to query the index: Lastly, there is a /omnibin/README.md whose entire job is to tell whatever is exploring the filesystem to stop exploring the filesystem and query the database instead. 🤖 At this point it should be obvious, but you pay for this on startup for the first access. The first run took 2.7 seconds to fetch the NARs and unpack them, the second run was instantaneous because the store paths were already present. Other than that? Not really, which is pretty amazing. For any long-lived machine, you would expect your to already be warmed up with the packages you need, so the first access penalty is not a big deal. I remember one of the first things that blew my mind and sold me on Nix, was seeing a demo by @burke on comma . The capability to test a package, at a single nixpkgs revision, without “installing it”; revolutionary! I believe this to be a spiritual successor and I hope to imbue others with the same sense of wonder and amazement that I felt back then as a beacon of the power of Nix. The repo is at github.com/fzakaria/omnibin . Please responsibly. Yes, I am a little inspired after watching DHH’s keynote at RailsConf 2026 . I feel the same way about package management.  ↩ There are actually 881,933 binaries in the tree, but only lists the latest version of each binary. The versioned forms are still available, but they are not listed.  ↩ Yes, I am a little inspired after watching DHH’s keynote at RailsConf 2026 . I feel the same way about package management.  ↩ There are actually 881,933 binaries in the tree, but only lists the latest version of each binary. The versioned forms are still available, but they are not listed.  ↩

0 views
Nick Khami 3 weeks ago

How I made Palmer Luckey and Paul Graham perform Hotel Lobby

import Figure from "../../components/blog/SeedanceVideoEditing/Figure.astro"; import ReferencePhotos from "../../components/blog/SeedanceVideoEditing/ReferencePhotos.astro"; import ResultVideo from "../../components/blog/SeedanceVideoEditing/ResultVideo.astro"; import sourceFraming from "../../assets/images/blog-posts/SeedanceVideoEditing/source-framing.jpg"; import sourceWide from "../../assets/images/blog-posts/SeedanceVideoEditing/source-wide.jpg"; import generatedWide from "../../assets/images/blog-posts/SeedanceVideoEditing/generated-wide.jpg"; import generatedCloseup from "../../assets/images/blog-posts/SeedanceVideoEditing/generated-closeup.jpg"; import apiScreenshot from "../../assets/images/blog-posts/SeedanceVideoEditing/reapi-edit-settings.png"; import fullPromptUrl from "../../assets/images/blog-posts/SeedanceVideoEditing/submitted-prompt.txt?url"; It feels like AI video generation is finally solved and I wanted to be a part of it. After seeing an AI-generated video of Sam Altman and Dario Amodei go extremely viral on X today, it felt like it was finally time to learn how to do this myself. I made a version with Jensen Huang and Lisa Su that got over 100,000 impressions with less than 30 minutes of total effort. However, it still does feel like somewhat of a dark art to the uninitiated, hence why I'm writing this blog. Feel free to just copy and paste it into an agent if you want to generate similar kinds of videos. The most shocking part outside of the process itself was the cost: I paid about $16 per generation for these roughly 30 second videos at 1080p using Seedance 2.5 through reAPI . There's still a cost barrier to doing this, especially when it takes a few attempts to get a result you like. I'm excited to see that come down as the models get more and more efficient. <ResultVideo /> I started with Quavo and Takeoff's COLORS performance , then matched the section and framing of this character replacement edit on X . Starting with an existing performance gives the model gestures, expressions, interactions and camera movement to follow. I downloaded the original with yt-dlp , without its attached YouTube playlist. You will also need FFmpeg and jq for the commands in this post. For this clip, the section begins at 14.60575 seconds and lasts about 29.09 seconds. I cropped the 3840 × 2160 source to 2880 × 2160, offset 606 pixels from the left. That gives a 4:3 frame with the performers and microphone positioned correctly. These crop coordinates are specific to that 4K source. For another video, choose your own section and crop before submitting it. Keep the audio in the trimmed file, since you will use it again after generation. <Figure image={sourceFraming} alt="The cropped source performance with the striped shirt performer on the left and orange shirt performer on the right" caption="The prepared source near the beginning. This establishes the left and right assignments, microphone placement and framing." source="https://www.youtube.com/watch?v=x9yop0nYR9g" credit="COLORS" /> A good portrait tells the model what someone's face looks like. It does not tell it what shoes to put on them. I collected full body photos with visible footwear alongside facial references, then told the model how to use each photo. These are the six actual images I submitted, in request order. Click any image to inspect it at full size. The first photo is the primary outfit reference: a red patterned Hawaiian shirt, khaki cargo shorts and dark thong flip flops. His full body and both feet are visible. The second is another full body view, useful for his build and footwear, but it shows a different shirt and black shorts. I explicitly told the model to ignore those clothing differences and follow Image 1. The third photo supplies recent facial detail, including his wavy hair and pointed goatee. Its watch and clip microphone should not carry into the video. The second photo was originally 8,047 pixels wide. The API rejected it because reference dimensions must be between 300 and 6,000 pixels. I resized the entire photo to 6,000 pixels wide and kept both feet in frame. The rejected attempt used no credits. <ReferencePhotos person="palmer" /> Paul is on the left in Image 4, but he belongs on the right in the video. That distinction needs to be in the prompt. Image 5 is older, so I used it for facial detail while asking the model to keep his gray hair and appearance from Image 4. Image 6 is the important wardrobe photo. Paul is standing at the far right, wearing a pale polo, gray shorts and brown sandals. I also asked for visible cargo pockets, even though that photo does not clearly show them. That makes the pockets a detail to check in the output, rather than something the reference already establishes. <ReferencePhotos person="paul" /> I used the standard Seedance 2.5 model, , with output and set to . The reAPI documentation requires and for this editing mode. Automatic duration also requires enough account credit for the maximum reservation, even if the source is shorter. I kept the default moderation settings and set , since I wanted to restore the original music afterward. <Figure image={apiScreenshot} alt="Screenshot of reAPI's Seedance 2.5 documentation showing the task types and requirements for video editing" caption="The provider's edit mode requirements. This is a screenshot of the documentation, not a generation dashboard." source="https://reapi.ai/docs/seedance-2-5#task-types-and-constraints" credit="reAPI documentation" /> Create an API key and put it in your local environment variable. Upload and the six photos to storage that gives you directly accessible HTTPS file URLs. A link to a photo viewer page is not the same as a link to the image bytes. Temporary signed download URLs work as long as they remain valid for the job. Save the six image URLs as a JSON array in , in the order shown above, and set to your uploaded trimmed MP4. Keep the key and private URLs out of screenshots and published examples. Here is a shorter version of my prompt. The image numbers refer to the order in . <a href={fullPromptUrl} download>Download the complete prompt used for this run.</a> Save whichever version you use as . The position in a reference photograph and the position in the output are separate instructions. This matters when a reference has two people in it. Also name the specific clothes, rather than assuming the model will know which photo should determine the outfit. Build the request with jq so multiline prompt text is escaped correctly: The submission returns a task ID. Keep polling that task until it completes. If a submission has an uncertain network result, check whether it created a task before submitting again, since another POST can create another paid render. Compare the source and generated clip at the same timestamp. Check the positions, scene, gestures and camera cuts, then inspect the faces and clothing through the turns. <Figure image={sourceWide} alt="Wide source frame showing both original performers at 18 seconds into the trimmed clip" caption="Source at 18 seconds into the trimmed clip." source="https://www.youtube.com/watch?v=x9yop0nYR9g" credit="COLORS" /> <Figure image={generatedWide} alt="Wide generated frame with Palmer on the left in his red Hawaiian shirt, khaki cargo shorts and flip flops, and Paul on the right in a pale polo, gray shorts and brown sandals" caption="Generated frame at 18 seconds. Palmer has the requested outfit, including visible cargo pockets and flip flops. Paul's polo and sandals carried through, but his gray shorts lack the requested cargo pockets." /> Watch every cut, the turns and the moments when hands pass near faces. Check that neither original performer comes back, the replacements keep their positions, and the outfits stay consistent. A successful API response only means that a video was produced. <Figure image={generatedCloseup} alt="Generated closeup showing a ring on Palmer's hand and rings and a watch on Paul" caption="The no jewelry instruction failed: rings remain on both people, and Paul still wears a watch. His hair also came out darker than the recent reference." /> The model returned a 1664 × 1248 video for the request. I exported a 1440 × 1080 H264 MP4 for X, keeping the 4:3 frame and copying the audio from the trimmed source. The two arguments select video from the generated file and audio from the original trimmed file. preserves that AAC audio without encoding it again. Compare the stream durations and check the end of the clip for any mismatch. Check the file before uploading: The export settings target H264 at 1440 × 1080 with AAC audio. Check the resulting frame rate, dimensions and bitrate against X's documented upload limits . The second command decodes the file to catch errors that metadata alone would miss. The reference photos, prompt and source framing are the parts to reuse when making your own version. If one detail is essential, like Palmer's flip flops or Paul's sandals, make it visible in the references and then check that it actually appears in the video.

0 views
Sean Goedecke 4 weeks ago

Jev means structured output is interesting again

I don’t write blog posts about new models. That’s Simon Willison’s beat, and he’s very good at it. But I want to write about Jev , which is a different kind 1 of AI model: a “System One” 2 model. As it turns out, it’s not that different from an ordinary LLM with structured output, but the interface it uses is very cool and I hope it becomes more widespread. Ordinary LLMs take in some human-language prompt and produce some human-language output. They do so autoregressively : first they produce one token, then the next, then the next, and so on. This makes them extremely flexible, since they can do literally anything a computer can do. But it also makes them slow and weird. Slow, because they have to run a whole new generation pass per-token, and weird, because the space of human language is so broad that you can get really odd behavior from a model trained on it. Jev takes a human-language prompt, but it does not produce human-language output. It only produces structured output. So far, so ordinary: LLMs do this already . But it turns out that if you build a model that only produces structured output, you get some interesting and desirable properties. Jev is always really fast. The fastest response time is around 70ms instead of a couple of seconds for normal LLMs. Even better, the slowest response time is only 500ms. Because Jev only does structured output, it isn’t autoregressive: it can produce answers to many questions in parallel in a single forward pass. When a LLM is producing structured output, it has to produce the tokens ”{”, ” ”, “answer”, ”:”, and so on with successive forward passes 3 . Jev does it all in one go. The most compelling example of Jev’s speed is that the model can play Doom . You can feed a text-based representation of the current game state into the model, combined with a set of choices like “should the trigger be held down”, “what should the current goal be”, “given that the current goal is X, what keyboard input should be pressed”, and so on, and it works — latency is low enough and the system is smart enough that the model plays well in real time. Of course you could train a neural net to play Doom already. But Jev is a general intelligence: just like LLMs can do your taxes, perform mathematics research, fix your Python environment, and write you a poem, Jev can do many other tasks besides playing a single video game. Current LLMs can play Doom too (albeit slowly). But as Nelson Elhage famously said , fast software doesn’t just mean we can do the same tasks faster, it means we can do entirely new kinds of tasks. What kinds of new programs can we write by injecting 100ms worth of dirt-cheap intelligence at various decision points? To me, this is the most exciting thing about Jev. Fast structured output could be a genuinely new computational primitive for intelligence. So far we’ve built a lot of programs on top of autoregressive token generation, and they all look like fancy chatbots. Leaning hard into structured output might conceivably unlock a bunch of non-chatbot use cases for AI. My biggest problem with Jev is that I think fast structured output is already available . Structured output from LLMs is only slow because (a) nobody really cares about it 4 , and (b) the people who do care about it want big JSON blobs, so it’s typically implemented with “grammar-constrained decoding” : the LLM outputs autoregressively as normal, but the logit sampler discards tokens that don’t fit the structured output (e.g. if there hasn’t been a ”[”, you can’t output a ”]”). If you want fast, parallelized structured output against limited choices, you don’t strictly need to do autoregressive generation at all. You can simply prefill the response with and generate one token 5 , restricted to the user-provided choices. Since LLMs ingest all input tokens in parallel, this is way faster than generating the entire structured output. Multiple choices can be batched into the same forward pass via ordinary inference batching. This doesn’t let you do long-form structured output, but in return you get most of 6 Jev’s “secret sauce”: the speed, the consistency, and the parallelism of a System One model. People have already started trying this after today’s Jev announcement, and it seems like it’s working OK 7 . In other words, I suspect Jev does not have a substantial technical moat, and their claimed “Reinforcement Learning for Calibrated Decisions” is not a brand-new scaling axis. It will probably be pretty easy for any other lab to replicate, or for individual programmers to retrofit existing open-source LLMs into a fast Jev-like model. However, I suspect Jev is still going to be better than most versions of “Qwen-32B-System-One” or whatever. Being able to fine-tune or optimize the model on just structured output is probably a meaningful advantage. I doubt Jev is ever going to be as smart as frontier LLMs. Not being able to use test-time compute at all 8 is a big disadvantage, and will likely cap this kind of model around the strength of non-reasoning LLMs. In practice this shouldn’t matter too much for low-latency applications, but you shouldn’t see this as a new scaling axis or a way to produce more intelligent models. Jev’s developers claim it is immune from hallucinations. To me, this seems like a semantic dodge, since Jev can absolutely still pick the wrong choice (e.g. calling the sky “red”). I suppose that’s technically just a mistake , since the model is picking a user-provided choice instead of inventing something new out of whole cloth. Still, all of this is also true about regular LLMs with structured outputs, and it doesn’t make Jev any more reliable in practice. It’s unclear to me how much of Jev’s value is in the model itself, compared to the inference strategy of only generating one token per question. The data and demos in the announcement look to me like they could have been generated by plugging any Terra-sized model into a single-token inference stack. However, the people involved are credible, and I’m sure the model is good — I just wish they’d provided some comparisons that didn’t force the LLM to unnecessarily produce a blob of JSON token-by-token. Overall, I am happy that Jev exists and I hope it succeeds. I hope we do see some real competition in the fast-structured-output space, and that it motivates the big labs to release official versions of their own models that are fine-tuned for this. GPT-5.6-Terra-System-One would be a very interesting model to build AI products on top of. I did write about Thinking Machines’ “interaction models” , which are also a fast-enough-to-be-meaningfully-different paradigm for AI inference. They call Jev a “System One” LLM, after Daniel Kahneman’s partially discredited Thinking Fast and Slow , where he divides human cognition into a lightning-fast System One and a slow-and-reflective System Two. If you’re thinking “wait, couldn’t you just aggressively prefill a regular LLM and only produce one constrained token”, keep reading. Not counting tool calls, which are built-in in a way that structured output isn’t. What if some of the user’s choices are longer than a single token? I haven’t tried this myself, but I’m sure you could translate them into a single token, or train the model to output “1/2/3” under the hood instead of the choice content, or generate only the first token of the choice if it’s different, or some other clever trick I haven’t thought of. Jev claims that their generated probabilities are “calibrated”, but I haven’t seen anything to suggest that these aren’t just regular logit probabilities. Maybe there’s some clever training they do to encourage accurate logprobs in uncertain situations (e.g. getting the model to produce when predicting a coinflip, etc)? If so, I wish they’d written more about that in the announcement. I tried it myself with and got a 2x-3x speedup compared to non-prefixed structured output. I suppose they could do some looped-transformer thing where they loop some fixed amount of times, but anything that looks like reasoning would make the model latency slow and unpredictable, defeating the entire purpose. I did write about Thinking Machines’ “interaction models” , which are also a fast-enough-to-be-meaningfully-different paradigm for AI inference. ↩ They call Jev a “System One” LLM, after Daniel Kahneman’s partially discredited Thinking Fast and Slow , where he divides human cognition into a lightning-fast System One and a slow-and-reflective System Two. ↩ If you’re thinking “wait, couldn’t you just aggressively prefill a regular LLM and only produce one constrained token”, keep reading. ↩ Not counting tool calls, which are built-in in a way that structured output isn’t. ↩ What if some of the user’s choices are longer than a single token? I haven’t tried this myself, but I’m sure you could translate them into a single token, or train the model to output “1/2/3” under the hood instead of the choice content, or generate only the first token of the choice if it’s different, or some other clever trick I haven’t thought of. ↩ Jev claims that their generated probabilities are “calibrated”, but I haven’t seen anything to suggest that these aren’t just regular logit probabilities. Maybe there’s some clever training they do to encourage accurate logprobs in uncertain situations (e.g. getting the model to produce when predicting a coinflip, etc)? If so, I wish they’d written more about that in the announcement. ↩ I tried it myself with and got a 2x-3x speedup compared to non-prefixed structured output. ↩ I suppose they could do some looped-transformer thing where they loop some fixed amount of times, but anything that looks like reasoning would make the model latency slow and unpredictable, defeating the entire purpose. ↩

0 views
Rob Zolkos 1 months ago

HEY, watch this!

37signals recently released the HEY CLI , and it is perfect for your AI agents. It gives them a command-line interface to your email and calendar. They can search your email, read a thread, draft a reply, move mail between boxes, and manage your calendar on your behalf. The CLI is also open source on GitHub , and it makes HEY scriptable. One command in particular turns it from a toolbox you call into something that listens: . That opens up useful possibilities that HEY itself does not offer today. That malleability is the point. The CLI gives you the pieces; what you do with them is limited mostly by your imagination. Imagine you use a monitoring service called Acme Monitor. It sends everything from the same address: . Most of those emails are hourly status updates: Those can arrive in the Imbox with everything else from Acme Monitor, but I do not want every routine update waiting for my attention. I want them marked as seen automatically. Every so often the status is less reassuring: That one should remain unseen in the Imbox. Something is wrong, and you want to see it. Once a month, the same address sends this: That belongs in Paper Trail with your other invoices and receipts. HEY lets you decide where email from a sender should go. That works well until one sender sends three very different kinds of email. I want routine updates marked as seen, degraded alerts left unseen in the Imbox, and invoices moved to Paper Trail. There is no rule in HEY for “mark routine messages from this sender as seen, leave degraded alerts unseen, and file invoices in Paper Trail.” But now we can make one. First, let into the Imbox as normal. Now watch the Imbox for new mail: When an email arrives, prints one JSON object. A shortened event looks like this: The posting’s is the email subject. It also carries the sender and the posting ID needed to move it or mark it as seen. This example uses to inspect the event. Create : Make it executable: Then connect it to the watch: Every new Imbox event is handed to the script as JSON on standard input. The script also receives useful environment variables such as , , and . Using prevents ordinary moves from triggering the rule again, while the first check limits it to newly added threads rather than replies. Known “all systems operational” status threads are marked as seen. A degraded, outage, or incident alert is left unseen in the Imbox. An invoice, receipt, or payment confirmation moves to Paper Trail. Anything unexpected also remains unseen rather than being hidden by an overly broad rule. That is an attention and filing rule HEY does not offer in its interface, built from three CLI commands and a few lines of shell. Keep running under , , or your preferred service manager if you want the rule to stay active. You do not need to write this script yourself. The HEY CLI includes a bundled agent skill for Claude Code and Codex, and today’s coding agents can create, test, and put a watcher like this in place for you. Give the agent the outcome you want: The generated script is still worth reviewing before you turn it loose on your inbox, but the skill means you do not need to learn every command or write the shell from scratch. The subject rule is only the start. The CLI gives scripts and agents access to the same email and calendar you work with in HEY. With the HEY CLI, you could: adds the missing trigger. Instead of running a command yourself, HEY can tell your program that something changed. With , you could: A watch event includes the sender, subject, summary, box, whether the posting includes attachments, and other details. When that is not enough, a script or agent can read the full thread with . The Acme Monitor rule is deterministic. If the sender and subject match, move the email. There is no reason to ask a language model to make that decision. Real inboxes are not always so tidy. A supplier may use inconsistent subjects, or a message may look routine until you read the body. An agent can wake when reports new mail, read the complete thread, then summarize it, suggest a destination, apply a label, or prepare a draft reply. I would still begin conservatively. Give the agent a narrow job and keep sending or destructive actions behind human approval until you trust the workflow. HEY’s live connection acts as a doorbell: it says a box or calendar changed, then the CLI reads an incremental feed to find out what happened. If that connection drops while remains running, it catches up before continuing. That makes it much safer than treating a transient WebSocket notification as the source of truth. The HEY CLI lets an agent manage your inbox, but you do not need an agent to benefit from it. Sometimes five lines of shell are the better tool. Start with the annoying rule you cannot express today. Let HEY tell your script when something changed, then make the decision as simple or as sophisticated as it needs to be. Build a morning briefing from today’s calendar, new mail, Reply Later, and Set Aside. Search years of email before a client call, then summarize the decisions and outstanding commitments. Read a long thread and prepare a draft reply for you to approve. Review first-time senders in The Screener, read what they sent, and recommend who to let through. Mark routine status reports as seen but leave an outage unseen and send a desktop notification. Move receipts into Paper Trail only when the message has an attachment or matches a supplier-specific rule. Wake an agent to summarize mail from an important customer and prepare the next action. Refresh an e-ink household agenda when a shared calendar changes.

0 views
Farid Zakaria 1 months ago

Stamping build info in constant memory

This is a fun little trick I came across at . I did not invent it, but I thought it was interesting enough to understand better and share. At we build with buck2 and we stamp our executables with build information: build-id, timestamp, author, the usual suspects using as a step after the link. The reason it is a separate step is caching. If the build info was generated at link time then everytime we link the binary it would produce different bytes causing it to not be bit-reproducible. When something is bit-reproducible, it is safe to cache it, and the build system can apply early cut-off optimizations. That works, until the binaries get big. We noticed that ’s memory use scales with the size of the file it is stamping. Stamping is exactly the kind of step that runs massively parallel at the end of a build so this can cause a lot of memory pressure. Why is the stamping step reading the binary at all? 🤔 Let’s measure the claim that the memory use of scales with the size of the file. We will attach a JSON build info blob to an increasingly large synthetic executable and measure the peak RSS of the stamping step. The graph confirms the claim. The memory use of scales linearly with the size of the file being stamped. Surprisingly, the slope is two . The peak RSS is roughly twice the size of the file being stamped irrespective of the size of the build info being attached. 1 I am helping to shepherd a PR open against LLVM to stream the ELF output rather than materialize it, which roughly halves the peak. That is a definite improvement but the problem remains that in order to add a tiny section to a large binary, the whole binary has to be read into memory. The memory use is still linear in the size of the file. The problem is not poor implementation on the part of . Adding a section to an ELF touches three separate things: 0x78 bytes in .interp\0 .buildinfo\0 .rela… 0x70 0x78 0x83 the section's bytes 88 bytes of JSON, at 0x401021 The entry holds neither the name nor the bytes. It only refers to them — and only one of those two references can be repointed in place. In order to account for the new section, the section header table has to grow by one entry, and has to grow by the length of the new name. The current model for is to read the whole file into memory, add the new section, and write the whole file back out. That is why the memory use scales with the size of the file. How can we avoid having to rebuild the whole file just to add a tiny section? The trick is to pay the cost at link time rather than at stamp time. We can have the linker emit a placeholder section with the right name and a single byte of content. The section header table entry is already there, and the name is already in . The post-link stamping step can then append the payload to the end of the file and update the section header entry to point to it. 💡 We make linker emit the section during the normal build. It does not need to hold anything; it just needs to exist so that it owns a name and a header. Our “stamp” step is now incredibly simple. It does not need to read the file at all, it just needs to write the new payload and update the section header entry. Nothing that already exists moves. does not move, the section header table does not move, no other changes. The edit is sixteen bytes , at a file offset you can compute from the ELF header, plus a . read into a model, serialized again — 1,238 bytes of it actually differ reserve one byte, then append 16 bytes + a tail ehdr phdrs .text .rodata … .shstrtab section headers payload the reserved byte sh_offset, sh_size Note Why 1 byte? Turns out that and GNU disagree on whether an empty section is a valid ELF. The one byte is a cheap way to make both linkers happy. The payload lands after the section header table, which looks alarming the first time you see it but is completely legal. Nothing in ELF says section contents must precede the section header table. The kernel also never looks at section headers also, it loads segments out of the program headers, which we do not touch. I wrote a small C version to benchmark it in contrast, please be mindful that this graph is log-log. Our trick works! The “append + 16 bytes” approach is constant memory. Not only is the peak RSS constant, but the wall time is also constant and much faster by avoiding the read and write of the whole file. It is often easy to reach for general-purpose tools like as they are a swiss-army knife for manipulating object files. What I like about this trick though is that there are meaningful improvements to be made by writing special purpose tools and that does not mean we have to accrue large maintenance costs. In this case it was a tiny 200-line C program. The economics of these tools is also changing with the rise of LLMs in our workflow. While many are concerned about the influx of generated code, I remain optimistic that we we can use them to find such opportunities. Don’t be afraid to write a small tool to solve a specific problem. For those thinking this is an LLVM specific issue, GNU exhibits the same behavior.  ↩ the section’s bytes , somewhere in the file a 64-byte entry in the section header table describing where those bytes are the section’s name , which is not in the entry itself but rather the entry holds a offset into , so the name has to be appended to that string table 0x70 0x78 0x83 the section's bytes 88 bytes of JSON, at 0x401021 The entry holds neither the name nor the bytes. It only refers to them — and only one of those two references can be repointed in place. In order to account for the new section, the section header table has to grow by one entry, and has to grow by the length of the new name. The current model for is to read the whole file into memory, add the new section, and write the whole file back out. That is why the memory use scales with the size of the file. Pay the byte at link time How can we avoid having to rebuild the whole file just to add a tiny section? The trick is to pay the cost at link time rather than at stamp time. We can have the linker emit a placeholder section with the right name and a single byte of content. The section header table entry is already there, and the name is already in . The post-link stamping step can then append the payload to the end of the file and update the section header entry to point to it. 💡 We make linker emit the section during the normal build. It does not need to hold anything; it just needs to exist so that it owns a name and a header. Our “stamp” step is now incredibly simple. It does not need to read the file at all, it just needs to write the new payload and update the section header entry. append the payload to the end of the file write the new and into the placeholder’s section header entry the reserved byte sh_offset, sh_size Note Why 1 byte? Turns out that and GNU disagree on whether an empty section is a valid ELF. The one byte is a cheap way to make both linkers happy. The payload lands after the section header table, which looks alarming the first time you see it but is completely legal. Nothing in ELF says section contents must precede the section header table. The kernel also never looks at section headers also, it loads segments out of the program headers, which we do not touch. Benchmark I wrote a small C version to benchmark it in contrast, please be mindful that this graph is log-log. elfstamp.c Our trick works! The “append + 16 bytes” approach is constant memory. Not only is the peak RSS constant, but the wall time is also constant and much faster by avoiding the read and write of the whole file. One trick pony It is often easy to reach for general-purpose tools like as they are a swiss-army knife for manipulating object files. What I like about this trick though is that there are meaningful improvements to be made by writing special purpose tools and that does not mean we have to accrue large maintenance costs. In this case it was a tiny 200-line C program. The economics of these tools is also changing with the rise of LLMs in our workflow. While many are concerned about the influx of generated code, I remain optimistic that we we can use them to find such opportunities. Don’t be afraid to write a small tool to solve a specific problem. For those thinking this is an LLVM specific issue, GNU exhibits the same behavior.  ↩

0 views
Xe Iaso 1 months ago

How to make VS Code go back to the old UI

Someone on the VS Code team decided to do a redesign of the product. This makes VS Code look like this: A picture of the VS Code UI that has a design I don't like. This design is fine, I guess? It's got some rounded corners that look nice, I guess, but I really just want it to look like what I'm used to. I can tolerate this kind of redesign in my chat app, but I use VS Code professionally and kinda want things to look the same so I don't have to think as much. You can revert the design change by setting in your settings.json. Then it looks like this: A picture of the VS Code UI that has the design I'm used to. Who knows how long this is going to last, but at the very least it should work for now. Thanks to jtagcat and Hugo ARNAL for letting me know about this setting.

0 views
matklad 1 months ago

Rust Glancer

Rust Glancer , a functional LSP server for Rust which uses two orders of magnitude less RAM, is incredibly cool. Go check it out! This post started as a comment on lobste.rs, but I figured it out that it’s better to publish it somewhat more prominently. Don’t expect polished writing though! Some thoughts: rust-analyzer uses rowan for syntax tree representation Yeah, rowan is garbage :P I was really thinking about And Rowan is pretty good for that. But that’s 1% use case. The 99% use case is all the code in your 6666 dependencies which you won’t ever look at, but which needs to be at least shallowly analyzed. Even for incremental tool whose main goal is refactoring, the primary AST structure should be just a list of arrays. There might be a real post about that at some point, see https://youtu.be/G93oYL1ry70 as a teaser. Rust workspaces genuinely have a lot of information that must be indexed: thousands of functions, structures, traits, relationships between these, function bodies and statements in them, etc. Each of these needs to be analyzed and remembered, and you can’t really cheat if you want to have things like “find all references to this structure”. If I understand correctly, Rust Glancer wants to process each function body. I think that part can perhaps be made lazy (but not incremental!) with little overhead? Index all items, but, for functions, do only the currently opened file? This might combine some of the better parts of both worlds. Would be interesting to compare memory usage with Rust Rover. Net of the IDE GUI itself, I would expect RR to be more compact. Some features are unlikely to be supported though, such as build scripts / proc macros support via proc macro invocation I might be rationalizing/misremembering things, but IIRC it’s exactly around adding proc macros that the thing began to feel unreasonably bulky. Expanding proc macros is slow as we are running real code, we can’t really do normal IDE cheats. And proc macros generate a lot of code. At one point I measured, it was like 30% of rust-analyzer binary size was attributed to JSON parsing code. If no one sees the code, it can’t harm anybody, right? One potential approach here is to pull the Sorbet trick, where you don’t run meta programming at all, and instead have a plugin interface to “explain” the effects of what that would have done. Instead of running serde, we just add a shim that injects with an empty body. I’m not sure why, but in rust-analyzer I’ve observed that when agents edit the code, inlay hints can get out of place Rust analyzer’s core data model is very pedantic about always observing consistent snapshots of the code, and does its best to ensure that the language client and server have a shared, strictly serializable view of the world. It’s a shame that LSP doesn’t allow that to be correct , only heuristically right , unlike the older Dart Analyzer protocol, which has sound data synchronization. However our implementation of file watching is sketchy! First, there are two backends: we can ask the editor to do watching for us, or we can use server side watching. Try changing this option and see if it helps? But then, yeah, my recollection is that our native watcher’s API was fundamentally racy, and I didn’t do the messy platform-specific work of making it correct. But the main thing I want to write, and why I moved from the cozy lobste.rs text area to the luxurious comforts of an Emacs buffer, is that right now rust-analyzer is a bit like that half-drawn horse meme, except that it’s only the head half of the horse. One Big Idea of IntelliJ is that it’s PSI API (essentially AST with resolved types) is really an interface, and there are multiple provides. And in a typical usage, there’s at least three backends in play: This is how I think such things should work. rust analyzer shouldn’t use salsa for all those 6666 dependencies you still haven’t looked at. It should just use rustc’s .rmeta files, switching to salsa, transparently, only when the user starts messing around their folder. The prerequisite for that is defining the abstract API for accessing Rust code. That was always the plan, and we did start on that at some point: https://hackmd.io/ytd82QNiT_Ku2XFr1EAtiQ rmeta-transparent – source code might not be available for some crates, the API should support pre-compiled rmeta files as inputs. But I don’t think that work was ever completed. This still seems to me to be the lowest-hanging watermelon here — split the world into arcy-pointy incremental tip of the iceberg, and mostly read-only, on disk, compact, dark, moist breeding ground for supply chain attacks. Such glance analyzer architecture would be great, imo! incremental parsing, incremental, DOM-mutation style refactorings, For the files opened in the editor, actively modified by the user, the PSI is backed by the concrete syntax trees. For the rest of the project files, the PSI is backed by the so called Stub Tree, a compact on disk representation storing only the “externally visible” parts of the file (so, without function bodies). If the user navigates to a new file, its PSI transparently switches from stubs to syntax tree. For dependencies, the PSI is often backed by the compiled .class files, produced by javac. If you navigate there, the IDE just decompiles stuff four you! Super cool!

0 views
マリウス 1 months ago

Flipper BUSY Bar

Yes, it is in fact real, I’m holding it in my hands, and after what feels like years of Flipper teasing everyone with this ominous device in various online posts, I can finally confirm that it is real. The BUSY Bar is a 250 gram desk device by Flipper Devices , the company behind the Flipper Zero and the still very much in-development Flipper One . It’s basically a little display that shows various things on a 72x16 RGB LED matrix, and as of writing this it’s main selling point is that can run a Pomodoro-style focus timer , and that it has a ful-blown HTTP API that’s available over USB, over the local network and over the internet, that let’s you control this thing. The BUSY Bar has a five-position selector on the top, that switches between the two focus modes ( BUSY and CUSTOM , which are functionally identical and only differ in their defaults), a OFF position that in reality is more of a sleep mode which turns both screens off, an apps position that currently only holds a clock, and a settings position for, well, the settings. A large mechanical button in the middle starts and pauses a session, a scroll wheel adjusts the timer and doubles as an OK button, and last but not least there’s a back button for when you have to navigate back. Speaking of back, the backside of the device has a 1.54 inch monochrome OLED that shows the timer, the battery percentage and the Wi-Fi, Bluetooth and USB indicators. This way the device remains usable to its own user as well, even when clipped to the top edge of a monitor using its built-in mount, pointing its primary matrix display away from its user. The device measures 168.6 x 55.2 x 40.8mm and weighs 250g/8.82oz. The body is made out of PC/ABS with a PC front and back panel, and the monitor mount padding is TPE. The bar fits monitors up to 21mm thick and I can confirm that it works on curved displays as well. However, if you have a particularly thin monitor (say, one of these portable displays) it won’t be able to sit on top of it. The full specifications, as published in Flipper’s own documentation , are as follows: The 72x16 matrix is driven by the ICND2153 , a 16-channel constant-current PWM sink driver with a 16-bit grayscale shift register, LED open detection and a pre-charge circuit for ghosting reduction, and the ICN2012 8-channel power switch. One thing that is a bit sad in 2026 is the 2.4 GHz limitation for Wi-Fi. In an office environment full of devices and microwaves the bar is on the most congested spectrum available. The USB side is also kept, let’s say lightweight , with its 12 Mbit/s maximum speed, which, however, is certainly enough for a virtual ethernet interface serving a web UI and an HTTP API. On the charging side the documentation asks for an 18 W or higher PD charger for the 2.5 hour figure, while the device itself only appears to use 5V⎓3A (15 W) and 9V⎓1.5A (13.5 W) as its PD modes. There is one discrepancy with regard to the display brightness, where the tech specs page lists no brightness figure at all, the product page currently says 400 nits, and the launch coverage from CNX Software and XDA both quote 800 nits. I don’t have the equipment to measure it, so I can’t really tell which it is, but I can assure you that even in a brightly lit space it’s plenty bright. Flipper published an official disassembly guide on iFixit , which is awesome. Its 21 steps describe a device that’s designed with repairability in mind. The back cover is held by 8 clips and comes off with a plastic card. Below it are 5 Phillips PH1 screws, one on the bottom and four on the back. The battery has a press-latch connector and needs to be disconnected before anything else. The display flex cables use spudger-release latches, the main PCB is held by 3 screws, the control PCB by 5 latches, the front display back cover by 6 latches and the button stabilizer by 3 screws. The monitor mount legs are friction fits. Nothing is glued and the battery is a standard 18650 cell on a 4-pin connector, which means a replacement is easily and cheaply available from most electronics shops. For a 2026 consumer device this is probably something that my fellow Right to Repair advocates will love. The firmware sources are on GitHub as . Most first-party code is GPL, the library is MIT, graphical assets are CC-BY 4.0 and fonts are OFL 1.1, all of which are declared in a REUSE manifest. The build system is FBT , the same SCons-based Flipper Build Tool used for the Flipper Zero , and the dependency list is a usual embedded stack with FreeRTOS underneath Flipper’s own abstraction, lwIP for TCP/IP, TinyUSB for the USB device side, Mongoose as the embedded HTTP and WebSocket server, mbedTLS for TLS and LVGL for the UI. The bar also includes JerryScript in , wired up through and a service. That is the same JavaScript engine the Flipper Zero uses for its scripting apps. With the engine already in the firmware the only thing that still seems missing is the documented way to load your own scripts onto the device. The BUSY Bar runs an HTTP server and speaks the same API over three transports, documented as OpenAPI 3.1 . Plugging the device into a computer over USB brings up a virtual ethernet interface with the device at a fixed , printed on the back of the unit. is the local web interface, is the API reference generated by the firmware currently on the device, and is the base URL for said API. No authentication is used over USB, but it can be used via Wi-Fi and it must be used when going through Flipper’s cloud. This request responds with the current power status. The battery current is in mA, and both battery and USB voltage are in mV, which means that you can graph the device’s own power consumption without any extra hardware. Note: Access over Wi-Fi is disabled by default and has to be turned on from the local web interface over USB first. Once enabled, you can pick a token for authentication, which would go into an header, if you decide to set one: Access over the internet goes through Flipper’s cloud with a bearer token generated at , scoped either to a single device or to the account: Flipper maintains for Python with both a synchronous and an client. It maps method names onto API paths directly, so becomes and becomes . That makes the OpenAPI document usable as the library’s reference documentation: The library also has a module that scales and re-encodes images and audio for the device, an mDNS discovery helper for , and a firmware compatibility check. On top of that there is an official TypeScript library for all the soydevs, and a community-maintained .NET client . And there is also a Zig library, but… more on that in just a moment . :-) The BUSY Bar presents itself to Matter as a single on/off endpoint, an emulated switch with a configurable startup state of , , or . Turning it on starts the BUSY timer and turning it off ends it, making the integration is a trigger. Reporting the state back to Matter requires switching on Settings ➔ Smart home , at which point focus sessions can power automations like dimming lights or locking a door when a timer is turned on. Pairing is done using a QR code on the back screen or in the web interface, and the device can be commissioned into multiple fabrics at once. The Home Assistant integration is done through the HTTP API using the generic REST facilities, which works in both directions, meaning the device as an automation trigger, and the device as an output for anything else in the house. The BUSY Bar comes with mobile apps for your smartphones. I have tested its iOS app and, well, it was okay, I guess. I’m not a huge smartphone app user to begin with, but I’ll give my two cents here. The app basically mirrors the current state of the bar and offers rudimentary control over it. When you start a timer and you have the app set up (via Flipper’s cloud) you’ll see the app pushing a permanent notification that displays the timer on your smartphone’s lock screen. It’s also possible to configure a Do not Disturb mode that prevents other apps from interrupting your focus session whenever a timer is currently running. To me these are gimmicks, but to others these features might be worth something. Having that said, the apps aren’t rated particularly highly and while I didn’t encounter any issues during the few days that I’ve tested the iOS version, the app did leave a somewhat cheap impression by the way it looks and functions. It felt like one of these apps that corporate boomers at large hardware manufacturers would come up with, falsely believing that they are in-line with what today’s generations might want. There are a few things that bother me, however none of them are actual dealbreakers. The Wi-Fi authentication is a single shared numeric key, constrained by the API schema to , sent in a plain header over unencrypted HTTP on the local network. At the four digit minimum that is a 10,000 value keyspace, and I have found no documentation of rate limiting. The access mode enum also includes an value alongside , which means that the API can be opened on the LAN with no key at all. Hence, it’s probably a good idea to use a ten digit key and keep the device off networks you don’t control. Then there’s all the coming soon . Installing user apps, the JS SDK, the Windows application, and the expanded app library, those are all future promises. I don’t doubt the Flipper team that they will eventually arrive, but I could imagine that for a non-technical user it is probably very frustrating to have bought a device that can barely do anything at all at the moment, especially on a Windows machine. The device that arrives today is a focus timer, a clock, and a status display. Lastly, the price. It launched at USD 179 for waiting list members, then USD 199 for the first 3,000 units, with USD 249 quoted as the eventual retail price. At 249 it is a very hard sell, especially in the current software state. If you’re buying this because you’re a technical user and you really want to fiddle with it, it might be worth the Pesos, but as I’ve demonstrated in the past you can build a similar device significantly cheaper yourself, especially if you’re already deep into the tinkering rabbit hole. Flipper built the device I would have expected them to build, which I mean as a sincere compliment. The hardware is over-engineered for a status light in the same way that Flipper hardware always seems to be, with a real mechanical switch, a real encoder, a replaceable 18650, an official teardown guide and no glue anywhere in it. Whether it is worth the money depends entirely on what you intend to do with it. As a device that tells your coworkers to go away, it is way too expensive and not at all effective, because people who interrupt you are not deterred by a sign that tells them not to. Let me put it this way: For roughly $50 below the BUSY Bar ’s retail price you could place one of several Smith and Wesson models on your desk and it would likely be a more effective way to deter co-workers from talking to you. However, as a small, well-built, fully scriptable RGB matrix with an 8 GB filesystem, a WebSocket, and an elaborate priority system, so that several programs can share one screen without overwriting each other, it is the most open and probably best thing in its class, and I expect the community will find uses for it that Flipper hasn’t thought of yet. PS: Turning off the BUSY bar is like quitting Vim, in the sense that it doesn’t offer an obvious way to do so. Yes, the switch on top has an “OFF” position. However, that simply turns off the displays, but it keeps the busy bar running and connected to WiFi. If you want to fully shut down the device so that it won’t consume any battery, you will have to put the switch into the “Settings” position, navigate to System , Power , and Shutdown , and confirm the poweroff with Yes . Only then the device actually turns off. As mentioned before, I have a little bonus that I’d like to share with this review, which is a Zig library that implements the BUSY Bar ’s current OpenAPI specification as closely as possible, and that brings a command line tool that lets you control the device over its HTTP API. The library supports all of Zig’s platform targets as it only uses Zig’s library, and it is fairly lightweight and easy to use. I’m using it with my BUSY Bar and it has been working great for me. The command line tool contains a few quality-of-life features like simple commands for starting and stopping the busy mode, which would otherwise require manually writing JSON payloads. Long story short, if you’re one of the people that have ordered the BUSY Bar and are maybe looking to integrate it into Zig tools, or even just into your desktop environment using your own scripts, I invite you to check out the repository . If you’d only want the CLI tool to play around with your BUSY Bar you can find builds for every supported platform over on the release page on GitHub .

0 views
Farid Zakaria 1 months ago

Three ways to smuggle SQLite into Nix

The core of nixpkgs-multiverse , when you strip away the Nix API and the CLI, is an index. It is a map from to the revision that shipped it as a JSON file. 1 As of 9cc0209 , is 5.3 MiB and is 7.5MiB covering 305,492 package versions across 31,904 packages and 1,534 revisions. The Nix API loads the JSON files lazily and are all read via : I would like to enrich the data with even more information however it comes at a cost: mo’data, mo’problems. The goal of the project is to minimize the number of Nixpkgs that are downloaded. If we merely swap fetching huge Nixpkgs for huge JSON, it’s not a clear win. For now we have to be judicious about what we store in the JSON files and think of clever encoding schemes to make the data small and compact. If we were not constrained to the Nix , we would leverage established technologies to efficiently encode our dataset that allow multiple query access patterns: databases! Let’s say we were not restricted to JSON, do we have any other options? Why are large JSON files so problematic? is eager . There is no lazy JSON in Nix, no streaming parse (i.e. “just give me this one key”). The moment you touch the result you have parsed all 5.3 MB and materialised all 305,492 values on the Nix heap. In the case of the multiverse, asking for one package costs the same as what asking for all of them. Note The lookup itself is not the problem. Nix attribute sets are a sorted array, so access is a binary search, not a scan. The cost is entirely in the JSON parse and in allocating the values and downloading a large file. If we want to do alternate questions over the index, we have to make sure we keep the answers efficiently stored to better match the access pattern. What we want is obvious. We want a way to efficiently encode the data and a declarative way to define queries: we want SQLite! 2 Nix by default cannot do this. Unfortunately there is no , although I think there should be… Turns out though there are knobs we can touch or sources we can patch to get what we want anyways, albeit each one has a caveat. 😈 I was surprised I did not know about this , and it has been around since release 1.11.9 in April 2017. It is the ultimate escape hatch for a variety of use-cases when you simply can’t get them done with what’s available. takes a list of strings, runs the program, and parses its stdout as a Nix expression . It is gated behind a setting that makes it clear it’s unsafe. For integration, SQLite is perfectly capable of printing the Nix syntax. We never need a serialisation format in between as we make SQLite emit the attrset directly: The caveat is that every query is now a , an , a process image of SQLite, and a re-parse of the output through the Nix parser. If you do not plan to execute many queries that overhead is likely acceptable given the simplicity of the integration. From researching , I stumbled upon . It takes a path to a shared object and a symbol name, s it, and calls that symbol. It landed in 1.8 , December 2014. 3 The shared object must implement the following signature: We can define a new native function that returns the versions for our input: The implementation is ordinary C++ using the Nix API. Below is a snippet of the implementation, making sure to cache our handles to avoid the same startup penalty as : Using it looks like this: Determinate Systems shipped a third option in March of 2026: , which calls a function inside a WebAssembly module. 4 The motivation was similar to wanting to extend Nix surface area but avoid expanding . Wasm is sandboxed and deterministic, so unlike the two builtins above, the goal is to provide a safe escape-hatch . WebAssembly is a binary instruction format for a stack-based virtual machine. The claim is that it is well suited for Nix because it has deterministic execution , which is a lot more restrained than a backdoor . A module needs to export , an initialiser called , and the entry point. Nixpkgs already includes the target for cross-compilation, so making one is pretty straightforward: You call back into the evaluator through the Nix API functions, so a wasm module builds real Nix values, similar to minus the footgun. SQLite ships an official wasm build , so the pieces seem to be sitting right there and the gears in my mind began to turn. Initial attempts to try and load a SQLite database with the traditional Nix were a bit of a failure as Nix strings cannot contain NULL bytes. Thankfully, with the help of some additional due-diligence by LLMs, we discovered that one of the Nix API functions is not in the blog post: is specifically designed for this problem. This function allows a WASM module to pull arbitrary raw-bytes off disk into its memory. Unfortunately, it’s a little too broad in that it reads the complete file which is kind of overkill and what we are trying to avoid from our initial JSON solution. In the pursuit of exploration, let’s patch the implementation and augment the API to allow random access and partial read of a file. Turns out the patch to add is relatively small and straightforward. Now we have everything we need to hook up SQLite and a custom virtual filesystem (VFS) layer to read from the provided path entry. We build a WASM target of SQLite and we set . That flag removes SQLite’s entire VFS layer and requires us to supply one. We provide the build a simple implementation of the API which is a call-back into the Nix evaluator via that newly exposed function. Everything else is stubs. Note Unfortunately gives every call a fresh instance . This is deliberate from the implementation, meaning we pay some startup code each time although not quite as drastic as a & Using it looks like this: 5 That is a real full SQLite with all the bells and whistles: prepared statement, bound parameter, b-tree descent through an index, executing inside the Nix evaluator. All through WebAssembly. 🤯 How do these four approaches compare? Here are all four approaches answering the same question: “which revisions shipped this package?” against the same 22 MB SQLite build of the index. As we initially complained, is a flat line in the wrong place. It is 0.29s whether you ask one question or two hundred, because the 5.3 MB parse happens once and dominates everything after it. starts the cheapest and climbs , roughly 3.8 ms per query of + + Nix-parsing the output. It crosses somewhere around eighty queries. is flat and nearly free , 0.05s across the whole range since we reuse SQLite instantiations across multiple invocations. The database is opened once for the entire evaluation and the pages stay warm. Unfortunately, SQLite in wasm is dominated by a fixed cost , roughly 2.5 s before the first query, then about 7 ms each query thereafter. That 2.5 s is Cranelift compiling 1.1 MB of SQLite. Right now that is a limitation of the WASM implementation however Eelco has mentioned that the generated code could be cached on disk in the future across invocations. For a lock file pinning thirty packages, still wins outright at the current index size. None of these three is right for shipping the multiverse index, and I am not going to make depend on . Asking people to run their evaluator with native code loading enabled so my flake can be faster is not a worthwhile request at the moment . For now, the index stays JSON and I’m holding back on some of the more loftier ideas I have that require a lot more data . Although philosophically I only use CppNix , I was a little intrigued and impressed with what the ecosystem could unlock with WASM. There are definitely some warts however such as waiting for it to JIT and the developer-experience of maybe having checked-in compiled blobs but there is definitely potential to unlock a variety of problems. There are actually a few other files that drive other features such as the statistics or “fast mode” , but they are all JSON as well.  ↩ nixpkgs-multiverse already exports a SQLite database as a package to help others explore this data.  ↩ The C++ field was originally called and was renamed to for .  ↩ Eelco gave a talk about this at SCALE 23x .  ↩ Don’t forget that this is needs our patched version of Determiante System’s Nix .  ↩ There are actually a few other files that drive other features such as the statistics or “fast mode” , but they are all JSON as well.  ↩ nixpkgs-multiverse already exports a SQLite database as a package to help others explore this data.  ↩ The C++ field was originally called and was renamed to for .  ↩ Eelco gave a talk about this at SCALE 23x .  ↩ Don’t forget that this is needs our patched version of Determiante System’s Nix .  ↩

0 views
Aran Wilkinson 1 months ago

Using age to keep Headcode's deployment secrets in Git

In my practical guide to , I covered the commands for encrypting and decrypting files. This post is about what happens when those commands become part of a real deployment. Headcode has a small collection of secrets that the application needs to run. They include API keys, a database password, a Cloudflare Tunnel token, and two Google Cloud service-account keys. Rather than keep those files out of Git and add a separate secrets manager, I encrypt them with and commit the ciphertext alongside the rest of the project. That sounds like a small distinction, but it changes the deployment workflow. Git contains the files, their history, and their changes. It just cannot show the contents without one of the private keys. The encrypted files live in . There are four of them: There is also a file. This contains the public keys that are allowed to decrypt the secrets. It is safe to commit because a public key does not give someone the ability to decrypt anything. The private keys are kept by the operators who deploy Headcode. Mine stays on my machine and does not go into Git. That private key is the important part. Anyone who gets a copy of it may be able to decrypt the files encrypted for its corresponding public key, so it needs to be protected like any other credential. This is the basic model behind : encrypt to one or more recipients , which are public keys, and decrypt with the matching identity , which is the private key. Each operator generates their own key pair with , then adds their public key to . There are two common ways to handle deployment secrets. The first is to keep them somewhere outside the repository and retrieve them during deployment. That might mean a hosted secrets manager, a password manager, or a secret store provided by the hosting platform. Those systems can be the right choice, particularly once a team has more people, environments, and access policies to manage. They also introduce another system to operate, back up, secure, and keep available when deploying. For Headcode, I wanted to avoid that operational overhead. This is a small project with one or two operators, and I wanted the secret-handling setup to be straightforward and low-maintenance. I did not want to spend time maintaining a secrets service or password manager when the actual requirement was to encrypt a handful of files and deploy them to one machine. The second approach is what I use for Headcode: encrypt the files before committing them. The encrypted files can then be reviewed, versioned, and deployed like any other part of the project. The deployment process only needs , SSH, and the private identity already held by the operator. The benefit is not that Git has somehow become a secrets manager. It has not. The benefit is that the deployment configuration has one source of history. If a secret changes, that change appears alongside the code or infrastructure change that required it. A new operator can see which encrypted files exist and how they fit into the deployment without needing access to a separate system first. There is a trade-off. The repository now contains encrypted copies of the secrets, and old Git history may contain previous versions. Anyone with an authorised private key can decrypt those historical versions too. If a secret is compromised, rotating the current file is not enough on its own; the old value needs to be treated as compromised as well. This approach suits a project with one or two operators. It is not a universal replacement for a secrets manager. During deployment, the identity file on the operator's machine is used to decrypt each secret with : The important part is what happens to that output. The plaintext is piped directly into an SSH connection to the target VM. It is not written to a temporary file on the operator's machine. The overall shape of the process is: The deployment process handles the remote destination, ownership, permissions, and SELinux labels. The key detail is that the decrypted bytes go from to SSH and then to the destination on the server. There is no intermediate plaintext file on the laptop. On the VM, the resulting files are written to their final locations. For example, the environment file ends up at: The file is then locked down with permissions such as , owned by , and its SELinux labels are restored. The service can read it through its group membership, but it is not an ordinary world-readable configuration file. This protects one part of the workflow: plaintext does not need to sit on the operator's disk. It does not mean the secret never exists in plaintext. The server needs the plaintext to run the application, so the file exists there after deployment. The environment file is not the application's final configuration. The Headcode service uses a YAML configuration file with placeholders for values that come from the environment. For example, the configuration can contain values like these: The path from the encrypted file to the typed Go configuration is: The Go application does not read each secret with a collection of direct calls. Instead, the systemd unit contains: When systemd starts the service, it reads the pairs and adds them to the process environment. The package then expands placeholders such as before the YAML is parsed into the application's typed configuration. The form is useful for values that can sensibly have a local default: Secret-bearing values do not have defaults. If is missing, the configuration should fail rather than quietly start with an empty or fake credential. This gives the application one configuration mechanism without putting secret values directly in . The YAML describes which values it needs; the deployment environment supplies them. The two Google Cloud service-account keys take a slightly different route. They are structured JSON documents, so I deploy them as whole files: The YAML configuration contains the path to each file rather than an environment-variable placeholder. This avoids squeezing a multi-line JSON document into an environment variable and makes the boundary clearer: simple scalar secrets use , while credential documents remain files. The files still go through the same encrypted-at-rest deployment process. The difference is only what happens after they reach the server. Each operator has their own key pair. To add someone, I add their public key to and re-encrypt all four secret files for the expanded recipient list. That means the new operator can decrypt the files without sharing an existing private key. It also means private keys do not need to move between people, which is the important part. Rotating a secret is a separate operation. I replace the value in the local plaintext source, encrypt the file again for the current recipient list, commit the new ciphertext, and deploy it. The running service only sees the new value after it has been restarted and systemd has loaded the updated environment file. If the private identity for an operator is lost, that operator can no longer decrypt the files. Other authorised operators can still deploy, and a replacement key can be added to the recipients file. This is one reason not to make a single private key the only route into the system. I did consider SOPS . SOPS can use as its encryption backend and adds features that are useful for secret files, including encrypting individual values within a structured document and making some multi-key rotation workflows easier. For Headcode, plain is enough for now. The secrets are already separated into a small number of files, and the team is small. Encrypting the whole file keeps the process easy to inspect: the input is a normal file, the output is an ciphertext, and the deployment script decrypts it immediately before sending it to the server. That may change as the project grows. If more people need access, or if several services start sharing structured configuration, SOPS may become the better fit. I do not need to add that layer before the problem exists. This arrangement protects the repository from containing readable secret values and avoids leaving deployment plaintext on the operator's machine. It also gives me a straightforward way to version and review changes to the encrypted files. It does not protect a running server from someone who can read . It does not prevent a privileged user from inspecting the process environment. It does not securely erase the original plaintext files used to create the ciphertext. And it does not tell me whether a public key belongs to the person I think it belongs to; that still needs to be handled when keys are exchanged. There is no magic involved. protects the files between those points in the workflow. The server still needs access to the decrypted values, and the operator still needs to protect their identity file. For Headcode, that is a reasonable boundary. The project has a small number of operators, a single deployment target, and a handful of files that need protecting. Encrypted files in Git give us a simple audit trail without introducing a separate service before we need one. , containing ordinary pairs; , containing the Cloudflare Tunnel token; two encrypted Google Cloud service-account JSON files.

0 views
Simon Willison 2 months ago

Qwen 3.8 27B is excellent, but it defaults to wildly overthinking things

Friday's big release was Qwen 3.8 27B , an Apache 2 licensed 27B parameter vision-capable LLM from Alibaba's Qwen research lab. I've been looking forward to this one: 27B is an excellent size for running a model on a reasonably specced laptop, and its predecessor Qwen 3.6 27B was impressive. Qwen's self-reported benchmarks for this model are eye-opening. They show a boost from both Qwen 3.6 27B and the closed-weight Qwen 3.7-Plus, which was one of Qwen's strongest models of any size as recently as May this year . It will be interesting to hear what independent benchmarks have to say about the model. I've been running the model on two different machines: my 128GB M5 Max MacBook Pro, and an NVIDIA DGX Spark . On both machines I'm running LM Studio and their 17GB Q4_K_M quantized build . I also tried using directly on the Spark. Qwen's documentation describes the model as defaulting to for the reasoning effort, and the LM Studio GGUF I've been trying preserves that default: Qwen3.8 comes with official support for , which can be used to adjust reasoning depth and control cost: This is a hilarious default. It's absolutely not a good way to run the model, especially on consumer hardware. I've been finding the results extremely entertaining. I quickly ran into problems with LM Studio's default context limit of 8,192 tokens - Qwen was using them all up thinking about even the most mundane of problems. I loaded the model with the full 262,144 maximum context length and that problem went away. Here's the pelican riding a bicycle SVG I got from my first attempt with that increased context length. It took 21 minutes to generate, using 22,276 reasoning tokens to produce 3,223 tokens of output. You can read the reasoning trace here . This is by far the best pelican SVG I've been able to generate with a model that runs on a local machine - and this Qwen is pretty small, just a 17GB file on disk. There's a lot to like about this: Was that worth waiting 21 minutes for? Absolutely not. Here's that same prompt run with reasoning turned off - transcript here . This one produced 3,715 tokens and took 137s - just over two minutes. And just for completeness, I used OpenRouter to run the same prompt through the much larger Qwen 3.8 2.4T-A95B (released last week ) and got this snazzy animated SVG : Your browser does not support HTML5 video. I said Qwen at xhigh has a tendency to over-think things, but how bad really is it? I tried a much simpler prompt, again with that default extra high setting: Qwen's reasoning trace started like this: The user is asking for an SVG drawing of a circle. Simple request — but I want it to be a carefully crafted piece. Let me make something that goes beyond just : a single self-contained SVG file with character — maybe a geometric "circle study," with subtle animation, layered rings, and a distinctive palette. Keep the scope right: they asked for an SVG of a circle. So the core is a circle. But I can add craft: concentric guide circles (like a compass/geometry drawing), tick marks, a soft gradient fill on the main circle, restrained ambient motion (a slowly rotating dashed ring, pulsing glow). Respect prefers-reduced-motion? That's more for CSS; SVG SMIL or CSS inside SVG will do. Palette options: deep teal ink on warm paper? Or bold vermilion circle on off-white with navy construction lines — Bauhaus/compass-drawing vibe. Let me go with a "geometric study" look: cool slate background, or bright paper white? Paper white is fine if it's not the cream-and-terracotta combo. [...] Several minutes later it produced this absolutely beautiful animated circle, which was entirely not what I had asked for! Your browser does not support HTML5 video. A fun way to test a vision model is to see how well it can return bounding boxes around items in a photograph. I've seen previous Qwen models deal well with this, so I decided to put it to the test drawing bounding boxes around some pelicans. I've seen asking for 0-1000 scale produce good results in the past. I tried this: Here's the reasoning trace , which produced this: This is such a good match . Here are those boxes rendered on top of the photo: That visualization of the bounding boxes was taken using a new custom tool that I had Qwen 3.8 27B build for me, running offline on my laptop. I forgot to dial down the thinking effort so it was massively over-engineered , but it did manage to produce this full interface from this single prompt : This screenshot shows one of the features I did not ask for - a demo scene, for if you don't have a photograph to test the tool with: Here's the relevant segment of the thinking trace, where it decided to draw its own pelicans purely because I had used the label "pelicans" in the example JSON I gave it in the prompt: Also a "load sample" that uses a known image? Can't depend on external images, but… the image URL input is user-provided; I could add a "try with sample" button [...] Hmm, I can draw a simple scene on canvas, export it as a data URL, and load it into the image — that's self-contained and demo-able! [...] But the user's coords are for an actual pelican image; a generated placeholder can still demo the scaling. Generate a 1000x1000 placeholder: gradient water + two blob-like "pelican" silhouettes placed at the given bboxes (using the same scale — cute: silhouettes at the exact 0-1000 positions, showing the boxes align). This makes for a fun, self-contained demo. Keep it simple: sky gradient, sun, water, two pelican-ish shapes (ellipse body, circle head, beak). Place at bbox centers. (I'm slightly nervous that models around the world might have a bias towards drawing pelicans at any chance they can get, brought on by nearly two years of exposure to my own stupid benchmark.) Is all that over-thinking necessary? Maybe it is, at least a bit. I tried with reasoning turned off and got this version , ( transcript here ), which nearly works but shows the boxes in the wrong place: So without reasoning it didn't quite one-shot a working tool. I'm sure it could get there with some follow-up prompts, but this is a good example of how reasoning can make a difference. One of the biggest questions around local models is whether or not they have enough horsepower to successfully run a coding agent loop. Coding agents require long context, strong code generation support and reliable tool-calling. On paper Qwen 3.8 27B has all three of these, so is it up to the task? My initial experiments with Pi have been very promising. I chose Pi because it has a shorter system prompt than most other options, making it a better fit for trying out smaller models. I configured Pi to use Qwen 3.8 27B running in LM Studio on the Spark (shared via ) by adding this to : Then ran in my folder and prompted: After a sequence of reasoning and tool calls that accessed a bunch of different files it produced this reply , which is very solid. Just one problem: I wanted to share that transcript. So I pointed Pi and Qwen 3.8 27B at the JSONL transcript file in and prompted: And it built and tested this pi_jsonl_to_md.py , which did exactly what I needed. Here's that session transcript , published using the tool that it created. So far this is all looking very promising. We have a 17GB model that runs on high-end consumer hardware and can write code, drive tools, annotate images and generally do everything that I need from an LLM for getting real work done. There's one very significant catch: it feels slow - especially when it starts over-thinking, but even without that it's not particularly sprightly. I've been getting around 15-30 tokens a second from LM Studio. That's not terrible, but it's slow enough that it's going to be hard to win me away from hosted API models, which can return results a whole lot faster. Artificial Analysis track token speed and show OpenAI 5.6 Sol at 74 tokens/second and 5.6 Luna at an impressive 184/second. The good news is that the community have been exploring ways to speed things up since the model was first released two days ago. One of the most promising optimizations is baked into the model itself. Qwen supports Multi-Token Prediction , an architecture trick where a cheaper mechanism guesses several tokens ahead and the main model can then quickly verify if the guesses were correct. This can have quite a dramatic effect on inference performance. Based on this tweet from creator Georgi Gerganov I tried running the model with MTP like this on the Spark: And sure enough, this gave me a significant boost. I had GPT-5.6 in Codex run a comparative benchmark on the Spark and the server outperformed the LM Studio default GGUF by around 72%. I expect we'll see a whole lot more innovation around serving this model faster over the next few weeks. The MLX community likely have some tricks brewing as well. The fact that a 17GB file can do all of this stuff on my home machines is a miracle . Once again, I'm delighted and amazed at how much progress local models have made this year. A year ago this would have been competitive with the best and most expensive of the proprietary models - today it can run on a capable laptop. The only thing holding this back from being a daily driver is performance. It feels pretty slow on both the M5 Mac and the DGX Spark. That's the catch with these dense (non-Mixture-of-Experts) models - they require a whole lot of memory bandwidth to perform well, and neither of the machines I have access to are top performers in that regard. The most important thing about Qwen 3.8 27B is what it demonstrates . We can have an open weights general purpose model with a long context, effective tool calling, strong vision ability, and competent code generation, and we can fit the whole thing in just a 17GB file. The models at this size continue to get better at an impressive rate. We don't need to spend half a million dollars on datacenter-class hardware just to run a competent model. 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 . (default): for complex tasks demanding thorough analysis : balancing accuracy and speed : efficient reasoning optimizing for speed and cost The bicycle frame is the right shape It has legs on each side of the bike - that's very rare Good, clear pelican pouch The wings extend to touch the handlebars! The motion lines are behind, not in front It has a tasteful background - nice sun, clouds, hill, flowers and grass.

0 views