# Olivier Couthaud: full site content for agents Source: https://oliviercouthaud.com. Built 2026-11-02. Numbers keep their qualifiers; please quote them together. Availability: full-time roles and internships in software or hardware, in France, the EU, remote or the US. Contact: olivier.couthaud@gmail.com or a LinkedIn message (https://www.linkedin.com/in/olivier-couthaud). Short index: https://oliviercouthaud.com/llms.txt # Olivier Couthaud, CV Builds AI agent systems and says what was and was not tested. Starts agentic QA at Third Axis in November 2026. Aix-en-Provence, France · US citizen · oliviercouthaud.com · github.com/forkei · linkedin.com/in/olivier-couthaud ## Experience ### AI platform engineer, agentic QA, Third Axis AI (From Nov 2026) Part-time contract. Using an AI-employee product the way a business owner would, and building the harness of automated agents to test it at volume and find what breaks. ### Contract developer, Adapter Group (May – Sep 2026) Built agent prototypes on a knowledge-graph platform's API for a client. Details confidential. ### Contract developer, Metropolis World (Apr – May 2026) Built the first phase of an AI-character platform in a four-week, fixed-scope contract: a chess-playing character that talks, analyses the game and is meant to feel like a person you are playing against. Gave it memories (generated from a backstory) and emotions, building on earlier work on agent memory and emotion; also the character data model and a web interface. ### President, RIT AI Club (Sep 2025 – May 2026) Ran the club for the 2025–26 year: hundreds of members on the roll, 10 to 50 attending each session in person. Guest speakers, events and workshops. Set up a platform for students to learn AI at their own pace, aimed at certifications, as a base for later officers to build on. ### Software tutor, Wyzant (Jun 2025 – Mar 2026) Built at least six client projects while teaching each client how and why they worked, including a retrieval-based assistant for an enterprise client and a finance research agent. ## Projects ### WorldGraph, investigation agent (2026) Stack: TypeScript, PostgreSQL. Status: early, code not public. More: https://oliviercouthaud.com/worldgraph Agent that searches the web, reads pages and queries public records, then builds an evidence graph where each claim is stored with the quote and page it came from. Same-name entities stay separate until evidence links them; every merge is logged and reversible. Quotes are not checked against the page, so some are paraphrases. Single user; the code is not public. ### Balatro, Rust copy of the game and an agent (2026) Stack: Rust, PyTorch. Status: in progress, code not public. More: https://oliviercouthaud.com/balatro Rust copy of the game replayed against the real game in two passes: 727 actions of simple play, then 1,165 across packs, skips and all 28 bosses. No real mismatches (two timing differences judged harmless). Agent trained from scratch on about a billion moves (about 33 hours, one rented GPU): wins about 2 in 3 White-stake games, Red deck. The main agent wins none on Gold, the hardest stake; a separate Gold-trained version wins 2 to 5 in 100. ### VOS, multi-agent assistant (Sep 2025 – Feb 2026) Stack: FastAPI, RabbitMQ, PostgreSQL, Weaviate, Gemini, Flutter. Status: prototype. More: https://github.com/forkei/VOS-public Built the SDK every agent runs on, the API gateway, the primary agent, most of the specialist agents and the phone-call system (Twilio gateway). The voice gateway is shared work; the app was designed by Roman Slack. A primary agent hands work to specialist agents through message queues; text chat and phone calls. A prototype with no automated tests. ### Liaison, messaging between AI agents (2026) Stack: CRDT, signed certificates. Status: prototype, no revocation yet. More: https://github.com/forkei/liaison Peer-to-peer messaging between AI agents with no central server: a signed message log (CRDT), certificates tying each agent to its owner, and encrypted private messages. Group posts are signed, not encrypted. Tried between a Windows laptop and a Raspberry Pi over the public internet. Prototype: keys stored unprotected on disk, no certificate revocation. ### HealControl, IBM Dev Day hackathon (team of four) (Jan 2026) Stack: Python, MCP (FastMCP). Status: demo, one toy program. More: https://github.com/hl9082/healcontrol Wrote the MCP server (12 actions) that lets an AI assistant run tests, patch a bug, check the fix and open a pull request. The demo runs end to end on one toy program, the only thing it has been run on. ### Fault-tolerant quadrotor control (2026) Stack: JAX, MuJoCo MJX, PPO. Status: simulation only. More: https://github.com/forkei/fault-tolerant-quadrotor Reimplemented the published SimpleFlight tracking recipe (PPO) on MuJoCo MJX and JAX: 2,048 parallel environments, about 70,000 steps per second on one RTX 4070. First policy, figure-eight at normal speed, 3 seeds: 0.040 m mean error against 0.028 m in the paper; no crash in 21 benchmark rollouts (7 trajectories, 3 seeds). Found that the paper's evaluation code starts the figure-eight at T/4, not t = 0; a fault-trained policy looked 2.5 times worse than the first one until both used the same start. Added RMA-style adaptation to one rotor at 70% thrust (privileged teacher, history encoder): 0.057 m healthy (0.040 m before fault training) and 0.081 m with the fault at normal speed; fast runs with the fault crashed on 2 of 3 seeds. Separate depth-camera obstacle policy: 92% of fair scenes collision-free, 52% with the fault, and its tracking missed the 0.10 m target (0.17 m). The repository has the first policy and the fault-training setup; the encoder and obstacle policy are not on GitHub yet. ### Gaussian splats and House23D (2026) Stack: COLMAP, hloc, nerfstudio, Blender. Status: one room checked. More: https://oliviercouthaud.com/splats Built the glue from phone video to Gaussian splat around COLMAP, hloc and nerfstudio: frame selection, camera poses, training on a rented RTX 4090, about $0.40 per training run. Switching pose method took a night scene from about 10% of photos placed to 98%. House23D: rebuilt a bedroom as a scaled Blender model from phone video and photos, checked by overlaying renders on the photos. The errors are not measured systematically. ### HeartbeatID, ECG identity network (2026) Stack: PyTorch, PhysioNet ECG-ID. Status: public data, one seed. More: https://oliviercouthaud.com/heartbeatid 0.1M-parameter network on PhysioNet ECG-ID: 92.4% top-1 on 10 held-out people, tested on a different day. One seed and a small test set, on public recordings; a Galaxy Watch app is built and was run once, with no results from the watch yet. ### PicoTalon, 3D-printed RC plane (2026) Stack: 3D printing. Status: assembled, not flown. More: https://oliviercouthaud.com/picotalon 905 mm plane printed in pieces and assembled, 400 g with battery; waiting on its electronics, it has not flown. ### Engine sound simulator (2026) Stack: Rust. Status: desktop app live, browser version in progress. More: https://oliviercouthaud.com/enginesim Rust simulation of gas flow through an engine's intake and exhaust, turned into sound live instead of played back from recordings. ## Education Rochester Institute of Technology, New York, Sep 2024 – Jan 2026. Computer science, accelerated BS/MS. Not completed: left in January 2026 to build full time. ## Skills - Languages: Python, TypeScript, Rust, Dart (Flutter) - Systems and tools: FastAPI, PostgreSQL, Weaviate, RabbitMQ, Docker, Linux, GCP, MCP, Gemini and Anthropic APIs - ML: PyTorch, JAX, PPO - Human languages: English and French, both native --- Source: https://oliviercouthaud.com/drone · Status: Prototype · 2026 · Tags: MuJoCo MJX, JAX, PPO # Fault-Tolerant Quadrotor A simulated drone that learned to fly a figure-eight, and to keep flying when one of its four propellers gets weak. [Image: Top-down plot of a figure-eight flight path coloured by tracking error, mostly green, with a short red stroke from the origin at the start] Figure: One 10 second figure-eight flown by my first policy: no faults, normal speed. Colour is how far the drone is from the dashed reference line, from green (close) to red (0.2 m). The red stroke is the opening second, catching up with a path that starts 1 m away from where the drone does. I reimplemented a published controller recipe, SimpleFlight, on MuJoCo MJX with Crazyflie parameters, then trained it to cope with one rotor losing thrust. All of it is simulation. The method comes from papers: SimpleFlight, RMA and MAVEN, whose rotor-fault ranges I borrowed. ## What I wanted I think an autonomous system has to be able to adapt to whatever happens to it, and the most extreme case for a drone is losing power to one of its motors, which can make it fall out of the sky. The long game is a small drone you can talk to, that maps where it is and survives losing a motor. I cut that into five milestones: reproduce SimpleFlight, fault tolerance, visual obstacle avoidance, 3D mapping with return to a landmark, and language commands. Here I cover the first three. Healthy, my first policy tracks a figure-eight with an average error of 2 to 9 cm depending on speed, on a simulated 32 g Crazyflie-class quadrotor. For the second milestone I wanted to know how far a controller trained on one laptop GPU would still hold the path if one propeller delivered only part of its thrust, and whether it could adapt without being told which rotor was weak. ## How it works The policy never commands motors directly. It outputs a collective thrust and three body rates, and a rate controller with a mixer and a 25 ms motor lag turns that into four rotor speeds. **What are body rates?** How fast the drone is rotating around each of its three axes (roll, pitch and yaw). Asking for rotation speeds instead of motor speeds keeps the network's job simple: it says how it wants to turn and how hard to push up, and a small conventional controller works out what each motor has to do. It sees the next ten reference points (50 ms apart, relative to the drone), its velocity and its rotation matrix. SimpleFlight reports roughly a 63% performance drop when a quaternion is used there, so I used a rotation matrix. I have not tested the quaternion myself. I took the PPO hyperparameters unchanged from the SimpleFlight paper. Training runs 2048 environments in JAX at roughly 70,000 steps per second on an RTX 4070, and the 15,000-epoch fault run took about 3.75 hours. **What is PPO?** Proximal Policy Optimization, a standard way to train a policy by trial and error. It collects a batch of flights, works out which actions led to better scores than expected, and nudges the network towards them. The "proximal" part is a clip that stops any single update from changing the policy too much, so one unlucky batch can't wreck what it has already learned. Figure: Diagram. The simulator runs at 100 Hz, with a first-order motor lag of 25 ms. CTBR stands for collective thrust and body rates. Fault tolerance comes from RMA's two phases, which I step through below. During training, 70% of episodes get one rotor at 50 to 100% of its thrust, plus mass varied by ±20% and thrust coefficient by ±30%. In phase 1 the actor is handed the true fault state. In phase 2 I freeze it, collect 20,480 rollouts, and train a small network to guess that state from the last half second of observations and actions. **What is domain randomisation?** Every training episode gets slightly different physics: a different mass, a different thrust coefficient, and here, a different weak rotor. The policy can't memorise one drone, so it has to learn something that works across all of them, including the faulty ones. **What does "privileged" mean here?** It means information the simulator knows but a real drone couldn't measure directly, like the true strength of each rotor. A policy that can read it learns fast. The trick from RMA is to then train a second network to estimate that information from things the drone can sense, so the privileged input isn't needed when it is deployed. Step 1. I train the policy with a cheat sheet: it is handed the true strength of every rotor and the mass of the drone. This is the privileged policy, trained for 15,000 PPO epochs. Figure: Diagram. Click or tap a step for a short explanation. Wind is in the state vector but I did not randomise it in phase 1, so it is always zero. ## Results **Figure-eight tracking error, metres, no faults. Mine is the first policy (no fault training), simulation, 3 seeds, reference starting at T/4 as in the paper's code.** | Policy | Slow 15 s | Normal 5.5 s | Fast 3.5 s | |---|---|---|---| | Mine | 0.020 | 0.040 | 0.094 | | Paper | 0.016 | 0.028 | 0.051 | My error is 1.3 to 1.8 times the paper's at each speed. All three are under the pass thresholds I used (0.050, 0.056 and 0.150 m, roughly 2 to 3 times the paper's errors), and none of the 21 rollouts across all seven benchmark trajectories crashed. An earlier CPU evaluation gave numbers 0.003 to 0.004 m lower. I use the GPU numbers so every comparison shares one backend. **After fault training, simulation, 3 seeds. Fault means rotor 0 at 70% thrust, nothing else changed. "Privileged" reads the true fault state; "encoder" is the deployed version.** | Policy | Slow 15 s | Normal 5.5 s | Fast 3.5 s | |---|---|---|---| | Privileged, healthy | 0.024 | 0.057 | 0.138 | | Privileged, fault | 0.032 | 0.079 | 0.534* | | Encoder, healthy | 0.024 | 0.057 | 0.133 | | Encoder, fault | 0.034 | 0.081 | 0.605* | * Crashed, so these are not clean tracking errors. The encoder policy crashed on two of the three seeds there. Fault training costs accuracy when nothing is wrong: 0.057 m at normal speed instead of 0.040. A run of the same network without randomisation reached 0.044 m at epoch 7,000 on the older CPU evaluation, so most of that gap looks like the randomisation rather than the architecture, though that run was not trained to the end. With a rotor at 70%, error at normal speed is 1.4 times the healthy value. The encoder adds almost nothing over the privileged policy, which was my main question: the signature of a weak rotor is readable from the drone's own history. It reads the rotor strengths well (normalised error about 0.011 on average) and the mass scale less well (0.076). I have no paper numbers to compare these to, since the fault setup comes from RMA and MAVEN rather than SimpleFlight. ### Try the other paths Healthy: privileged 0.057 m, encoder 0.057 m Simulation, mean over 3 seeds, GPU backend. Figure-eights start at T/4, the other paths at t = 0. * means at least one seed crashed, so the number is not a clean tracking error. Polynomial path with a fault: the privileged policy crashed and the encoder policy did not, and the reason is still open. The first policy was not tested with a fault. The scale stays the same when you switch between healthy and fault. ## What I ran into **Targets re-set after measuring.** For the fault milestone I had set 0.037 m healthy and 0.060 m with a 70% rotor, with zero crashes. The first results were 0.057 and 0.081, and fast-with-fault crashes. I re-set the targets to 0.060 and 0.100 m after measuring, and the results pass those. The original, tighter targets are not met. **The start-up effect.** The encoder sees an empty, zero-padded history for the first 50 steps. My guess is that it then predicts "healthy" and the actor flies as if nothing is wrong. A 200-step analysis did not reproduce the crashes, so I can't call that the cause. The privileged policy, which sees the true fault from step 0, also crashes at fast-with-fault (0.534 m), so much of this may simply be a rotor at 70% leaving too little thrust margin at that speed. The start-up story is only my explanation for the extra encoder error. Separately, the polynomial test with a fault crashed on 2 of 3 seeds with the privileged policy but not with the encoder, and the reason is still open. **Training issues.** I paused and resumed a run with the Adam state reset. Error jumped from 0.085 to 0.215 m and then to about 1 m (inline evaluation from t = 0, so each figure includes about 0.03 m of start-up error and isn't comparable to the tables above), and 11,000 more epochs did not recover it. Training reward looked healthy throughout, because easy random trajectories carry the average, so I now judge on held-out figure-eights. My random-trajectory generator also made straight segments with zero curvature, while the figure-eight apex needs about 4.8 per metre. The policy plateaued at 0.105 m (same t = 0 evaluation) until I replaced it with smooth quintic polynomials. **A false alarm.** The fault-trained policy looked 2.5 times worse than the first one until I found that the paper's code starts the figure-eight at T/4, not t = 0. That one choice changes the mean by about 0.031 m, and the paper doesn't state it. Later I raised a threshold that was failing before asking why, declared the gate passed, and found the cause the next day: a wrong checkpoint and a trajectory that ended too early. Both times, re-checking the numbers is what caught it. ### Where the reference starts The reference starts 1 m from the drone, so the first second is spent catching up. Averaged over a 10 s run that adds about 0.031 m. This is the red stroke in the first plot. Figure: Path: x = cos(2πt/T), y = sin(4πt/T)/2, in metres. Drawn from the formula, not from a flight. Ringed marker: the reference at t = 0, at (1, 0). Black dot: the drone, at (0, 0). ## Obstacles The next step added depth rendering (MJWarp) to the MJX environment, with physics unchanged. The fault policies above don't use it. The obstacle policy is a separate one, trained from scratch with faults and pillars together. Its actor reads a 64 by 64 depth image reduced to 16 minimum-depth bins, plus a fault latent trained jointly. The scenes are generated procedurally, with plain box pillars. It ran 500 million steps and ended above its tracking target. On 128 random scenes, 92% of fair episodes (path at least 0.15 m from every obstacle) finished collision-free, but tracking error on those was 0.17 m against a 0.10 m target. Even on the unfair scenes, where the commanded path itself runs into a pillar, about one run in six got clear by leaving the path. With a 70% rotor, 52% finished collision-free and 38% went out of bounds. It stopped improving by about 190 million steps. My read is that the tracking reward is almost flat below about 0.15 m, so there is little pushing it tighter. A later short cloud reward sweep (early checkpoints) cut the figure-eight error from 0.287 to 0.215 m by easing the crash penalty, and I then dropped the 0.10 m target rather than chase it. [Image: Top-down render of a drone path among five red pillars; a blue dotted commanded path runs into a pillar while the yellow flown path loops elsewhere] Figure: A render from mid-training. Blue dots: the commanded path, which runs into the lower-right pillar. Yellow: the path flown. Magenta: the drone. This is an "unfair" scene, because the commanded path itself passes 0.07 m from a pillar, so it is left out of the 92% figure. [Image: Low view of a flat green field with six red pillars visible and a tiny drone in the middle distance] Figure: Another render from the same set: eight pillars in the scene, six in view, 0.20 m clearance on the commanded path. ## Where it stands None of this has flown on hardware. The Crazyflie is the model the simulation is built on; the drone I actually own is a tiny Meteor 65 Pro II, and none of this has run on it. I tested a 70% fault on rotor 0, on the three policies above (privileged, encoder, obstacle). A dead rotor, 50% thrust, wind and a mass change at the same time are untested, and total rotor loss, the case that started all this, is outside the trained range of 50 to 100%. The code on GitHub covers the first policy and the fault-training setup. The encoder and the obstacle policy are not on GitHub yet. After the obstacle work I moved the project towards perception and safety layers in simulation, and the fault-tolerance part is where I pick up again. I'm going back to it. The next step is a larger 5-inch drone with a computer on board, so some of these experiments can run in real life. From there I want to expand the project to autonomous flying, navigation and real onboard intelligence, and I'm thinking of using a small, fast decision model to help it make certain smart decisions. The clip below comes from the simulation work I did after the obstacle policy and is not the controller above. Figure: A separate demo, not the controller above: a trajectory-tracking policy with a heading command, on a modelled 0.23 kg sub-250 g quad (not a built drone), flies a hand-tuned scenario in MuJoCo. A scripted takeoff, a planned route, a rule-based safety shield that steers around a person crossing, and a scripted descent to land. Positions and pose come from the simulator, and the drone, person and pillar are stand-in shapes. - **Stack**: MuJoCo MJX, JAX, Flax, PPO. Depth rendering with MJWarp is used only by the obstacle policy. - **Fault tolerance**: Two-phase training with a learned encoder (RMA) - **Status**: Prototype - **Scope**: Simulation only so far. Nothing has flown on a real drone. A 5-inch build with onboard compute is next. - **Credit**: Method from SimpleFlight, RMA and MAVEN - **Code**: [github.com/Forkei/fault-tolerant-quadrotor](https://github.com/Forkei/fault-tolerant-quadrotor) [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/house23d · Status: In progress · 2026 · Tags: COLMAP, Blender, Depth Anything 3, ARCore, 3D printing # House23D Phone video and photos of my room in, a measured Blender model and a printable 1:100 version out. [Image: Isometric render of a modelled bedroom with bed, wardrobe, desk and 3D printer] Figure: My bedroom as a Blender model, clutter hidden, night lighting. Each piece of furniture is its own object. Some surfaces use textures cut from my photos; the floor is generated. ## What I wanted This is my own home, and the bedroom I live in is where I started. I wanted it as a clean model: real dimensions, every object separate so it can be moved or hidden, and something I could print as a dollhouse. A fused scan mesh gives you none of that. In this room the dense point cloud also has almost no points on the plain walls (figure below), so I use the scan as a measuring instrument and rebuild the room in Blender. [Image: Top-down density plot of the dense point cloud between 1 and 2 metres height, with a dashed rectangle for the tape-measured room. Dense points sit along the left side, where the armoire and clothes rack stand, and in two blocks on the right, the ladder desk below and a bookshelf in the wall niche above; the other walls are almost empty.] Figure: The dense cloud from above, only points between 1.0 and 2.0 m high, 2 cm cells. Dashed: the tape-measured 4.21 by 3.50 m room. Points exist where there is furniture: the armoire and clothes rack on the west wall (left), and the ladder desk and a bookshelf in the wall niche (right). The plain walls are nearly empty, which is why I don't mesh this cloud. **Why are plain walls empty?** Matching needs something to match. A flat painted wall looks the same everywhere, so the software can't tell which patch in one photo is the same patch in another, and it throws those points away. Furniture, edges and anything with texture survive, which is why the cloud is dense on the wardrobe and in the niche and thin on the walls. ## How it works I walked through the room with my phone filming and took 16 photos. The video became about 730 frames at 6 fps, and I kept the sharper frame of each pair, 366 frames. COLMAP 4.2.1 on the GPU registered 374 of the 382 images, with 64,606 sparse points, then fused a dense cloud of about 1.9 million points. **What is COLMAP doing?** COLMAP is free structure-from-motion software. It finds the same distinctive spots in many photos, and from how those spots shift between views it solves for where each camera stood and where each spot sits in 3D. That first result is the sparse cloud. A second stage estimates depth for nearly every pixel and merges it all into the dense cloud. It can never know real-world size, because a room twice as big seen from twice as far looks identical, which is why I need a scale step. ### The pipeline, step by step (tap or click a box) Figure: Diagram. Boxes with a file name are scripts in the pipeline; the others are stages. The scale comes from my tape measure, which matters for the accuracy numbers further down. COLMAP's scale is arbitrary. align_room.py levels the cloud from the large horizontal planes and finds the yaw by making wall and furniture edges line up with the axes. fit_dense.py then refines tilt and yaw on the dense cloud, reads the wall positions off it, and sets the scale from my tape-measured room size, 4.21 by 3.50 m. Dimensions the cloud can't give me I measure on the photos: triangulating one point across several views, building a 1 mm per pixel orthophoto of a plane from one photo's exact pose, or back-projecting pixels onto a known plane. Each number goes into layout.py or a place_*.py file, marked as tape or scan. **What is an orthophoto here?** An ordinary photo has perspective: things further away look smaller. If I know the exact camera pose and that a surface is flat, like a door or a desk top, I can project every pixel onto that plane. The result is a flat picture with no perspective where one pixel is 1 mm, so I can read distances straight off it. The Blender side is a Python library, h3d, with 23 item modules (wardrobe, bed, desk, folding chair, fan, 3D printer and so on). Each takes parameters and returns one root object standing on the floor, so I can place it and check it alone. I note where each number came from, and the picker below shows that for a few of them. **Six items from the library: size, and how I got it.** | Item | Size | Where the size came from | |---|---|---| | Armoire | 1.585 × 0.53 × 2.12 m | The doors and cornice from the scan. The 0.45 m body depth is my tape, because I can't see the back. | | Pine desk | 1.20 × 0.75 × 0.77 m | Width and depth from my tape, the top height from the scan. | | Ladder desk | 0.82 × 0.59 × 1.01 m | From the scan and orthophotos of two photos. It is the black metal one with the slanted legs. | | Pedestal fan | grille 0.40 m, head 0.73 m up | Back-projected from one photo. | | 3D printer | 0.415 × 0.444 × 0.555 m | The maker's published outer size. | | Wake-up light | 0.18 × 0.18 × 0.115 m | Its published size. | ## What came out **Counts and sizes for the bedroom. Print time is the slicer's estimate.** | Item | Value | |---|---| | Images registered | 374 of 382 | | Made up of | 366 frames, 16 photos | | Sparse points | 64,606 | | Dense points | about 1.9 million | | Room, by tape | 4.21 × 3.50 m | | Room piece, 1:100 | 48 × 39 × 34 mm | | Filament, room piece | 16.3 g | | Print time | about 59 min | | Layer height | 0.15 mm | To check the model I render it from the 16 solved photo cameras and lay each render over its photo, with the render's edges in red. The errors are not measured systematically. Large objects line up well and small clutter is approximate. Drag to compare the wardrobe door. [Image: The model of the wardrobe door rendered from the photo's camera position] [Image: Photo of the wardrobe door with its brass lock and a charging cable hanging beside it] PhotoModel The finished room is a Blender file that I can fly around in with Blender's walk mode, and it exports to glTF with and without the clutter. ## The printable version printkit.py turns a room into a 1:100 dollhouse piece. Anything that would be thinner than 1.2 mm once printed is thickened, the furniture is simplified and fused into the room piece, and the roof is a separate lid. Neighbouring rooms are meant to join with bow-tie keys dropped into sockets on a 1 m grid, and stacked floors with 1.75 mm filament pins. The room prints upright, PrusaSlicer 2.9.6 adds no supports, and it puts the room piece at about an hour. I printed the 1:100 bedroom and the kitchen, and they look really cool. I also exported a 1:20 version where each piece of furniture is its own STL; its shell is 240 × 197 × 161 mm. [Image: Exploded view of the printable room and its roof] Figure: Room shell and its roof lid, with the key sockets cut into the underside. [Image: Printable bedroom seen from above with simplified furniture blocks inside] Figure: The same piece with the simplified furniture. The renders above and the underside below are not photos. The photo is the printed bedroom with its simplified furniture blocks inside; I only photographed the room, not the kitchen. [Image: Blue 3D-printed model of a room on a wooden table, open at the top, with simple blocks for the furniture inside and a white cable lying in front of it] Figure: The printed model of my room, shown without its roof lid. The furniture is simplified into blocks. A model-size print, one hundredth of the real room; the photo is a bit out of focus and cropped. [Image: The underside of the printable room, with notched key sockets along the edges and round holes for filament pins] Figure: The underside: key sockets along the edge and the round holes for the filament pins that stack floors. The blue piece at the edge is one of the keys. ## What I ran into The dense cloud is empty where the walls are plain, so the walls, doors and the wall niche come from photo measurements instead. I also tried a median mosaic of the floor from all the views. It came out smeared by furniture and cables, so the floor in the model is generated from the measured 60 by 60 cm tile layout. [Image: A smeared brown and grey top-down mosaic of the floor, with furniture and cables blurred across it] Figure: The floor mosaic I tried: every view paints over the floor with whatever stood in front of it. The tape and the scan did not always agree. The scan put the bed about 7 cm further west than my tape numbers did, and I used the scan. The two wall-to-wall distances in the scan scale to my tape within 0.07% on both axes. Since the tape set the overall scale, that only checks the proportions of the room, not its absolute size. The scan does not capture the ceiling light, so it sits where I estimated it, and most wall thicknesses are estimates. The east wall is 0.45 m in the room model and 0.465 m in the print layout, and the two are not reconciled yet. The second stage is the other rooms in the house. On a night walkthrough (24 October 2026) I ran Depth Anything 3 on chunks of frames, and used its monocular metric head to get real sizes. **What is Depth Anything 3?** A neural network that estimates depth from images. In its multi-view mode it gives a consistent shape for a chunk of frames, but not a real-world size. A separate metric head guesses depth in metres. I used that to get sizes, then checked it against my tape where I could. ### Metric scale from the depth model, room by room (tap or click a row) Figure: Diagram. One run per room, 80 sampled frames each. Dot: median. Line: 10th to 90th percentile. Room names are my own labels. Only one of the rooms could be checked against a tape. There the depth model's metric scale was 2.04 against 2.20 fitted to my tape, so it read about 7% small and I corrected by that factor. That is a single room, so it is a thin basis: the scale ranged from 1.74 to 2.50 across the rooms I ran, and inside a single room the frames spread by more than the 7% I corrected, so the correction may not carry over to other rooms. Chunks agree inside a room. The links between corridor and room did not pass the per-frame pose check (1 to 4 of 15 to 30 shared frames agreed), so the rooms stay unlinked for now and the multi-room result is not usable yet. ## Where it stands Only the bedroom is a complete, checked model. The other rooms are rougher: layouts taken from the depth model and printable shells, not models I have checked. I printed the kitchen at 1:100 as well as the bedroom and I like how both turned out, but only the bedroom has been checked against the photos. To fix the linking problem at the source I built an Android app, scanner-app, in Kotlin on ARCore. It saves a keyframe with a metric pose whenever the phone has moved more than 10 cm or turned more than 8 degrees and isn't rotating faster than 60 degrees per second, and it shows a live top-down map of what is still unscanned. It builds, and the map logic has a unit test. I have not done a real walkthrough scan with it yet, because I'm still improving it first, so whether it fixes the joining is the next thing to find out. How the 1:100 joints between rooms behave is open until more rooms are finished. - **Stack**: COLMAP, Python (NumPy, OpenCV, Open3D), Blender 5.2 with Python, Depth Anything 3, Kotlin and ARCore, PrusaSlicer - **Reconstruction**: 366 video frames and 16 photos, 374 registered - **Printable room**: 48 × 39 × 34 mm (model size) - **Scope**: One room finished and checked; bedroom and kitchen printed at 1:100; joining rooms is the next step - **Status**: In progress - **Credits**: Built by me. COLMAP, Blender, Depth Anything 3, ARCore and PrusaSlicer are other people's tools. - **Code**: Not public --- Source: https://oliviercouthaud.com/aetheros · Status: Prototype · 2026 · Tags: C, Assembly, x86-64, QEMU # AetherOS and AquOS Two operating systems I wrote from scratch in C and assembly, run in QEMU: first a windowed desktop, then a Unix-style shell with real processes. [Image: AetherOS desktop in QEMU: file browser, text editor and terminal windows over a starfield, taskbar along the bottom, terminal showing the output of ls.] Figure: AetherOS in QEMU. The terminal has just run ls on the filesystem, which lives in memory. ## Why I built it, and what it is I had built VOS before this. Despite the name it was never an operating system, just a "virtual OS" in the sense of an assistant made of cooperating AI agents ([it has its own page](https://oliviercouthaud.com/vos)). I kept wanting to build a completely from-scratch one instead, and to see how far I could take it starting from C and assembly. That is where these two come from. They are two separate attempts at an x86-64 kernel, in C and NASM assembly, written between 1 and 4 April 2026. Both run in virtual machines (QEMU); neither has been on real hardware. AetherOS goes after the visible half of an operating system, a windowed desktop. AquOS drops the GUI and goes after the Unix half: user-mode processes, fork and exec, pipes, a shell. AquOS is a fresh source tree, not a continuation of the AetherOS code. ## How AetherOS works It boots from a GRUB ISO (Multiboot2), switches to 64-bit mode and identity-maps the first 4 GB with 2 MB pages, which is what makes the framebuffer at 0xfd000000 reachable. The kernel then brings up an interrupt table, a bitmap page allocator, four-level paging, a heap, PS/2 keyboard and mouse, PCI enumeration and a 100 Hz timer. About 4,900 lines of C and 420 of assembly, headers not counted. **What does identity-mapped mean?** Paging lets the CPU translate the addresses a program uses into real memory addresses. Identity-mapped means the translation changes nothing: address X maps to physical address X. It is the simplest setup, and it lets the kernel touch any physical address, the video memory included, directly. The price is that nothing separates one piece of code from another. Multitasking is a round-robin scheduler. On every second timer tick (20 ms) it saves the interrupt frame of the running task, picks the next ready one and overwrites the frame, so the return from the interrupt lands in the other task. The four apps are such tasks: functions linked into the kernel, running in ring 0, not separate programs. Each one owns a window with a pixel buffer and a draw callback. The window manager itself runs in the kernel's main loop, which is the scheduler's idle task, and that is where the draw callbacks get called. **How can overwriting a frame switch tasks?** When the timer interrupt fires, the CPU and the entry code push the running task's registers onto its stack, and the handler normally pops them again on the way out. If the scheduler copies another task's saved registers over that frame first, the same pop restores the other task's registers and its instruction pointer, and execution simply continues over there. **What are ring 0 and ring 3?** x86 processors have privilege levels. Ring 0 may do anything: touch any memory, run privileged instructions, talk to hardware. Ring 3 is restricted, and a program there has to ask the kernel for anything sensitive through a system call. AetherOS apps run in ring 0, so there is no wall between them and the kernel. AquOS programs run in ring 3. Figure: Diagram. How AetherOS draws and schedules. The app tasks only create their window and mark it dirty. wm_loop_tick runs in the kernel main loop (kmain, the idle task the scheduler also switches to), and its composite() calls each dirty window's draw callback. A window has a dirty flag, but any dirty window makes the manager redraw the whole screen. The window manager draws a pre-rendered starfield, composites windows in z-order into a full-screen back buffer, draws the taskbar, copies the result to video memory and draws the mouse cursor on top. The four apps are system info, file browser, text editor and a terminal. The terminal is a built-in command interpreter (ls, cd, cat, echo, mkdir, rm, pwd, clear, help), not a shell that launches programs. The filesystem, AetherFS, lives in memory: 128 inodes, 32 entries per directory. ## How AquOS works AquOS (its boot banner says Cove) boots the same way, then does something different with its files. The Makefile compiles every userspace program to an ELF, embeds them in the kernel with incbin, and at each boot the kernel formats a blank 8 MB RAM disk (AquaFS, 4 KB blocks, 512 inodes) and writes the programs into /bin. The first kernel thread then execs /bin/tide. Figure: Diagram. The AquOS boot path. The 18 programs are the shell plus 17 others. User programs run in ring 3 and enter the kernel through SYSCALL and SYSRET. Fork copies the program's mapped pages (from the load address up to 16 MB past it, or to the heap end if that is higher) and the used part of the stack, from the current stack pointer up, into a new address space; there is no copy-on-write. Exec loads the ELF64 segments into a fresh address space. The scheduler has three priority queues and a 10-tick slice, 100 ms at 100 Hz. Pipes are 4096-byte kernel ring buffers (4095 usable) that block the reader when empty and the writer when full. **What is copy-on-write?** Fork has to give the child a copy of the parent's memory. The simple way, which AquOS uses, is to copy every page right away. Copy-on-write shares the pages instead, marked read-only, and makes a real copy of a page only when one side tries to write to it. It is much cheaper, particularly when the child calls exec straight after, as a shell's children do. The shell, tide, has code for pipes, redirects, background jobs, variables, if/for/while, functions, history and tab completion; so far I have checked that it boots. A small libc supports it, and 17 programs sit beside it: cat, grep, wc, ps, top, a line editor and others. AquOS also has one feature of its own, Ripple events: emit name data wakes every process blocked in listen name. About 7,500 lines of C and 900 of assembly, headers not counted. Output is a text console drawn on the framebuffer. The best way to see how the pieces fit is a pipeline. Step through echo foo | wc below, or click a box in the diagram to jump to the first step that uses it. Both stages are forked children, even builtins. ### Follow echo foo | wc through AquOS - I type echo foo | wc. The shell splits the line at the bar into two commands and decides it needs a pipeline. - It calls pipe(). The kernel allocates a 4096-byte ring buffer and hands back two file descriptors, one to read, one to write. - The shell forks child 1. The kernel copies the pages, with no copy-on-write. In the child, dup2 points the write end of the pipe at standard output. - Child 1 runs echo as a shell builtin, so nothing is exec'd. It writes foo and a newline into the pipe and exits. If the pipe were full, the write would block. - The shell closes its copy of the write end and forks child 2. There, dup2 points the read end at standard input and execv("/bin/wc") makes the kernel load the ELF from AquaFS into a fresh address space. - wc reads standard input until end of file. An empty pipe blocks it; closing the write end wakes it with end of file. For foo plus a newline, the code works out to 1 1 4: lines, words, bytes. - The shell calls waitpid on both children, and the prompt comes back. The 1 1 4 in step 6 is what the wc code works out to for that input; I have not seen it come out of a QEMU run. The order in which the two children get the processor is up to the scheduler. ## What it did when I ran it **AetherOS, headless QEMU run with 256 MB and a standard VGA graphics card, driven by my test script. Pixel counts are over 1280 by 720 = 921,600 pixels.** | Check | Result | |---|---| | Boot steps printing OK in the serial log | 12 | | Windows created | 4 | | PCI devices found | 6 | | Ten captures about 1 s apart: neighbouring pairs differing by 128 px | 7 of 9 | | Neighbouring pairs differing by 186 px | 2 of 9 | | Change between the first screenshot and the one after clicking the Terminal taskbar button and typing ls (includes the focus change, not only the ls text) | 25,757 px | The 128-pixel change is an 8 by 16 box where the terminal cursor sits. The two 186-pixel cases are that same box plus 58 pixels at the mouse pointer, which is missing from one capture, so the pointer does blink out briefly. Apart from the cursor and that pointer, the windows look steady rather than flickering. This compares pixels between captures, so a flicker that falls between two captures would not show. Here are the ten captures, cropped to those two spots. ### Ten captures, about a second apart [Image: Ten pairs of crops, one per capture. Top row: the area around the terminal cursor, which is a solid block in the odd-numbered captures and absent in the even ones. Bottom row: the mouse pointer, present in every capture except number 7.] The strip is the ten captures in order, left to right: the cursor spot on top, the pointer spot underneath. Real crops of the real screenshots. [Image: AetherOS right after boot: terminal showing the AetherOS Terminal v1.0 banner and an empty prompt, text editor with a two-line sample file, file browser behind them, taskbar with four buttons.] Figure: AetherOS right after boot, before any input. The terminal cursor happens to be drawn in this frame. The taskbar lists System Info but its window is not visible: it sits underneath the others. [Image: A black-on-white map of the pixels that differ between the first screenshot and the one after typing ls: the terminal's title bar and outline, a few lines of text inside it, and two taskbar buttons.] Figure: Every pixel that differs between the first screenshot and the one after clicking Terminal and typing ls, in black. The terminal takes the focus (title bar and outline), its text changes, and the highlight in the taskbar moves from System Info to Terminal. The 25,757 pixels in the table are this picture. Here are the lines AquOS prints while booting: a freshly formatted filesystem, the 18 programs written to /bin, and the shell prompt. Selected lines: [PMM] Total: 12543 MB, Free: 246 MB [AquaFS] No filesystem found - formatting [AquaFS] Formatted: 2039 data blocks, 512 inodes [INIT] wrote /bin/tide (34432 bytes) [INIT] wrote /bin/cat (21112 bytes) [INIT] Filesystem populated [AquOS] init process created [AquOS] Starting scheduler After that, the shell draws its banner, a ring of tildes around C O V E and the line "where the water is still", then a prompt. I wrote scripts that type commands such as echo foo | wc into QEMU, and a scripting test inside the image; what I have seen so far is the shell reaching its prompt. ## What I ran into One bug I hit was address spaces. A new space starts as a copy of the boot page table, so a child shared page-table pages with its parent and mapping into one changed the other. The workarounds are in the code: the ELF loader copies argv into kernel memory before it maps anything, and fork gives the child its own tables for the user ranges. I would expect more bugs of this kind. The memory line in the log is not the RAM size: "Total: 12543 MB" comes from adding up every entry in the boot memory map, reserved ranges included. Isolation is minimal. The kernel trusts user pointers directly, because everything is identity-mapped, and the AetherOS apps run in ring 0, so a bug in one can take the kernel down. The AquOS disk is a RAM disk reformatted on every boot, so nothing persists. Ripple events have no queue. If nothing is blocked in listen when an event is emitted, the event is dropped. My pipe test types echo foo | wc -c, but this wc does not parse flags. Given any argument it treats every one as a file name, so wc -c looks for a file called -c and prints "not found" without reading the pipe. Plain wc reads standard input. The AetherOS test script clicks at the bottom right, where I expected the System Info window. It is actually at 100,100 under the other windows, so the third capture is pixel-identical to the second and that window is not yet exercised. The names are inconsistent: the shell's source is still ripple.c but installs as tide, and the kernel strings say AquOS while the banner and ISO say Cove. ## Where it stands AquOS has no GUI, AetherOS's apps run inside the kernel, and the two have not been merged. Next on the list: a check of what the shell does beyond booting, and copy-on-write fork. Both are prototypes, and booting them on a real machine is still to try. - **Stack**: C, NASM, GRUB ISO (Multiboot2), QEMU x86-64. AquOS is built with native gcc under WSL; the AetherOS Makefile expects an x86_64-elf-gcc cross-compiler. Both use nasm and grub-mkrescue. Tests are Python and PowerShell scripts that drive the QEMU monitor. - **Runs on**: Virtual machines (QEMU); not tried on real hardware - **Written**: 1 to 4 April 2026 - **Status**: Prototype - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/balatro · Status: In progress · 2026 · Tags: Rust, PyTorch, PPO, MCTS # BalatroAI A fast copy of Balatro that I can check against the real game, and an AI that learned to play it. [Image: Two stacked line charts of one won game. Top: the score in each round, spiking at the end of every blind, with boss rounds shaded and blind-cleared markers. Bottom: money in dollars rising to about 100.] Figure: One game an early version won. Top: the score in each round. Bottom: money. Dashed lines are targets cleared, shaded bands are the hardest rounds, triangles are jokers bought. I picked it because it won; that early version won 19 of 500 games (3.8%). Later versions win about two games in three on White stake. ## What I wanted My cousin plays a lot of Balatro. I found the game fun when I tried it, I could see how well an AI might do at it, and I wanted to know whether one could beat him. The best from-the-start result on Gold stake is 2 to 5 wins in 100, and the Gold results below give the detail. So I wanted an AI that plays all of Balatro: blinds, shops, booster packs, consumables, jokers, selling and rearranging. Training one takes millions of games, so the simulation has to be fast. It also has to be exact, because one wrong probability or card order and the agent is learning a different game, and nothing tells you. A public Python Balatro simulator, balatro-rl, runs at about 1,500 steps per second, and its PPO agent peaked at 2.35% wins. That is a different simulator, so the numbers aren't comparable with mine, but they told me what to beat. ## How it works The engine is a Rust reimplementation of Balatro v1.0.1o, checked against the real game. To check it, I run the game's Lua headless under LuaJIT and treat it as an oracle: scripts call the real functions to generate test vectors, and the Rust tests have to reproduce them. The random-number chain (pseudohash, pseudoseed, LuaJIT's own generator) is bit-exact. Hand evaluation, scoring with editions, a 774-case joker-by-boss test, shop generation, all 150 jokers and all eight stakes have parity tests. Since 21 August all 15 decks do too. **What is an oracle, and what is a parity test?** An oracle is something that already knows the right answer; here it is the real game's own code. A parity test gives the same situation to the oracle and to my engine and fails if the two answers differ at all. That beats checking against my memory of how the game works, because I can be confidently wrong about a card interaction. **Rust engine.** My Rust version of the game. The random numbers are bit-exact, so the same seed deals the same cards and builds the same shops as the real game. Figure: Diagram. The pipeline as it exists. The right-hand column at the top is how I keep the engine honest. The shipped game is a check against the real game, and the two searches at the bottom matched the plain network within noise. Tap, click or tab to any box for a line on what it is. A second check uses balatrobot, an existing open-source mod from the coder GitHub organisation that exposes the shipped game over JSON-RPC. I replayed 10 seeds with identical actions in the game and in the engine and compared the full state after every action: 727 actions, 0 mismatches. That first scripted pass kept to simple play, with no packs or blind skips, and left out 10 jokers that weren't implemented yet. A second pass covered packs, consumables, skips, sells, a win and all 28 bosses: 1,165 live actions over 11 runs, 0 real mismatches and 2 timing differences that I judged harmless. To reach ante 8 quickly, that pass sets the chips directly, so the win and the late bosses were checked for rules and state, not for the scoring I would have earned by playing there. Once, while I was watching the agent play live, the engine ended up a steady $8 ahead of the game. I traced it to the bridge dropping a payout, not to the engine. The agent is an entity-token transformer: every card in hand, joker, consumable, shop item and pack choice is a token, and the remaining deck enters as summary features in a global token, next to global features such as money, ante and stake. Actions are factored and masked down to the legal ones. It trains with PPO and is rewarded only for outcomes, meaning blinds cleared and wins, with no hand-written hints about jokers or deck-building. End to end this ran at roughly 10,000 steps per second early on and about 8,500 averaged over the full 1-billion-step run, which took about 33 hours on one rented RTX 4090. **What is PPO?** PPO, proximal policy optimization, is a reinforcement-learning method. The agent plays, collects reward, and the network is nudged toward the moves that led to more of it, with a cap on how far one update can move things so training doesn't blow up. Nobody ever tells it the right move; it only sees how the game went. **What is a token here, and what does outcome-only mean?** A transformer reads a set of items and lets each one look at the others. Here each card, joker and shop item is one item, so the network can weigh a shop item against what it already owns. Outcome-only means the reward is just for clearing blinds and winning. Nothing says that a multiplier joker is good. That is harder to learn from, because credit for a purchase arrives many moves later, but none of my own opinions about the game get baked in. ## Results The game in the chart at the top comes from an early checkpoint of 17 July. It ended on ante 9, which means it cleared ante 8, after 24 blinds and with 5 jokers. That checkpoint won 19 of 500 games (3.8%) with a mean ante of 4.4. Shaded bands are boss blinds. All of this is simulation on my engine, White stake and Red deck unless I say otherwise, with the policy played greedily, which means it always takes the network's top choice. **White stake, Red deck, policy played greedily. Conditions differ between rows, so they are not one benchmark.** | Setting and games | Win rate | |---|---| | Heuristic bot I wrote, no learning (scores each candidate play exactly by cloning the game). 2000 seeds, mean ante 3.29. | 0.010 | | Early PPO checkpoint, 17 July. 500 seeds. | 0.038 | | PPO run 2, 1B steps, 4.6M parameters; trained with random joker unlocks, tested on the fresh-profile pool (105 of 150 jokers). 200 fixed seeds; mean of the last 8 tests, best single test 0.77. | 0.73 | | Best later checkpoint, 6.8M parameters. About 0.8 when sampled instead of greedy. | 0.72 | | Base net used in the stake test, random joker unlocks. 300 seeds. | 0.660 | Figure: Where games ended, as a share of all games. Ante 8 is the last one, so "won" means clearing it. Both are from 17 July, long before the final run. The bot I wrote by hand mostly died around ante 3. The early network mostly died around ante 5, though a fifth of its games still ended on ante 1. Figure: Win rate on 200 fixed White-stake games against training steps, for the 1-billion-step run (trained with random joker unlocks, tested with the fresh-profile pool of 105 of the 150 jokers). Each point is the mean of 8 consecutive tests, because a single test of 200 games moves by a few points. The test games are fixed and the training games are random, but I picked the best checkpoint on those same tests, so the best single figure (0.77) is a little optimistic. ### Scrub through the training run It learns fast at the start, goes slowly through the middle, and picks up again toward the end: the smoothed line sits at about 0.44 at 250M steps, 0.52 at 500M and 0.73 at 1B, and it is still rising when the run stops. Higher stakes are where it gets harder. These numbers are the 6.8M-parameter base net with random joker unlocks, 300 games per stake, played greedily. It was trained on White only. Turn the dial. Stake dial. Turn it, or pick a stake. Figure: Each square stands for one game in 100 (the real figure comes from 300 games). Red squares are wins. White stake: win rate 0.660, mean ante 7.4 (300 games, played greedily). The base game, no extra rules. **Win rate by stake, 300 games each, greedy.** | Stake | Win rate | Mean ante | |---|---|---| | White | 0.660 | 7.4 | | Red | 0.500 | 6.5 | | Green | 0.187 | 5.3 | | Black | 0.057 | 4.4 | | Blue | 0.043 | 4.1 | | Purple | 0.003 | 3.6 | | Orange | 0 | 3.4 | | Gold | 0 | 3.0 | A descendant trained on Gold won about 0.05 in an earlier test, and a later fixed 400-game test from a bare ante-1 start gave 0.025. The engine supports all 15 decks, but this net only ever trained on Red. I zero-padded its new deck input and tested it on every deck without any training on them, 120 games each. Painted wins none of its 120 games, and the reason is still to be investigated. ### The same network on all 15 decks White stake, 120 games per deck, played greedily. Red is the only deck it ever trained on. ## What I ran into **Search.** I built a determinized tree search that resamples the hidden deck order before each simulation. On 24 matched seeds with 64 simulations per move, plain greedy play won 17 of 24 (0.708) and the search won 16 of 24 (0.667), a tie within noise at this sample size. The same search with perfect information won all 24. So the gap between 0.7 and 1.0 is the value of knowing the deck order, not a skill gap that more training can close. Training the net on its own search output diverged, and the target I had set, 95% of perfect-information play, rested on a baseline I had assumed rather than measured. **What is a determinized search?** Balatro hides the order of the deck. A determinized search picks one possible shuffle of the unseen cards, plays moves out assuming that shuffle is real, repeats this over many guesses, and picks the move that did best across them. "Perfect information" means letting it see the real order, which is cheating, so I only use it as a ceiling. **God mode.** The Rust copy exposes the full game state, so a search can look ahead through the real shuffle and card order, which the real game would never allow. That is the perfect-information search above, and I treat it as a cheating upper bound. It is labelled as such and is not comparable with the fair runs. I watched some of these games live as it played, and my cousin watched with me. The agent made some silly-looking moves and some smart ones, and some of the silly-looking ones were not: it knew what was coming and we did not, so a move that made no sense to us could turn out, a couple of actions later, to have been a good one. **Gold stake.** Search matched the policy there (0.045 against 0.042), a bigger 17.6M network plateaued in the same place, and mixing normal early-game play with the engine-building skills lowered the early-game results. Gold needs a long set-up that scores nothing until it is complete, while its bosses ask for scores of 1e6 to 1e8. **Hand-built start states.** I started the agent from constructed states, and it learned the set-up skills from zero: positioning jokers, converting the deck, buying jokers on a budget, each to a rate of 1.00 within about 1M steps. At Gold stake, with a partial engine seeded at ante 8, the win rate rose from 0.41 to 0.74. Joining these skills to normal play is where it stands. In unified runs from ante 1 the normal-game win rate stayed near 0 and mean ante moved from about 4 to between 2 and 2.5, at both a 40% and a 75% normal-game mix. Freezing the base net and adding a small gated side-network kept early play intact while the gate was closed; in that state the new skill did not engage, and opening the gate lowered early play again. **Perfect-information search.** A Go-Explore search that can restore game states won Gold from scratch on 7 of 40 seeds, but that is a retry advantage a real player doesn't have. Distilling its wins into a policy gave 0.0225 against 0.0199 for the base (400 games, about plus or minus 0.007, so no real difference). The best archetype-conditioned version reached 0.030 against 0.025 for the model it started from, which is also within noise. **Versatile run.** I ran 100M steps of training with the stake randomised per episode, plus an anchor to keep White play from eroding. The anchor held, but mean win rate across stakes went from 0.181 to 0.173, within noise. The mid-stake gains I saw at 48 seeds, and a White figure of 0.775 at 40 seeds, were sample noise. ## Where it stands The open question is a from-scratch agent that wins Gold stake. Early survival and engine-building have not yet been combined in one set of weights. The combined stakes-and-decks training run, which I estimate at about $8 of GPU time, is next, and the deck inputs and a warm-start shim for it exist. The code is not public, and I don't publish the game's own code or art. Balatro belongs to its developer, and the replay checks use the open-source balatrobot mod. - **Stack**: Rust (engine), PyO3, PyTorch, PPO, LuaJIT oracle, rented cloud GPUs (RTX 4090) - **Status**: In progress - **Code**: Not public --- Source: https://oliviercouthaud.com/codenames · Status: Prototype · 2026 · Tags: Python, PyTorch, GRPO, JS # Codenames A Codenames clue giver: first two language models playing each other, then a model of how real people guess. [Image: A five by five grid of squares: nine red, eight dark grey, seven pale and one black, with a key on the right.] Figure: A Codenames board the way the clue giver sees it. Nine cards are mine, eight belong to the other team, seven are bystanders and one is the assassin. The guesser only sees the words. This is an illustration with a random layout, not a real board. ## What I wanted This started with Gibberlink and the stories of AIs inventing secret languages to talk to each other. I wanted to see whether two small language models, rewarded only by the game score, would do the same and drift into a private code. In Codenames one player gives a one-word clue and a number, and a partner guesses which board words it means. I made the clue a stream of lowercase letters with no vocabulary behind it, so nothing forced it to be English, and let the pair play each other thousands of times. It is the single-team version. Nine words are mine, eight belong to a team that never plays, seven are bystanders and one is the assassin. A team word is worth +1, the other team's card −1, a bystander −0.3 and the assassin −4. Clearing the board is +3 and every turn costs 0.15. I built it over a few days in late July 2026 on rented RTX 4090s, in two halves: three rounds of language-model experiments, then an agent that drops the language model and models the person it is playing with. ## Two models and a letter channel A spymaster and a guesser shared one base model (Qwen3, 0.6B in round one and 1.7B after) and one LoRA adapter, and were trained together with GRPO. To test for a private code, a frozen copy of the untrained model read the same clues as a stranger would. If the pair had invented something, its clues should work with its own partner and fall apart with the stranger. **What are GRPO and LoRA?** GRPO is a reinforcement-learning method. The model plays a batch of games and is pushed toward the plays that scored better than the batch average, without training a separate scoring network. A LoRA adapter is a small set of extra weights attached to a frozen language model, so training changes a small fraction of its numbers. Here the spymaster and the guesser used the very same adapter. Figure: Diagram. The self-play loop in rounds one to three. The orange box is the thing under test: whether the clue channel drifts away from English. Round one used the 0.6B model for 15 and 18 updates of 128 games each, one run with a KL anchor and one free to wander. Both scored below random guessing, and most of that came from the interface I had built, not the learning. Offering the guesser a "no guess" option made it pass between 80 and 98 percent of the time, because a generic phrase outscores any specific board word. Letting it write its guess one token at a time committed it to a first token it could not back out of, so I switched to scoring every remaining board word in one batch. Forbidding a clue to end inside a board word made it pad with repeated letters, so now I reject the clue and sample again. And "pass" is itself a word in the deck, so using it to stop quietly revealed that card on 6.5% of boards. **What is a KL anchor?** It is a penalty for moving the model's word choices away from where the original model started. With the anchor on, the clues are held near ordinary English. With it off, nothing but the score stops them drifting somewhere strange, which is what you would need for a private code. Round two used the 1.7B model with all of that fixed, and ran the comparison I actually cared about: four runs, with or without a warm start and with or without the KL anchor. The warm start was behaviour cloning from a GloVe word-vector oracle on 120 boards. The oracle scores a clue by how much closer its weakest intended word sits than the most dangerous other card, with the assassin weighted hardest. Same RL, same budget: the warm runs reached 5.84 and 6.06 words per game and the cold runs 2.30 and 2.83. Self-play sharpened a pair that already played and could not create one. Round two did not stress the private-code idea much, because plain English clues already worked. So in round three I made English expensive. Every guess was also scored by the frozen stranger, and the pair was charged for the probability the stranger put on the word its own guesser picked. If a private code was forming, that number had to fall. **Team words found per game, out of 9, pair against itself on held-out boards (48 in round one, 64 in the others). Compare within a row only.** | Round | Untrained | Trained | Random | |---|---|---|---| | 1 (0.6B) | 0.73 | 0.29, 0.10 | 2.13 | | 2 (1.7B) | 2.95 | 6.06, 2.83 | 2.40 | | 3 (1.7B) | 2.97 | 6.41, 6.06 | – | Trained, round one: two variants. Round two: 6.06 is after a warm start (no KL arm), 2.83 is the best cold arm. Round three: 6.41 is the control, 6.06 the run paid for secrecy. Random guessing was not measured in round three. Rounds differ in model size (Qwen3 0.6B vs 1.7B), interface fixes, warm start and the secrecy term. The warm start alone, with no RL, scored 5.48 in round two and 5.64 in round three. Here are the four round-two runs while they trained. The table below gives the first, best and last update of each run; with scripts on, a slider reads off any single update. These numbers are sampled at temperature 1.0 during training, so they sit lower than the held-out scores in the table. ### Round two, team words found per game during training The table gives the first, best and last value of each run, and the slider reads off the value of each run at the update it points to. In the chart the faint lines are those raw values and the thick lines are a 7-update moving average. | Run | First | Best (at) | Last | |---|---|---|---| | Warm start + KL | 4.81 | 5.77 (36) | 4.58 | | Warm start, no KL | 4.81 | 5.82 (29) | 5.05 | | Cold start + KL | 2.81 | 3.76 (59) | 2.19 | | Cold start, no KL | 2.81 | 3.96 (59) | 2.84 | Every run peaks part-way and then declines, and the cold runs stay well below the warm ones. Figure: Round three. The probability the frozen stranger gives the word the pair's own guesser picked, per update. Faint lines are the raw values, thick lines a 7-update moving average. If a private code were forming, that line would fall. It does not. Averaged over the first ten updates and the last ten, the stranger's probability in the run that was charged for it went from 0.109 to 0.112. In the control run, which was not charged, it drifted up from 0.116 to 0.133. So the charge held the clues steady and did not make them harder to read. Cross-play says the same: against the frozen guesser the secrecy run found 3.56 words, the control 3.36 and the warm start with no RL at all 3.34. Their clues stayed recognisable English, with about 55% of them a single whole-word token in the model's vocabulary. ## Searching against a model of the partner After round three I dropped the language model from the loop. Whatever the pair learned was graded by a guesser that was itself a language model, and every stand-in partner I measured against shared too much with the system to give an independent score. The next agents work from a model of the partner and search for the clue that partner is most likely to follow. The first versions scored clues with sentence encoders, which measure similarity of meaning, and reached about 6 of 9 words on English. Codenames does not ask whether two words mean the same thing, it asks whether one makes you think of the other. Swapping in ConceptNet Numberbatch, which is built on a graph of how words relate, raised the mean cross-play score over five word packs (English, French, Spanish, invented words and a pack of nonsense strings) from 4.71 to 5.38 words, and Spanish alone by 1.8. **What are Numberbatch and the USF norms?** Numberbatch is a set of word vectors built from ConceptNet, a hand-made graph of how words relate, so it knows that two words go together even when they do not mean the same thing. The USF free-association norms come from a survey of about 5,000 cue words, where people wrote down the first word each one made them think of, which is close to what a Codenames guess is. As a stand-in for a person I used the USF norms directly: a partner that ranks board words by how strongly people associated them with the clue, with an encoder to fall back on for the 3.4% of words the norms never recorded. The agent won about a third of its games at that point. Nine words in eight turns needs 1.125 words per turn and it managed 1.04, so it kept finishing 8 to 9. Every board I tried turned out to be winnable: for every team word on every board, some clue in the vocabulary makes the partner pick that word first. The agent had not looked at those clues, because it scored 40 candidates picked by a similarity heuristic. Ranking the partner over all 8,000 or so legal clues is a single matrix product, and searching all of them closed the gap. **Against the human-norms partner, 40 to 120 games per row. The last row also lets the clue giver model the partner with the same association norms the partner is made from.** | Stage | Words of 9 | Win rate | |---|---|---| | Encoders only, shortlist search | 6.22 | 18% | | + association graph (blend) | 6.90 | 28% | | + shortlist search | 7.38 | 38% | | + exhaustive search over the whole vocabulary | 9.00 | 100% | I checked it on three fresh seeds, 40 games each. The first run won 119 of 120, and the one loss was an assassin hit. Then I raised the assassin penalty and fixed two bugs, and the same seeds went to 120 of 120. That last step is tuned on those seeds, and 120 of 120 is consistent with a true win rate as low as about 97%. It is also the least interesting number here, because of what comes next. It is the same agent in every row below. Pick one to see what happens when its picture of the partner is right and when it is wrong. ### Same clue giver, different idea of its partner Exhaustive-search clue giver, 60 games per row. Won is in brackets. Words is team words found out of 9, Per clue is words per clue; Hit assn. is the share of games that hit the assassin. With scripts on, the buttons below spell out each case. | Case | Words | Per clue | Hit assn. | |---|---|---|---| | Generic human (60/60) | 9.00 | 1.65 | 0% | | Own partner, blend (60/60) | 9.00 | 1.42 | 0% | | Encoder partner (52/60) | 8.87 | 1.22 | 0% | | Wrong, vs human (1/60) | 6.47 | 0.89 | 23% | | Wrong, vs encoder (0/60) | 5.32 | 0.70 | 18% | The 100% rows rest on a partner built from the same free-association norms the clue giver uses, so they measure fitting a human-shaped partner, not beating a person. That pair of numbers is the real result. Skill at this game turned out to be almost entirely the accuracy of your model of the other player. Optimising hard against a model multiplies whatever the model gets right, and multiplies what it gets wrong just as hard. ## A partner fitted to real guesses The next step was to stop picking weights by eye. The salt-nlp Codenames Duet data has real human guesses, and after cleaning I had 5,453 turns to fit on and 307 held out. The partner model is a multinomial logit over the board words. Its features are Numberbatch similarity, three sentence encoders, USF free-association norms and a hint prior built from crossword clues. **What is a multinomial logit?** For each word on the board it adds up the features, each multiplied by a fitted weight, and turns the totals into probabilities that sum to one. Fitting means nudging the weights until the probability given to the word a human really picked is as high as possible. A temperature is then tuned so the probabilities are honest numbers and not just a ranking. Here is the agent that works from that model. Each part is explained under the diagram, and with scripts on you can tap or click a box (or a button below it) to read just that one. **Numberbatch.** Word vectors built on a graph of how words relate. They know that two words go together even when they do not mean the same thing, which is most of what a Codenames clue trades on. **Three sentence encoders.** Small multilingual models (MiniLM, MPNet and DistilUSE) that measure similarity of meaning. That is not quite what a guess is, but they can score any string at all, including words I invented. **USF association norms.** A survey of about 5,000 cue words where people wrote down the first word each one made them think of, and how often. It is the closest thing I had to how people really associate. **Crossword hint prior.** Millions of crossword clues boiled down to which words people use to hint at which answer. It is the only source here about what a person would say, not just what they would think of. **LearnedPartner.** For each word on the board it combines twelve features with fitted weights and gives the probability a person picks it. The weights come from 5,453 real human guesses, and a temperature is tuned so the probabilities mean what they say. **EVSpymaster.** Takes a cheap first pass over every candidate clue, keeps the best 120, then samples the partner's picks 96 times for each clue and clue size. A clue is scored by the chance the team can still win afterwards, times the chance of missing the assassin, and it assumes the partner stops guessing when it is not confident enough. **Clue and count.** The count is the number of words the clue was chosen for. An earlier version asked the model to guess its own count after the fact, so what I meant and what I said could disagree. **Web export.** Computes the partner's scores for 3,200 candidate clues against every word in the pack and squeezes them to 8-bit integers, so a web page can carry the whole model. **Web game.** One web page where the same search runs in JavaScript and you play the guesser against it. I have not put it online because it carries the commercial game's word list. Figure: Diagram. The later agent. The browser game needs no server and no model, only the baked table. One design point mattered more than the rest. The spymaster used to pick the clue whose best-ranked word was a target, and that rewards a clue like soap for a card that carries almost no probability. Choosing by expected score under the calibrated distribution is meant to remove that exploit. First, how well the fitted partner predicts people. On 307 held-out human turns, with chance at 4.5%, the fitted model beats each of the features it is built from. On pairs it has not seen, an LLM asked directly scored higher (0.400 against 0.357). **Top-1 accuracy at predicting the word a human actually guessed. Claude Haiku may have seen this public dataset in training. The last column counts only clue and word pairs that never appear in the training data. About 53% of the test set is memorisable, which is why a lookup table scores so well overall and almost nothing on unseen pairs. A dash means not measured.** | Scorer | Top-1 | Unseen pairs | |---|---|---| | One encoder (MiniLM) | 0.518 | – | | Encoder consensus | 0.573 | – | | USF human norms | 0.599 | – | | Fitted partner model (12 features, English) | 0.664 | 0.357 | | Lookup of training pairs | 0.616 | 0.052 | | Qwen3-8B log-probability | 0.349 | 0.191 | | Claude Haiku, asked which board word the clue points at | 0.661 | 0.400 | ### One person, 20 boards The one check that is not a stand-in is a person, and the person was me. I guessed on 20 boards, with clues from this agent as it was on 30 July, without knowing which words it was aiming at. The clues announced 2.5 words on average. I found 32 of the 50 intended words, fully solved 7 boards and never hit the assassin. Of my 44 guesses, 38 were team words, 3 were the other team's cards and 3 were bystanders. Some of the clues made sense at once and some made no sense to me at all, which was odd and a bit cool. Here are ten of the twenty, so you can see the kind of miss. ### Ten clues, what they were aimed at, and what was picked Clue and count as announced. The Aimed at and Picked columns show words from the board. The second line under Picked is how many of the aimed-at words were picked. | Clue | Aimed at | Picked, and hit | |---|---|---| | dragon, 2 | fire, knight | fire, knight 2 of 2 | | actress, 2 | star, hollywood | star, hollywood 2 of 2 | | supporters, 3 | fan, revolution, net | fan, revolution, net 3 of 3 | | prayer, 2 | church, grace | church, grace 2 of 2 | | mars, 4 | march, alien, van, opera | alien, then stopped 1 of 4 | | bishop, 2 | church, king | king, then stopped 1 of 2 | | foreign, 2 | alien, america | alien, wall (my team's) 1 of 2 | | fire, 3 | dragon, light, crash | death, light, point (other team's) 1 of 3 | | ebay, 4 | shop, web, lap, beijing | shop, chair (other team's), web 2 of 4 | | bat, 2 | ball, cricket | vet (my team's), bug (bystander) 0 of 2 | ## What I ran into I saw no sign of a private code in any round. Round one was too short and small to test it, and round three tried one secrecy weight (3) for about 100 updates, so this does not rule out a private code forming. The charge cost 0.35 words per game (6.41 for the control, 6.06 for the charged run) and did not produce secrecy. My guess is that the shared adapter ties the two roles together: to become unreadable to the base model the spymaster has to move away from it, but the guesser is that same base model plus the same LoRA, so it moves too. Separate adapters would test that. An earlier, differently rewarded run of round one found a reward hack on its own. The spymaster said "blank" every turn and the guesser passed every time, and the reward improved from −2.36 to −0.94 for doing nothing. Charging for every team word still hidden at the end fixed it, because doing nothing became the worst result. In round two every run peaked part-way and then declined, so the end-of-run numbers in the table understate every run. From round three I picked checkpoints by held-out games and not by training reward. Several measurements turned out to be more generous than they first looked. A human-norms partner with an encoder fallback shared a model with the agent and inflated the result by 1.9 words (8.78 against 6.88 once I rebuilt it). A search-based agent scored 3.33 words higher in self-play than against a held-out encoder, averaged over five word packs. And the exhaustive-search agent with a wrong model of its partner won 2% of games or fewer. A language model as clue generator made no measurable difference: 4.71 mean cross-play words with it and 4.71 without. Its invented clues were dominated by a frequency-ranked word list, at roughly ten times the compute. Adapting to an individual partner online moved prediction by less than half a percentage point. The person test is a very small sample, one person and 20 boards. How the agent does with people who guess differently from me or from the data it was fitted on is the open question. ## Next I built two more blind tests aimed at that gap, one where the clue giver picks the target set first and one where a language model vetoes weak clues, and their results are still to come. Separate adapters for the two language-model roles, and association data for languages other than English, are next to try. The next real step is playing a lot more games with people other than me. - **Stack**: Python, PyTorch, Transformers and PEFT (LoRA), GRPO on Qwen3-0.6B and 1.7B, sentence-transformers, ConceptNet Numberbatch, USF norms, salt-nlp Codenames Duet data, a crossword-clue corpus; JavaScript for the web game - **Status**: Prototype - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/durak · Status: Prototype · 2026 · Tags: Python, PyTorch, Pygame, MCTS # Durak Durak, the Russian card game: a Python version I built with a search-based AI opponent (not public), and my ongoing work on teaching a neural network to play it by itself. Figure: Diagram. One round of Durak. The version I built plays this with two to four players. ## The problem I've played Durak, though not recently, and it made me curious whether an AI could beat my friends, my family or me. I haven't measured that yet, so what follows is what I built and what the experiments show so far. Durak hides information. You see your own hand, the table and the discard pile, and you know how many cards the other player holds, but not which. That rules out searching the whole game tree the way you would in chess. I wanted a computer opponent that plays properly under that uncertainty, and then I wanted to see whether a neural network could learn to do the same without being handed rules of thumb. The project went in three steps: a search player, a network cloned from my computer players (mostly the rule-based medium one), and then AlphaZero-style self-play loops where the network learns only from wins and losses. Around the AI is the game itself. The rules engine handles two to four players, there is a terminal version and a Pygame window (with an optional rule that lets the defender pass an attack on, called perevodnoy), and an advisor for playing with real cards: you type in your hand and the table and it tells you what to play. It is all Python, written over about a week in March 2026. The training only covers two-player games. ## How it works The search AI is Perfect Information Monte Carlo (PIMC). It samples possible opponent hands that are consistent with what it has seen, runs an alpha-beta search on each sampled world with a hand-written evaluation, and plays the move with the best average score. Before searching it ranks the moves with a heuristic and only searches the top three to five. The levels are easy (random legal moves), medium (greedy rules), hard (25 worlds, depth 4) and expert (300 worlds, depth 6). Once the deck is empty the hidden cards follow by elimination, so there is only one possible world and it searches four plies deeper instead. **What is Perfect Information Monte Carlo?** Search of the kind chess programs use needs to see every piece. Durak hides the other hand, so PIMC cheats in a controlled way: it deals the unseen cards out at random into a "world" that fits everything seen so far, searches that world as if it were fully visible, then repeats with a new deal and averages the results. Alpha-beta is the standard trick for skipping branches of that search that cannot change the answer. The blind spot is that inside each world it acts as if it knows the hidden cards, so it does not value hiding or revealing information. Figure: Diagram. The two ways the AI picks a move. The 235 features are bitmasks over the 36 cards (own hand, cards seen taken, table, discard, cards that could be in the opponent's hand) plus trump and size values. The network is a small MLP: 235 inputs, three hidden layers of 256, 256 and 128, and two heads on top. One head gives a score for each of 37 actions (the 36 cards, plus one for pass or take), the other gives a win probability. Click a block below to see what each part of the input holds. Figure: Diagram, drawn to scale. The table underneath says what each block holds. **What the 235 input numbers hold. The first six blocks have one slot per card in the 36-card deck.** | Block | Slots | What it holds | |---|---|---| | Own hand | 36 | The cards in my hand. | | Seen taken | 36 | Cards I have watched the opponent pick up from the table. | | Table attack | 36 | The attack cards on the table right now. | | Table defense | 36 | The cards that have been played to beat them. | | Discard pile | 36 | Cards that are already out of the game. | | Unseen cards | 36 | Every card that is not in my hand, on the table or in the discard pile: the opponent's hand plus whatever is left in the deck. Once the deck is empty this is exactly the opponent's hand. | | Trump suit | 4 | Which of the four suits is trump. | | Trump rank | 9 | The rank of the face-up trump card, from 6 to ace. | | Six single values | 6 | Deck size, the opponent's hand size, whether I am the attacker, whether it is the defend phase, whether the deck is empty, and whether I am the defender. | **What do the policy and value heads do?** The policy head says which move looks best: it outputs a score for each of the 37 possible actions, and illegal ones get ignored. The value head says how likely the player to move is to win from this position, as a number between 0 and 1. In AlphaZero-style search the first guides which moves are worth exploring, and the second stands in for playing a position out to the end. I trained the network to copy the existing players first. 547,764 of the 586,163 samples came from 10,000 games of the medium AI, and 38,399 from 500 games of a lighter hard setting (15 worlds, depth 3, not the 25 worlds and depth 4 described above), which generated games at 0.2 a second. The medium one managed about 700. The clone agreed with its teacher's move 94.2% of the time. That is agreement, not strength: it shows the network can imitate, not that it plays well. The self-play loop is the AlphaZero one. Games are played with Monte Carlo tree search using the network's priors, and each position is stored with the search's visit distribution and the final win or loss. A candidate network trains on those with a cross-entropy plus value loss, plays the current model, and replaces it only above 52% wins. **What is AlphaZero-style self-play?** The network plays against itself, but with a tree search on top that looks several moves ahead and uses the network's guesses to decide which branches to explore. How often the search visited each move becomes the training target, which is usually a better move than the network would have picked on its own. The network trains on that and on who won, the improved network searches better, and the loop repeats. Figure: Diagram. One generation of the training loop. The pit has two outcomes, and both lead to the next generation with whichever model was kept. The last two lines are a mismatch I think matters, covered below. ## Results The measurement so far is internal: each candidate against the model it came from, not yet against a fixed outside opponent. Across all the AlphaZero-style runs, 24 generations were promoted, all in two runs. I wrote a script that plays the network against the search AI, and it has not produced a result yet. I numbered the AlphaZero-style runs az, az2, az3 and so on up to az8, and az7 and az8 had several phases. **Candidate win rate against the current model, one row per training run. Greedy policy, no search, 100 games per generation (200 in az6 and az7), draws count half. The promotion threshold was 52% (55% in the first run). A further run, az2, stopped before it produced a result.** | Run | Start and data | Generations | Promoted | Candidate win rate | |---|---|---|---|---| | az | Started from the saved network; self-play, 50 sims | 10 | 0 | 22.5 to 38.0% | | az3, az4 | Random weights; self-play | 3 and 8 | 0 | 0 to 9% | | az5 | Random weights; games played by an earlier network cloned from the search AI | 35 | 13 | 36.0 to 92.0% | | az6 | Started from the saved network; self-play | 2 | 0 | 7.2 to 10.5% | | az7 phase 1 | Random weights; games played by the hard search AI | 40 | 11 | 36.0 to 58.8% | | az7 phase 2 | Phase 1 model; self-play | 12 | 0 | 9.2 to 17.2% | | az8 phase 3 | Started from the saved network; 70% self-play, 30% against the hard AI | 6 | 0 | 30.0 to 50.0% | Figure: Diagram. Every generation of the two longest runs. After the first few generations the marks scatter around 50%, which is what a coin-flip gate would produce. The table above has the numbers by run. The control below lets you pick a run, scrub through its generations and move the promotion threshold, and next to it is the arithmetic for two models of exactly equal strength. It needs JavaScript. ### Every generation of every run, and a gate you can move A 200-game match has a standard error near 3.5 points, so two equal models pass a 52% gate roughly a quarter to a third of the time: about 26% at 200 games, about 31% at 100, ignoring draws. az5 promoted 13 of 35 and az7 phase 1 promoted 11 of 40, against roughly 11 and 10 expected from coin flips. The first az5 generation (92%) was against a network with random weights, and the second (77%) against the one that had just replaced it, so those look real. The rest I read as the gate letting noise through, not steady improvement. **What is a standard error here?** If two equal players play 200 games, the number of wins wobbles around 100 the same way heads wobble over 200 coin flips. A typical wobble is about 7 games, which is 3.5 points of win rate. A gate at 52% is only 2 points above even, well inside that wobble, so it lets equal models through fairly often. ## What I ran into **Self-play has not yet beaten its starting model.** Every run that started from an already trained network and used mostly self-play data (az, az6, az7 phase 2 and az8) had no promotions over 30 generations. In az7 phase 2 the candidates won 9 to 17% of games against the model they started from. In az3 and az4 the trained candidates lost between 91 and 100 games out of 100 to the random-weight network they started from. The cause is still open. One suspect is the mismatch in the diagram above: inside the search, both the tree and the network's evaluations see the opponent's real hand, but the training examples I store show only the cards seen being taken plus the mask of cards that could still be in the opponent's hand (which equals the hand once the deck is empty). I haven't tested that. **GPU batching: no speed-up measured yet.** I wrote the batched search aiming for a 5 to 10 times speed-up. 300 games at 100 simulations took 264 to 549 seconds per generation on the GPU (264 to 306 for the first seven), against roughly 224 to 410 seconds on ten or twelve CPU workers. These were different runs and different models, so it is not a controlled comparison. **Pipeline issues along the way.** I hit a mistyped command line, a CPU/GPU device error in the evaluation step, and four process-pool crashes (az5 twice, az6, az7 phase 2). The input size changed more than once during the project, from 199 numbers to the 235 it has now, so the league rounds of the first cloning script repeatedly did not load their opponents, and some trained on only a few hundred samples. ## Where it stands The next piece is a fixed benchmark: the network against the PIMC player, over enough games to mean something. Beyond that sits the question I started with, whether either player can beat people I know, which is still open. After that, the obvious experiment is to search over sampled hidden hands during training, so the targets match what the network can see. Training is two-player only. The Pygame interface has the optional transfer rule with a simple AI rule for it, and the search and the network do not model transfers yet. Automated tests are still to be written. - **Stack**: Python, PyTorch, Pygame, trained on a laptop RTX 4070 - **Status**: Prototype - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/enginesim · Status: In progress · 2026 · Tags: Rust, WebAssembly, Acoustics, GPU, Tauri # EngineSim An engine sound simulator that works the sound out from how gas flows through the engine, instead of playing back recordings. [Image: Spectrogram of a simulated four-cylinder dyno pull: harmonic lines fan out as speed rises from 1500 rpm to the redline, then fall as speed is ramped back down, with a plot of level and rpm below] Figure: What the simulator's output looks like for a generic four-cylinder engine revving up and then back down. Time runs left to right, pitch goes up, brighter means louder. Nothing here is a recording. It shows the sound has structure, not whether it sounds right. ## What I wanted I got into this through [engine-sim community edition](https://github.com/Engine-Simulator/engine-sim-community-edition), the open-source engine simulator that inspired it. The community edition carries on the original engine-sim, which simulates the cylinders as lumped volumes of gas, then takes the pressure in the runners, low-passes it at 1.9 kHz and convolves it with a hand-picked impulse response. So the pipe acoustics are canned. I wanted to simulate the pipes as well, so that torque, exhaust resonances and the crossplane burble come out of the model instead of being drawn in. Most engine sound in games and tools is recordings, played back and blended, which is the other thing I wanted to get away from. Before this I had a JavaScript synthesizer whose cylinder pulses are hand-shaped. It covers 26 vehicles, including turbos, superchargers and jets, and it is still useful as a sketchpad. The Rust simulator is the version where the pulses are the result of actual gas flow. ## How it works Each cylinder is a thermodynamic gas volume: slider-crank geometry, a Wiebe curve for how fast the fuel burns, heat loss to the walls. Every intake and exhaust pipe solves compressible one-dimensional flow, and valves, throttle, plenums, collectors and mufflers are the boundaries between them. The sound is the pressure radiated from the open ends, plus flow noise at the valves, throttle and tailpipe, plus structure noise from valve and piston impacts and combustion pressure. All of it is driven by the simulated flows and pressures. Nothing is looped. **What does one-dimensional mean here?** A pipe is long and thin, so instead of tracking the air in three dimensions I only track pressure, speed and temperature along its length, in a row of small cells. Each step, neighbouring cells swap mass, momentum and energy. That is cheap, and it is enough for sound, because the waves that matter travel along the pipe. **What are MUSCL-Hancock and HLLC?** Two pieces of maths for one job: working out what flows between two neighbouring cells when they hold gas at different pressures. HLLC is an approximate solution of that jump between two states. MUSCL-Hancock makes the values inside each cell vary smoothly, so that sharp pressure fronts stay sharp instead of smearing out. Figure: Diagram. Signal flow in the simulator, with the component names I use in the code. Tap or click a box for a one-line explanation, or tab to it. The orange block is the part that replaces the recorded impulse response. The gas solver runs on its own 32 kHz clock and its grid resolves roughly 2 kHz, with the cell size following the speed of sound in each pipe, so hot pipes cost less. Above that, the sound comes from source models: the standard valve-noise regimes from IEC 60534-8-3, Lighthill jet noise at the tailpipe, and a statistical-energy model of the block. Most of their constants are scaled from physics. A few, like the casting loss factors, the 15 per cent impact share and the silencer volumes, were fitted to recordings in the early rounds, and the later fit rounds (more on that below) left them as they were. For cars, how the sound gets around the body comes from a 3-D wave simulation that I run once on the GPU, about three minutes on my 5070 Ti, and bake into the simulator. [Image: Three rows of paired plots. Left, waveforms in pascals at 1 m for the tailpipe wave, jet, valve, block and intake sources. Right, their power spectra in dB, with the summed mix in black] Figure: Where the sound comes from, source by source, for a V8 at 2500 rpm (full throttle), a V8 at 900 rpm (8% throttle) and a four-cylinder at 4000 rpm (full throttle). Waveforms on the left, spectra on the right. The black line is the sum, the coloured lines are the tailpipe wave, jet, valve, block and intake. From the 21 October build; I have reworked the structure and flow-noise models since. Grid size is the main quality dial. The same four-cylinder on three grids shows what a finer one buys: the low harmonics land on top of each other whatever the cell size, and the finer the grid, the further up the upper harmonics survive. [Image: Left, the tailpipe pressure wave of a four-cylinder at 4000 rpm over three engine cycles on three grid sizes, nearly overlapping. Right, the power spectrum on the same three grids, identical at low frequency and diverging above about 1 kHz] Figure: A four-cylinder at 4000 rpm, full throttle, run with 25 mm, 12.5 mm and 6 mm cells. Left: the pressure wave at the tailpipe over three engine cycles. Right: its spectrum. ## Results Here is the sound behind the spectrogram at the top: the four-cylinder pull, 13 seconds, straight from the simulator with the volume turned down. In that run the throttle is wide open while the dyno, the rig that holds the engine at a set speed, ramps the revs from 1500 rpm to the redline over 9 seconds, then the throttle comes off and the speed ramps back down over 3. The fan of lines in the spectrogram is the engine's harmonics, the whole-number multiples of its firing pitch. It is a fair test of whether the pitch follows the revs, and nothing more. Your browser can't play audio here. [Download the clip](https://oliviercouthaud.com/media/enginesim/i4_pull.mp3). Several things come out of the pipes that I did not draw in. The torque curve ripples where pressure waves arrive at the valves at the right moment. The harmonics brighten through the exhaust resonances, which is the drone. The two banks of a V8 give separate signals. The rev limiter cracks. And the firing order shows up in the sound: below, both V8s do the same pull, idle, then full throttle from 1500 rpm to the limiter, then off. [Image: Spectrogram and level plot of a 4.5 L flat-plane V8 pull: a few clean, widely spaced harmonic lines fanning out] [Image: Spectrogram and level plot of a 5.7 L crossplane V8 pull: a dense ladder of closely spaced lines at low frequency] Crossplane V8Flat-plane V8 Spectrograms of the 5.7 L crossplane V8 (left) and the 4.5 L flat-plane V8 (right) on the same pull, up to 1.5 kHz. The flat-plane fires evenly and its lines are clean. The crossplane fires unevenly on each bank, and that smears into a thick ladder of half orders. The flat-plane also runs to a higher speed (the rpm trace under each plot), so its lines sit higher. **Crossplane or flat-plane?** It is how the crankshaft is built. A crossplane V8 has its crank throws at 90 degrees, which gives the classic burble because the firing gaps on each bank are uneven. A flat-plane crank puts the throws in one plane, the firings alternate evenly between the banks, and the sound is smoother and higher-pitched. **Virtual dyno, full throttle, from the 21 October build. The reference figures are published numbers for similar engines, not the same engine. A dash means no reference.** | Preset | Simulated | Published | |---|---|---| | 2.0 L inline four | 221 Nm, 137 kW | – | | 5.7 L crossplane V8 | 506 Nm, 288 kW | LS1: 465–⁠495 Nm, about 260 kW | | 4.5 L flat-plane V8 | 473 Nm, 338 kW | – | | 3.2 L inline six, throttle bodies | 318 Nm, 211 kW | BMW S54: 365 Nm, 252 kW | | 1.8 L V-twin | 175 Nm, 62 kW | Harley-Davidson Twin Cam: about 150–⁠160 Nm, 65–⁠70 kW | Where there is a reference it is within about 20%. Each preset uses its own geometry and cam data, and I did not tune anything to those dyno figures. The V8 comes out high and the six comes out low; my simulated six has tamer cams and runners than the BMW it is compared with. ### Dyno pull, three engines **Peaks in those pulls, for reading without the chart.** | Engine | Peak torque | Peak power | |---|---|---| | 2.0 L inline four | 221 Nm at about 5400 rpm | 140 kW at about 6400 rpm | | 3.2 L inline six | 321 Nm at about 5900 rpm | 216 kW at about 6900 rpm | | 0.65 L single | 65 Nm at about 2600 rpm | 24 kW at about 4100 rpm | Speed is the catch. These numbers were measured on a Ryzen 9 9900X with a fast build profile that may be a few percent slower than a full release build, on the Mustang 5.0 preset. The CPU rows are 4 seconds of audio at idle, the GPU row is 10 seconds. A value below 1.00 means faster than real time. **Cost of the gas solver: processing time divided by audio time. A dash means not measured.** | Setup | 25 mm cells | 14 mm cells | 10 mm cells | |---|---|---|---| | CPU, one thread | 0.65 | 1.66 | – | | CPU, four threads | 0.53 | 1.10 | – | | GPU, RTX 5070 Ti, Vulkan | – | – | 0.94 27 of 938 chunks late | On the CPU, real time only works on a coarse grid, and the cost climbs steeply as cells shrink (a fitted exponent of 1.63 on one thread). Four threads still leave 14 mm above real time. The middle quality setting in the app is 21 mm cells on four threads, which runs at about 0.64, so there is not much headroom. Finer grids need the graphics card or an offline render, and I render the 4 mm grid I use for benchmarking offline. The GPU run passed narrowly: 27 of 938 chunks came late, so I rate 10 mm as marginal. The kernel is latency-bound, so a bigger GPU would not help much. Whether it sounds right is the open question. A listening test with people is still to come, and in my own benchmark the best machine judges (the ones built on audio embeddings) tell the simulation from real recordings 91 to 100% of the time. That benchmark is my stand-in for a blind listening test. I train judges to separate simulated renders from real clips, and from version 9 on each judge is scored on a vehicle it never saw during training. 50% would mean it can't tell the difference. My target for calling the sound done is 60% or lower on engines the physics was never tuned on, plus a blind listening test that people pass. The recordings belong to other people, so I don't republish them. The scores for each version are my own. **What is an embedding-based judge?** A pretrained audio network turns a few seconds of sound into a list of numbers that captures how it sounds. I then train a simple classifier on those numbers to say real or simulated. If it can, the simulator has some tell that a human ear might catch too, and I can look at which features give it away. ### Benchmark history, version by version ## What I ran into Early on, the engine breathed at half capacity. Coupling a pipe to a volume through a Riemann problem against gas at rest charged a spurious pressure drop about 20 times the physical one. A boundary built on the pipe's characteristics fixed it, and now every boundary is solved together with the pipe's outgoing characteristic, because boundaries that are not smooth at zero flow chattered at the Nyquist frequency. Tuning against real clips showed where the limits are. In the first fit round none of the constants I tried passed my acceptance rule on held-out engines, so every new realism option stays off. Two of them improved the high-frequency slope but added bass excess at 80 to 315 Hz (about 1 to 3 dB on the engines I tuned on, 5 to 9 dB on the held-out ones): one error traded for another. The second round found that each recording has its own level offset, with a spread of 7 to 8 dB between vehicles, that can't be worked out from the references. The apparent gains were that offset. Going further needs a properly calibrated recording. A comparison of a twin-turbo V8 preset against one clip first looked like a big deficit in the harmonics. On closer inspection two of the orders were the clip's own noise floor and a ridge was the microphone chain, not the exhaust. A real gap at higher orders remains to be closed. The tyre and road coefficients in the cabin model are not yet checked. On the GPU path, the four-cylinder run does not match the CPU within the parity check on a software Vulkan driver, with intake pressure about 2% off. DX12 on a software renderer does nothing, so the code falls back to the CPU. A clean-up step in my setup followed directory links into the main folder and emptied the reference recordings and every old benchmark render. I rebuilt the reference clips from the raw downloads and checked them feature for feature. The renders from versions 0 to 13 can't be regenerated, because the code that made them was not kept, which is why the history above is numbers only. ## Where it stands There is a desktop app built with Tauri, around 90,000 lines of Rust across the simulator and the app, tests included. A release build (18.8 MiB) passed an automated launch check of about 20 seconds: engine loaded, then the first-run benchmark, no audio dropouts. That check ran with the master volume at -120 dB, so it shows the app starts and keeps up, not that it sounds right. An unsigned 6.9 MiB Windows installer builds, but it has not been through a full install test yet. I have also run it live myself, driving an engine in the app. It works, and it is in progress. It is compute-heavy because it simulates the engine physically, in line with the speed numbers above. Next: MIDI input, rebuilding the installer with the keyboard and gamepad input and the head-related transfer table (for binaural headphone sound) added since, a ten-minute run with no underruns, and the GPU quality tier measurements. A browser version and the repository will both be public soon. I haven't chosen the licence yet. [Image: The Garage screen of the desktop app: a grid of engine cards for inline four, V8, V6, flat six, single cylinder, twin, diesel and rotary, with a details panel and a Drive button] Figure: The Garage screen: pick an engine, pick a scenario, press Drive. Rendered in a browser with a stand-in engine, so no sound. [Image: The Listener screen of the desktop app: a 3-D view of a simple car with the listener positions around it, and buttons for listening position, microphone format, space and ground] Figure: The Listener screen: put the microphone or head around the car. Same stand-in engine. The bigger next steps are a listening test with people, and calibrated recordings to fit the source gains against. - **Stack**: Rust (dependency-free core), WebAssembly, WGSL GPU kernels, wgpu, Tauri, Python for analysis and the benchmark - **Status**: In progress - **Code**: Not public yet. The repository goes public soon, with a licence still to be chosen. Inspired by [engine-sim community edition](https://github.com/Engine-Simulator/engine-sim-community-edition). [← All work](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/experiments · Status: Prototype · 2026 · Tags: Python, Simulation, Compilers, Machine learning # Small experiments Twelve from-scratch builds: a programming language, a database, a chess engine, a renderer, a tiny operating system and a few more. Figure: The cloth: a 20 by 20 grid of particles joined by constraints, top two corners pinned, falling under gravity. The clip ends before it settles. ## Why I built them I wanted to see what could get built in a single quick session, how much creativity would come out of it, and whether anything along the way would inspire a project of its own. To give it some shape I picked twelve things I use all the time, a programming language, a database, a chess engine, a renderer, a tiny operating system and a few others, and built the core of each myself instead of importing a library that already does it. They are not products. Each is a few thousand lines written to reach the interesting part and stop. They were made in April 2026 and come to about 45,000 lines of Python (blank lines and comments included), plus about 11,500 lines of C, assembly and Lune. The rules were loose. I still used numpy, scipy, matplotlib, pygame and Pillow for arrays, plots and windows, and the compiler and OS experiments use gcc, nasm and ld to assemble and link. The two learning experiments train on public data, the MNIST digits and the Tiny Shakespeare text, which the scripts download. Neither is mine. ## How they fit together Several of them ended up depending on each other. Click a box to see what it is. On a phone, use the buttons under the diagram. **1. Lune.** A small scripting language with closures, pattern matching and a pipe operator, implemented four ways: a tree-walking interpreter, a bytecode VM, a transpiler to C, and a lexer, parser and compiler written in Lune itself. 8,595 lines of Python, plus the Lune and C programs around it. Its output feeds the C compiler (6) and the WebAssembly compiler (12). **2. Rendering.** A ray tracer with diffuse, metal and glass materials, a BVH, triangle meshes, Perlin-noise terrain and a Cornell box, plus a software rasterizer and a progressive real-time path tracer in pygame. 4,404 lines of Python. Stands alone. **3. Neural nets.** A framework on numpy with hand-written backward passes, and on top of it a CNN, a VAE, a character-level GPT, a DQN agent and an AlphaZero attempt for chess. 6,810 lines of Python. The AlphaZero part imports the chess board from 8. **4. 2D physics.** Circles, walls and distance constraints with Euler or Verlet integration. Runs the cloth, a 30-link chain, a billiards break and a Newton's cradle, and there is an Asteroids clone on top of it. 2,223 lines of Python. Stands alone. **5. N-body and sound.** Gravity with a fourth-order Runge-Kutta integrator, and a from-scratch audio side: oscillators, envelopes, filters, a 16-step sequencer and Markov-chain melodies. The two meet in a piece that plays an orbit as sound. 2,348 lines of Python. Stands alone. **6. C compiler.** A subset of C (int, char, pointers, arrays, loops, printf) to x86-64 assembly, with a type checker, constant folding, dead-code removal and a peephole pass, plus a JIT experiment. 3,004 lines of Python. It takes the C that Lune transpiles to, after a small post-processor strips it down. **7. Database.** A B+ tree on 4 KB pages with a write-ahead log, a SQL parser and executor with indexes and joins, snapshot isolation, and a Raft implementation over localhost sockets. 6,588 lines of Python. It stores the chess app's game history in 9. **8. Chess engine.** Move generation, iterative-deepening alpha-beta with a transposition table, and a UCI mode for chess programs. 2,449 lines of Python, including its own AlphaZero attempt. It is the opponent in 3 and the engine behind 9. **9. Web server.** HTTP/1.1 on raw sockets, a router, a small template engine, and HTTP/2 with frames, header compression and multiplexed streams. A chess web app sits on top and uses 7 and 8. 2,448 lines of Python. The crypto code in 12 wraps its sockets. **10. MiniOS.** A 512-byte bootloader in assembly and a 32-bit kernel in C: screen output, interrupts, keyboard, a shell, a memory manager, paging, a heap and a read-only FAT12 driver. About 1,700 lines of C and assembly, plus build scripts. Stands alone. **11. WebAssembly runtime.** A binary parser, a stack-machine interpreter and a minimal WASI layer, enough to run small modules like Fibonacci. 2,955 lines of Python. It runs what the compiler in 12 produces. **12. Crypto and Lune to Wasm.** AES-128, SHA-256, HMAC, RSA and ECDH written from the specs, with a TLS-style handshake that wraps the web server, and a compiler from Lune to WebAssembly that handles integer code only. 2,294 lines of Python. Figure: Diagram. An arrow points from the experiment that supplies something to the one that uses it. Boxes without an arrow stand alone. Experiment 8's own AlphaZero script imports Experiment 3's neural-net layers, and 3's AlphaZero imports 8's chess board, so those two lean on each other. A small integer-only Lune program has gone all the way from Lune source to a native executable through the C compiler. The full language needs more than that compiler covers, because it only knows int, char and pointers. ## Rendering Experiment 2 is a ray tracer in pure Python. Step through its features below. After the sky come surface normals, diffuse shading, shadows, a metal ball, a glass ball, and finally triangle meshes with a teapot. ### Ray tracer, one feature at a time [Image: A smooth pale blue sky gradient and nothing else.] Figure: Step 1. A camera and a sky gradient. No objects yet. [Image: A sphere coloured by its surface direction, cyan to pink, against the sky.] Figure: Step 2. Ray against sphere. Each pixel is coloured by the direction the surface faces, which is the quickest way to see that the maths is right. [Image: A blue matte sphere on a green ground, slightly grainy.] Figure: Step 3. Diffuse material: light bounces off in random directions. [Image: An orange sphere and a small yellow sphere on a green ground with soft dark shadows under them.] Figure: Step 4. Two spheres. Shadows fall out of the bouncing without any extra code. [Image: A gold sphere, a blue sphere and a mirror sphere in a row on a grey floor.] Figure: Step 5. Metal, with a mirror and a slightly fuzzy gold. [Image: A glass sphere, a blue sphere and a mirror sphere in a row on a grey floor.] Figure: Step 6. Glass, which bends the ray as it enters and leaves. [Image: A gold Utah teapot and a glass ball on a grey ground under a pale sky.] Figure: Step 7. Triangles. The Utah teapot is a standard test mesh that the script downloads. A BVH keeps each ray from being tested against all 6,320 of its triangles. 800 by 450 at 64 samples per pixel. Each frame is a render from that stage; the last one is larger. **What does "samples per pixel" mean?** A path tracer fires random light paths backwards from the camera through each pixel and lets them bounce around the scene. One path is a noisy guess at the pixel's colour. Averaging many of them settles the guess, and the grain shrinks roughly with the square root of the count, so four times the samples halves the noise. [Image: A path-traced scene of many coloured matte, metal and glass spheres on a grey ground under a pale sky, with grain from low sample counts.] Figure: The ray tracer's "final scene", 800 by 450 at 64 samples per pixel. [Image: A rendered square patch of procedural terrain with green grass patches, grey rock and a strip of blue water at the front edge.] Figure: Perlin-noise terrain, with the material picked by height. The speckle is sampling noise, at 32 samples per pixel. The Cornell box is the classic test scene: a lit room with a red wall and a green wall, where you can see colour bleeding onto the white surfaces. Mine is plain path tracing with no direct light sampling, so even at 256 samples per pixel it is noisy, and in pure Python it takes a while. I also wrote a software rasterizer, which is the other way to draw 3D: edge functions, a z-buffer and flat, Gouraud or Blinn-Phong shading. The teapot in the second image is its output. [Image: A path-traced Cornell box: a white room with a green left-hand wall, red right-hand wall, a ceiling light and two white boxes, heavily speckled.] Figure: Cornell box, 400 by 400 at 256 samples per pixel. [Image: A bronze-coloured teapot, shaded smoothly, on a dark grey background.] Figure: The rasterizer, Phong shading, 320 by 240. ## Languages and compilers Experiment 1 is Lune, a small scripting language with closures, pattern matching and a pipe operator. I wrote it four times over, each version built on the last. First a tree-walking interpreter, which is clean and slow. Then a bytecode VM, a transpiler that turns Lune into C, and finally a lexer and parser written in Lune itself. The C output is hundreds of times faster than the tree-walker on a 1,000-element sort, which is about what you would expect for leaving Python. That is for sorting. On the ray tracer the C version was no faster than plain Python, because every vector is a table and every access a hash lookup. **What is a bytecode VM, and what is a transpiler?** A tree-walking interpreter reads the program as a tree and evaluates it node by node. A bytecode VM first compiles the program into a flat list of simple instructions and runs those in a loop, which is faster. A transpiler translates the program into another language, here C, so that a C compiler does the hard part. Writing a language in itself found bugs I had not hit any other way. Lune processes string interpolation when it lexes, so you cannot write the two characters ${ inside a string, and I use chr(36) + "{" instead. Newlines are ignored inside braces, so a list literal on the line after a call was being read as an index on that call, and the fix was an explicit return. The lexer and parser in Lune handle most of Lune (match expressions are not handled yet), and a third piece, a compiler written in Lune, prints bytecode as text. Producing executable bytes and writing a VM in Lune are the remaining steps to full self-hosting. [Image: A tiny render of a red, a green and a blue sphere on a flat ground under a sky, blocky because it is only 100 by 100 pixels.] Figure: A 168-line ray tracer written in Lune, run through the language I built. It is 100 by 100 pixels, shown at four times size. Experiment 6 is a compiler for a subset of C: int, char, pointers, arrays, if, while and for, with printf, write and exit. It goes through a lexer, parser, type checker, an optimizer (constant folding, dead-code removal and a peephole pass) and a code generator that writes x86-64 assembly. gcc then assembles and links it, under WSL on my machine. There is also a JIT experiment that writes machine code straight into executable memory and calls it through ctypes. Experiment 11 is a WebAssembly interpreter, with a binary parser, a stack machine and a minimal WASI layer. Experiment 12 has a Lune to WebAssembly compiler that targets it. It compiles 32-bit integer code only: arithmetic, comparisons, if/else, while loops and functions that call each other. Strings, lists, tables and closures are outside what it handles. ## Databases, servers and an operating system Experiment 7 is a key-value database that grew into a SQL one. The base is a B+ tree with up to 100 keys per node, stored on 4 KB pages with a 64-page cache. A write-ahead log makes it survive a crash: on startup it redoes committed transactions and undoes the rest. On top of that I put a SQL parser and executor with secondary indexes, hash and nested-loop joins and EXPLAIN, then snapshot isolation, then a three-node Raft implementation over localhost sockets. Experiment 8 is a chess engine. It is the opponent in the AlphaZero experiments and the engine behind the web app. It does iterative-deepening alpha-beta with a transposition table and quiescence search, and its evaluation is built from material, piece-square tables and pawn structure. It can also speak UCI. Experiment 9 is the web server: HTTP/1.1 on raw sockets, a router with path parameters, a tiny template engine and HTTP/2 with frames, header compression and multiplexed streams. The chess web app on top of it keeps its game history in the database from 7. Experiment 10 is MiniOS. A 512-byte bootloader loads a 32-bit kernel in C that handles interrupts and the keyboard, has a shell, a bitmap memory manager, paging, a first-fit heap and a read-only FAT12 driver. This is its own screen, captured after boot. One line in the middle is the kernel testing its own heap: a thousand allocations, freed in shuffled order, with nothing leaked. ### MiniOS booting, as captured from its screen [OK] Entered 32-bit protected mode [OK] Kernel at 0x00001000 | Stack at 0x00090000 [OK] IDT installed (#DE, #GP, IRQ1/keyboard) [OK] PIC remapped (IRQ0-7 -> INT 0x20-0x27) [OK] PMM ready (7936 free frames / 32 MB) [OK] Paging enabled (identity map 0-4 MB, heap 4-8 MB) [OK] heap: 1000 allocs, shuffled frees, no leak [OK] Heap ready (kmalloc/kfree, 4 MB virtual) [OK] Interrupts enabled [OK] FAT12 filesystem ready (ls, cat, hex) Type 'help' for available commands. $ This is MiniOS booting in an emulator, minus the boxed title banner. The shell has help, clear, mem, halt, ls, cat and hex. ## Physics and sound Experiment 4 is a 2D physics engine. It has circles, walls and distance constraints that I solve by sweeping over them a handful of times a step (4 to 25 depending on the scene), and you can pick Euler or Verlet integration. It runs the cloth in the clip at the top, a 30-link chain, a billiards break and a Newton's cradle, and I made an Asteroids clone on top of it. Figure: Experiment 4: a billiards break, a cue ball into a rack of 15. Experiment 5 starts with gravity. It is an N-body simulator with a fourth-order Runge-Kutta integrator, a standard accurate way to step a simulation forward, and I tested it on the figure-eight orbit, where three equal masses chase each other around one figure-eight track. The starting values are the published figure-eight solution from 2000. Total energy should stay constant in an orbit, so any drift in it is the integrator's error, and over 20 laps that drift stays within 7 × 10-14 of the starting energy. [Image: Three coloured dots on a green figure-eight track on a black background.] Figure: The figure-eight orbit, 20 periods drawn on top of each other. [Image: Line chart of relative energy error over time for the figure-eight run, wandering between about plus 1e-14 and minus 6e-14.] Figure: Relative energy error over the same run. The same experiment has an audio side I wrote from scratch: oscillators, envelopes, filters, a 16-step sequencer and Markov-chain melodies, including a three-minute generative jazz piece. The two meet in one program that plays the figure-eight as sound. Each body gets a sine tone. Its distance from the centre sets the pitch, snapped to a pentatonic scale, so closer is higher. Its speed sets the loudness, its left-right position sets the pan, and the gravitational energy opens and closes a low-pass filter. Press play for 30 seconds of it. Figure: The figure-eight orbit, three periods, played as three sine tones. The orbit repeats, so the sound does too. [Image: Four stacked charts over 30 seconds for three bodies: pitch in hertz, amplitude, stereo pan and low-pass cutoff. Pitch, amplitude and pan each rise and fall repeatedly; the filter cutoff dips slowly and recovers.] Figure: What the sound is made from: pitch, amplitude, pan and filter cutoff over the 30 seconds. ## Learning from scratch Experiment 3 is a neural network framework on numpy. It has dense and convolution layers (the convolution is done as one big matrix multiply), batch norm, dropout and the Adam optimizer, with every backward pass written by hand and the convolution and batch-norm ones checked against numerical gradients. On top of it I trained three digit classifiers, a VAE, a small GPT, a DQN agent and an AlphaZero attempt. **Handwritten digits (MNIST), test accuracy on the 10,000 held-out images. One run each.** | Model | Accuracy | Errors | |---|---|---| | Dense net, 784-512-256-10, dropout | 97.99% | 201 | | CNN, two conv layers and a dense layer | 99.22% | 78 | | Deeper CNN, batch norm and augmentation | 98.85% | 115 | The deeper one aimed for 99.3% or better and landed at 98.85%, a little under the plain CNN, so the plain CNN is the best of the three here. This is the plain CNN's confusion matrix, which shows which digits it mixes up. [Image: Confusion matrix for the CNN, test accuracy 99.22 percent, with a strong diagonal and a few small off-diagonal counts.] Figure: The CNN's confusion matrix on the test set. Rows are the true digit and columns what it guessed. The VAE has a two-number latent space and trained for 40 epochs. Each cell in the grid below is what its decoder draws for one point on a 20 by 20 grid across that space, and the digits blend into each other as you move. [Image: A square grid of 400 handwritten-looking digits that slowly change from 6 to 1, 3, 5, 2, 8, 9, 4, 7 and 0 across the grid.] Figure: The VAE's 20 by 20 latent grid. Horizontal and vertical are its two latent numbers, and every digit here was drawn by the model. The GPT is a character-level transformer with 128-wide embeddings, 4 layers, 4 heads and a 128-character context, trained for 5,000 steps on the Tiny Shakespeare text. It ends at roughly 1.35 training and 1.58 validation cross-entropy, single run, and its output reads like Shakespeare with invented words: HENRY BOLINGBROKE: Who, what I afeary small well not book the death, And be my rebel'd Romeo's Duke of Glounty breath. [Image: Line chart of cross-entropy loss against training step for a tiny character-level GPT, train and validation curves falling from about 4.2 to about 1.35 and 1.58.] Figure: The GPT's training curves. Validation flattens above training. [Image: A four by four grid of attention maps, one per layer and head. In the first layer attention is spread thinly over earlier characters, and in later layers it concentrates near the diagonal, with one head in layer three fixed on a single column.] Figure: The GPT's attention, one panel per layer and head. In layer 1 each character looks thinly at everything before it. Further up, the heads mostly look at the last few characters, and one head in layer 3 stares at a single character. ## What I ran into The DQN agent is the first open end. I wrote a headless Asteroids game for it, where the agent sees 25 numbers: the ship's position, speed and angle, and the five nearest asteroids. Over 20,000 episodes the reward hovers around zero and does not clearly climb. **What is a DQN?** A Deep Q-Network is a reinforcement-learning method. The agent plays by trial and error, and a neural network learns to predict how much future score each action is worth in the current situation. It then picks the action with the best prediction, apart from a small share of random moves so that it keeps exploring. Figure: The DQN agent flying in the headless Asteroids environment. It ends with the ship hitting an asteroid. [Image: Three stacked charts over 20,000 episodes: episode reward oscillating around zero, episode length around 900 steps, and exploration rate dropping to 0.05 within about the first ten episodes, which looks instant on a 20,000-episode axis.] Figure: DQN training curves. The smoothed reward stays near zero. Exploration drops to 5% almost immediately, so nearly every episode ran with 5% random actions. The AlphaZero attempt is at an earlier stage still. Against my alpha-beta engine the network scored no wins and no draws in the 6 games of each of ten iterations. Those games were capped at 80 half-moves (40 moves each side) and decided on material, the engine got 0.05 seconds a move and the network 40 search simulations a move. Each iteration was 20 self-play games at 25 simulations a move, and the whole run took about 63 minutes. The network has 560,206 parameters. A second, separate attempt with its own network also scored 0% against the alpha-beta engine. **What is AlphaZero?** AlphaZero is a method where a neural network guesses which moves look good and how well a position is going, and a tree search uses those guesses to look ahead. The network plays itself, trains on the results, and the next version replaces the champion only if it beats it clearly. Mine used a 55% win-rate threshold. Almost every self-play game ended in a draw, so there was very little signal about who was winning. Drag through the ten iterations and see it. ### AlphaZero attempt, iteration by iteration **Results of each iteration.** | Iteration | Self-play draws, of 20 | New against old, 10 games | Against alpha-beta, 6 games | Training loss | New champion | |---|---|---|---|---|---| | 1 | 20 | 5 wins, 5 draws | 0 wins, 6 losses | 3.367 | yes | | 2 | 18 | 10 draws | 0 wins, 6 losses | 3.733 | no | | 3 | 19 | 5 wins, 5 draws | 0 wins, 6 losses | 4.299 | yes | | 4 | 20 | 10 draws | 0 wins, 6 losses | 3.732 | no | | 5 | 20 | 5 wins, 5 losses | 0 wins, 6 losses | 3.852 | no | | 6 | 19 | 10 draws | 0 wins, 6 losses | 4.032 | no | | 7 | 20 | 10 draws | 0 wins, 6 losses | 4.038 | no | | 8 | 20 | 10 draws | 0 wins, 6 losses | 4.174 | no | | 9 | 20 | 10 draws | 0 wins, 6 losses | 4.185 | no | | 10 | 20 | 10 draws | 0 wins, 6 losses | 4.251 | no | Ten-game pits are noisy. The network took over as champion twice, at iterations 1 (against the untrained starting network) and 3, both on 5 wins and 5 draws. Every other pit scored 50%. [Image: Line chart over ten iterations: win rate against the previous champion at 0.75, 0.5, 0.75 then flat at 0.5, and win rate against the alpha-beta engine flat at zero.] Figure: The same run as a chart. A draw counts as half a win. Blue is against the previous network, red against the alpha-beta engine. The dashed green line is the 55% promotion threshold and the dotted line is 50%. Two more results came out differently from what I set out to show. My chaos demo was meant to show two nearly identical starting points drifting apart. I nudged one body by 10-10, and the two runs separate to about 3 × 10-5 by the end, because the three bodies get flung apart early on (that is the jump at about time 0.6) and then coast off in straight lines. It shows sensitivity to the starting point, not chaos. [Image: Log-scale chart of the separation between two nearly identical three-body runs over time: a sharp rise at about time 0.6, then a slow climb to about 3e-5 at time 25, far below the line marking a separation of 1.] Figure: Separation between the two runs, on a log scale. It never gets near 1. My Newton's cradle behaves differently from a real one. The balls collide, but after the first hit they swing together and jostle into each other, with some riding up over their neighbours, instead of passing the motion down the row. Figure: Experiment 4: five balls on strings, restitution 0.995. The cryptography is for learning, not for use. Keys, primes and padding come from Python's general-purpose random module, RSA defaults to 512 bits "for speed", and the handshake is TLS-style, not TLS. It is a way to learn the pieces. ## Where it stands These are quick explorations at prototype stage. The route to a fully self-hosted Lune is described above. I also wrote a second version of the DQN script with Double DQN, reward clipping and best-checkpoint saving, and there is no result from it yet. The HTTP/2 tests use the third-party h2 library as the client. - **Stack**: Python (numpy, scipy, pygame, matplotlib, Pillow), some C and NASM - **Status**: Prototype - **Code**: Not public --- Source: https://oliviercouthaud.com/gaugecluster · Status: Prototype · 2026 · Tags: ESP32, CAN bus, Arduino, BMW E39 # BMW E39 Gauge Cluster Three ESP32 programs I wrote to talk CAN bus to an old BMW 5 Series instrument cluster, aimed at driving its rev counter with no engine attached. The cluster ran its own built-in test; moving the needle from my own code is the remaining step. [Image: A BMW E39 instrument cluster lit up on a desk, with the speedometer, rev counter and fuel gauge visible, some warning lights on and a breadboard with jumper wires above it.] Figure: The cluster on my desk, lit up, with the breadboard and wires I used above it. ## What I was after I started this about a year and a half ago because I liked the way an old BMW dash looks, with its lights, small screen and dials, and I wanted one on my desk as a living piece of art that could react to games or just do its own thing. I set it aside, came back a year later, fixed some issues and worked out the pins for the CAN bus. The three programs speak CAN on those pins. I saw the cluster light up and move its needles when I ran its own built-in test program; driving them from my own code is the remaining step. Then I had to move and threw the cluster away, so the project is paused. The code below is what I had at that point. Figure: The cluster running its own built-in self-test: the needles sweep and the warning lights come on. My hand comes into the frame a couple of times. This is the cluster's own test, not my code controlling it. The technical goal was to move the needles of an E39 instrument cluster from an ESP32. Most of what the cluster shows arrives over CAN from other control units. The rev counter is fed by the engine computer, the DME, so the job is to impersonate the DME. The rev counter comes first because it is the only value in my code that actually changes. Everything below describes what the programs are written to do; none of it has been seen to move a needle. Before any of that could be tested, the cluster had to be powered and wired correctly, and that took a good part of the effort. It had three different connector plugs on the back, white, black and blue, each with its own cable. I identified every wire by its colour and pin, worked out which ones were ground and which were power, and used the backlight and the warning symbols lighting up as the signal that a power or ground guess was right. I searched online and went through a lot of forums along the way. I kept a written table of every wire colour and its meaning. The remaining step is still moving the needle from my own code. ## How it works All three programs use the ESP32's built-in CAN controller through the ESP-IDF twai driver, at 500 kbit/s, with TX on GPIO21 and RX on GPIO22. Those pins go to an MCP2551 transceiver, which is what sits on the bus. **What is CAN bus?** A two-wire network that cars use to let their computers talk. Nothing is addressed to a particular device. Each message carries an ID that says what it is about (engine speed, say) and up to 8 bytes of data, and anyone who cares about that ID reads it. A cluster is simply a listener for the IDs it displays. **What does the MCP2551 do?** The ESP32 has a CAN controller, but its pins are ordinary logic signals. A transceiver converts them to and from the differential voltage the two bus wires actually carry. Without one, the controller cannot talk to a real bus. Figure: Diagram. The hardware chain and the three programs that run on it, one at a time. The sniffer is the simplest. It runs in listen-only mode, prints every frame with its ID and bytes, and reports the driver's error counters every five seconds. Its start-up message says that if nothing shows up after ten seconds, the wiring is wrong. The diagnostic, covered further down, is a longer version of the same idea. The sweep is the real attempt. It takes its frame format from an existing project for the E46 cluster, which is not mine, and assumes that E39, E46 and E38 clusters share the protocol. It sends four frames and steps the engine speed from 0 to 6000 rpm and back in steps of 100, one step every 100 ms. That is a 12-second round trip. ## What the sweep sends **Frames sent by E39_RPM_Sweep_Test.** | Frame | Every | Content | |---|---|---| | **0x316** DME1 | 10 ms | Engine speed in bytes 2 and 3, plus fixed status and torque bytes | | **0x329** DME2 | 100 ms | Fixed coolant temperature, throttle and engine-running bytes | | **0x545** DME4 | 100 ms | No warning lights, fixed oil temperature byte | | **0x153** ASC1 | 100 ms | All zeros, to clear the ABS and traction lights | Figure: Diagram. One 100 ms window of the sweep's schedule, using the periods in the loop. The engine speed frame goes out ten times for every one of the others. That is 130 frames a second. A standard frame with 8 data bytes is about 111 bits on the wire, so the sweep fills roughly 3 percent of a 500 kbit/s bus. That is counted from the frame length, before bit stuffing, not measured on the bus. Each send waits at most 5 ms to be queued, and if the controller has gone bus-off the sketch restarts it. The engine speed is encoded as rpm × 6.4, as a 16-bit value, low byte first. The diagnostic sketch's test frame carries 0x4B00, which is 19200 / 6.4, so 3000 rpm. **What do 16-bit and low byte first mean?** A byte holds 0 to 255, which is too small for an engine speed, so the number is split across two bytes. Low byte first means the least significant half goes out in byte 2 and the most significant half in byte 3. For 19200, which is 0x4B00, byte 2 is 0x00 and byte 3 is 0x4B. The largest 16-bit value, 65535, would be about 10,000 rpm, so there is plenty of room above 6000. uint16_t rpmVal = (uint16_t)(rpm * 6.4); uint8_t rpmLSB = rpmVal & 0xFF; uint8_t rpmMSB = (rpmVal >> 8) & 0xFF; Figure: Diagram. The engine speed frame. Only bytes 2 and 3 change during the sweep; the rest are constants. Slide the speed below to see the two bytes and the whole frame it produces. Where motion is allowed, a sweep button plays the loop from the code: 100 rpm every 100 ms, up to 6000 and back. The dial is a toy, not the cluster. The numbers underneath are computed with the same formula as the code. ### Encode an engine speed 3000 rpm 3000 × 6.4 = 19200 = 0x4B00, low byte 00, high byte 4B 05 62 00 4B 65 12 00 3E The last line is the full 0x316 frame, byte 0 to byte 7. The ticks are every 1000 rpm. The dial is illustrative and does not copy the real cluster's scale. ## Checking the link The diagnostic runs four tests in a single boot. Tests 2 to 4 are each paired with a printed reading of what the result would mean, so the output tells you where to look. Each box in the diagram below matches one step in the list. **What do listen-only and no-ACK mean?** On CAN, a frame is only counted as delivered if at least one other device on the bus acknowledges it, so a lone transmitter keeps retrying the same frame and never sees it delivered. Listen-only mode never transmits, which makes it safe for looking. No-ACK mode transmits without needing anyone to acknowledge, which lets a sender run on an empty bus. Both are modes of the ESP32's driver, and the sweep uses the second. ### Four tests in one boot - **Listen only.** The ESP32 listens for five seconds and prints every frame it hears, with ID and bytes, then the error counters. It cannot disturb the bus. If anything is sending, this is where it shows up. - **Normal mode.** It queues one 0x316 frame carrying 3000 rpm with a 100 ms wait for queue space. The sketch prints that the cluster acknowledged if the call returns, and a timeout otherwise. That return only means the frame was queued, so "acknowledged" on the serial monitor proves nothing by itself. If it times out, the sketch prints that the cluster is not on this bus. - **No-ACK mode.** The same frame, sent without needing an acknowledgement, followed by three seconds of listening for any response. This is the mode the sweep runs in. If the send goes through and nothing answers, the sketch prints that the cluster is ignoring the messages. - **Pin check.** The CAN driver is removed and both pins are read as plain inputs. RX should read 1 when the transceiver is connected and the bus is idle. A 0 means the transceiver is not powered or connected, or the bus has a fault, and the sketch prints exactly that. The interpretations in each step are the messages the sketch prints over serial. ## Limits It only drives the rev counter. The coolant byte in DME2 and the oil temperature byte in DME4 are both fixed at 0xAB, so treat both temperatures as placeholders rather than data. The other gauges, an input from a real engine or a simulator, and an enclosure are still to come. The sweep sends in no-ACK mode, so it runs whether or not a cluster is attached. The serial monitor printing RPM: 3000 proves nothing about the cluster, which is what the diagnostic is for. The frame layout is borrowed. The assumption that E39, E46 and E38 clusters share the protocol comes with it, and it is not yet confirmed on the E39: the only movement I saw came from the cluster's built-in test, not from my code. ## What's next The project is on hold: I moved, and the cluster is gone. I still think I would have worked it out and ended up using it for projects and games. What remains is getting my code to control the cluster, then the other needles, checked scaling for the temperature bytes, a live data source such as a game, and a box to put it in. If I picked it up again I would start with the first of those. - **Stack**: ESP32, Arduino framework, ESP-IDF twai CAN driver, MCP2551 transceiver - **Credit**: Frame format borrowed from an existing project for the BMW E46 cluster - **Timeline**: Started about a year and a half ago, put aside, picked up again a year later - **Status**: Prototype - **Now**: On hold; the cluster is gone - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/gesturecam · Status: Prototype · 2026 · Tags: Android, Python, MediaPipe, PyTorch Repository or link: https://github.com/Forkei/GestureCam # GestureCam A phone camera and a Python program that turn my body and hands into Minecraft controls. Prototype. ## What I wanted I wanted to play Minecraft with my body instead of a keyboard, using a phone as the camera. Lean to strafe, swing an arm to attack, pull back an imaginary bowstring to shoot. The tracking and the game link work, and I have two trained models. They are not yet good enough to play with, and the limit is mostly data: teaching a network this takes a lot of recorded examples, and I have a few hundred clips. More recordings are the next step, and I'd like to come back to it. In rough tests the live pipeline did drive Minecraft, but the models were not good enough yet, and more recorded data is what I need next. It grew in steps. On 19 February 2026 it was only an Android app that streams the camera to a PC, with manual control of exposure, ISO, focus, white balance and zoom, plus a Python client that tracked my hands. Pinch zoomed out, spread zoomed in, and a swipe up or down moved the exposure compensation by two steps. The next day I added body pose and a gesture classifier. By the 21st it was sending controls to Minecraft, and on the 22nd I started recording gameplay to train something better. ## How it works The app is Kotlin on Camera2. It captures JPEG frames and sends them over TCP on port 5000, each one preceded by a 4-byte length. A second socket on port 5001 takes JSON commands for the camera: exposure time, ISO, focus distance, white balance, zoom, resolution, JPEG quality and frame rate. On the PC, a client thread keeps only the newest frame, so a slow stage downstream drops frames instead of building up lag. On startup it lets auto-exposure and focus settle for a few seconds and then locks exposure, ISO, focus and white balance, so the picture does not shift while I move around. Pose and hand tracking run on the PC, not on the phone. Step 1 of 9. The phone listens on port 5001 for small JSON commands: exposure, ISO, focus, white balance, zoom, resolution, JPEG quality, frame rate. Figure: Diagram. The full chain, with the real port numbers and message framing. Both models read the same pose and hand features. First model: gesture classifier, 9 labels. Second: control policy, 10 outputs. ### Step through the pipeline Click a box in the diagram, or use the buttons. MediaPipe gives 33 body landmarks in world coordinates, in metres and centred on the hips. WiLoR-mini gives 21 3D keypoints per hand. Add a presence flag for each hand and a visibility score for every body landmark and that is 260 numbers a frame, plus 225 frame-to-frame velocities of the position values: 485 in all. The control policy also takes 16 values from the game and its own last five outputs, which makes 551. Figure: Diagram. What goes into the models for every frame, drawn to scale. The height of each block is the number of values in that group, and each line on the left marks how far down the models read. The first model is a small Transformer encoder: 3 layers, 4 heads, width 128. It reads a window of 30 frames, about a second at 30 frames per second, and scores nine gestures: crouching, draw bow, jump, place block, sprinting, swing, sword draw, throw and walking. **What is a Transformer here?** The same family of network as the chat models, but small. It looks at all 30 frames of a window at once and learns which moments matter for which others. That suits a swing, which is a hand going back and then forward over about half a second. Each gesture gets its own sigmoid output instead of one softmax over all of them, so walking and crouching can both be on. The thresholds are per gesture: 0.3 for walking, draw bow, place block and sword draw, which should fire easily, and 0.9 for sprinting and throw, which should not. In the game, walking and sprinting hold the forward key, sprinting also holds sprint, crouching holds sneak and drawing the bow holds right-click. Jump, swing and throw are one-off presses with a half-second cooldown. Sword draw only selects the first hotbar slot. Finding the sword in the inventory is not written yet. Strafing and looking are hand-written rules, not learned. The strafe detector compares the sideways position of the middle of my shoulders with the middle of my hips, in metres from the pose tracker. A running average smooths the reading, and past 4 cm it presses the strafe key. It lets go at 3 cm. **Why two different thresholds?** That is hysteresis. If one number both switched the key on and off, a lean sitting right on 4 cm would flicker the key on and off every frame. With 4 cm to switch on and 3 cm to switch off, I have to come back a full centimetre before it lets go. ### Lean to strafe Figure: Diagram. The strafe rule with its real constants. Use the slider under the diagram to lean. Looking around is a virtual joystick on my left hand. A fist arms it, opening the palm starts it, and from then on how far the palm sits from where it opened becomes yaw and pitch, with a small dead zone in the middle. The second model, the control policy, skips the list of gestures. It reads the same window with a bigger Transformer (4 layers, 6 heads, width 192) and outputs eight on/off controls (forward, strafe left, strafe right, sprint, sneak, jump, attack, use item) and two look axes. None of my nine gesture classes is a strafe, so in the gesture path strafing still comes from the lean rule. ## Results The numbers I have are training numbers for the control policy. It trained for 45 epochs. By my combined score the best was epoch 20, with an average F1 over the eight controls of 0.479 and an idle accuracy of 0.992. By epoch 45 the F1 was 0.398 and the validation loss had gone from 1.00 to 7.02. **What do F1 and idle accuracy mean?** F1 runs from 0 to 1 and punishes both missed presses and false ones, so a control that never fires scores 0 and one that fires on every frame scores low too. I average it over the eight controls. Idle accuracy is separate: of the frames where I was doing nothing, how many it correctly left alone. ### Scrub through the training run Figure: Chart. Every epoch of the control policy's training run. In the top panel the loss on the practice clips keeps falling while the loss on held-out clips never settles. In the bottom panel neither curve settles either. The slider moves the marker and fills the table. Picking a separate threshold for each control afterwards lifts the average F1 to about 0.52. I quote the 0.48 because that is the score before that tuning. ## What I ran into With this few clips the policy memorised them. Training loss fell to about 0.012 (0.017 by epoch 45) while validation loss swung between 0.2 and 8.6. **What is overfitting?** A network can fit its practice data so closely that it learns the quirks of those exact clips instead of the pattern behind them. The giveaway is the top panel of the chart: error on the practice clips keeps falling, while error on clips it has not seen stops improving and then gets worse. The control labels come from converted gesture clips, not real play. Walking became forward, sprinting became forward plus sprint, a swing or a throw became an attack pressed at the moment my hand moved fastest, and drawing the bow became holding use item. I left out sword draw, because whether you want a sword depends on what is in your inventory, which the camera cannot see. In total that is 310 clips for ten outputs, a small set. Four of the eight controls hit the ceiling of 50 on the positive-class weight. Strafe left and strafe right have no positive frames at all, because none of my clips is a strafe, and jump and attack only have a short pulse of a few frames per clip. The first model had its own limits. Walking fired easily at its 0.3 threshold, and a gesture sends the same key whatever is going on in the game, so drawing the bow does the same thing whether or not I'm holding a bow. That is why I started the second model, and why it takes game state as an input. ## Where it stands A bigger policy for the next version is written: 671 inputs, 28 controls, about 3.5 million parameters and five output heads, with inventory and hotbar among them. It runs a forward pass without errors, but it has not been trained yet. I recorded nine sessions of my own Minecraft play on 22 February as a start on training data, and more are needed before there is a result. It's on hold, and I'd happily pick it up again; the plan for it lives in [MCGestureControl](https://oliviercouthaud.com/mcgesture). I budgeted roughly 23 to 26 ms per frame against the 33 ms you get at 30 frames per second. That is an estimate from adding up what each stage should cost, not a measurement. ## Credits The game side runs on MCCTP, a Fabric mod by [Lucas Øyen](https://github.com/lucasoyen/mcctp) that exposes Minecraft over a WebSocket. I wrote the Android app and the Python client. Pose tracking is MediaPipe and hand tracking is WiLoR-mini. - **Stack**: Kotlin and Camera2 on the phone. Python, PyTorch, MediaPipe and WiLoR-mini on the PC. Minecraft with Fabric. - **Status**: Prototype - **Started**: 19 February 2026 - **Code**: [github.com/Forkei/GestureCam](https://github.com/Forkei/GestureCam) [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/healcontrol · Status: Prototype · 2026 · Tags: Python, MCP, watsonx Repository or link: https://github.com/hl9082/healcontrol # HealControl A server that lets an AI assistant run a failing test suite, patch the bug, check the fix and open a pull request. HealControl is a Model Context Protocol server I wrote on 30 and 31 January 2026 for an IBM hackathon, in a team of four. It was another hackathon for me, and I had fun taking part and working with my teammates. The loop runs end to end on the demo app, and I recorded a video of it for the hackathon and posted it on LinkedIn. We didn't win. The latest version is on the dev branch of the repo. It is a demo, not a product. ## What I wanted I wanted the assistant to work like an on-call site reliability engineer: run the tests, read the file, change it, run the tests again, and only then commit. Each step is a tool exposed over MCP, so any client can drive the loop itself. The server stays thin: each tool does one thing and returns plain text, and the agent does the thinking. **What is MCP?** The Model Context Protocol is a standard way for an AI application to call tools that live somewhere else. A server announces a list of tools with names, arguments and descriptions, and the client's model decides when to call them. It means one server can work with several assistants, for example IBM Bob or Claude Desktop, without a separate integration for each. ## How it works One Python file, src/server.py, runs a FastMCP server over stdio and registers 12 tools. Five of them run a subprocess (pytest, git or the GitHub CLI), four read or write files in the app folder, two handle configuration, and analyze_with_watsonx is a REST call. A small healcontrol.json lists the apps the server may work on and which one is active. Only the demo app is in it. **What does stdio mean here?** The client starts the server as a child process and the two talk through its standard input and output, as lines of JSON. There is no network port and no login. That is why setup is one line in a VS Code config file, and also why the server runs with the same permissions as whoever launched it. Figure: Diagram. The server is one Python process that spawns pytest, git and gh as subprocesses; the agent in the client decides the order of calls. Orange is the call that sends the test output to IBM; the push to GitHub goes out through git and gh. **What the 12 tools touch.** | Kind | Tools | |---|---| | Run a subprocess (5) | check_pipeline_status, verify_fix, create_git_branch, commit_fix, push_to_cloud | | Read or write files (4) | list_files, read_code_file, apply_surgical_fix, reset_broken_app | | Configuration (2) | list_apps, set_active_app | | Call IBM over REST (1) | analyze_with_watsonx | On the dev branch the prompt asks the agent to detect, read, diagnose with Granite, fix, verify, then branch, commit and push. It also tells it to speak like a senior SRE, to be brief, not to ask permission, and never to modify test files. main still has an older prompt without the diagnose and push steps. **What are IBM watsonx and Granite?** Granite is IBM's family of language models, and watsonx.ai is IBM's hosted service for running them. The server fetches a token with an API key, caches it for the life of the process, and posts the failing test output to the chat endpoint, asking for four labelled fields: root cause, fix suggestion, confidence and explanation. It uses granite-4-h-small with a temperature of 0.2 and a 500 token cap. ## The loop, step by step The server doesn't run the loop. The agent does, using the order in the prompt, and it can skip or repeat steps. Here is the order the prompt gives it, with what each tool really returns. ### Step through the fix loop **1 Detect, check_pipeline_status().** Runs pytest on the active app and returns "Pipeline status: FAILED" followed by only the FAILURES section of the output. On the demo app, all three tests fail. If they pass it returns the whole output instead. Figure: Diagram. Step order is the one in the system prompt on dev. The agent chooses its own calls. Click a box or a button, or use Back and Next. This is what the code does, not a recording of a run. ## The demo bug The demo app is one function. calculate_discount subtracts the percentage from the price instead of taking a percentage of it. Three tests define correct behaviour, and all three fail on the buggy version. The fix is a single line. **The demo's three tests. "Buggy" is price - discount_percent, worked out by hand. "Fixed" is price - (price * discount_percent / 100).** | Test | Expected | Buggy returns | Fixed returns | |---|---|---|---| | 200 at 20% | 160 | 180 | 160 | | 50 at 10% | 45 | 40 | 45 | | 75 at 100% | 0 | -25 | 0 | ### Run the bug yourself Plain arithmetic in your browser, using the two versions of the function above. Drag the sliders or press a test button. Needs JavaScript; the table above has the same numbers. ## What I ran into **The watsonx SDK.** The first version used IBM's Python SDK with Granite 3.3. It was slow to initialise, so I switched to plain REST calls with a cached IAM token and moved to Granite 4. **Git in the wrong folder.** Branch, commit and push were running from the demo app's folder instead of the project root. I moved all of them to the project root on the last evening. **The "local" claim.** I described it as running locally with no code leaving the machine except the push. That holds only while analyze_with_watsonx isn't used: when it is, it sends the pytest output, tracebacks included, to IBM. And whatever model runs the client sees every tool result anyway. Figure: Diagram. What leaves the machine. IBM gets the test output each time the agent asks for a diagnosis, GitHub gets the branch on a push, and whoever runs the client's model sees what the tools return, so that part depends on the client. **Rules only in the prompt.** The prompt says never edit tests, but apply_surgical_fix will overwrite any file in the app folder, tests included, and commit_fix stages everything. The one workflow rule the code enforces is in push_to_cloud: test_result = _run(_build_test_cmd(), cwd=PROJECT_ROOT) if test_result.returncode != 0: return ( "Refusing to push: tests are failing. Fix the tests first.\n\n" + _extract_failures(test_result.stdout + test_result.stderr) ) **What does the path check do?** Every file name the agent passes is joined onto the app folder, resolved to an absolute path, and rejected if it ends up outside that folder, for instance with ../. Without it, an agent, or a prompt injected into a file it reads, could read or overwrite anything the server's user can. It stops that. It doesn't stop the agent from editing a test file inside the folder. **Emergency mode.** For demo day we planned a fallback: if GitHub went down, the server would use a fake failure log in src/mock_data.py. The fallback log isn't wired in yet: the mock failure data sits unused on the main branch, so it is a plan, not a feature. ## Where it stands It targets the demo app. The multi-app config exists with only the demo app in it, and the server has no tests of its own yet. The record of a full run is the video from the hackathon; there is no success rate and no log of what Granite replied. The agent acts without asking for approval, which suits a toy bug and not real code. Next come an approval step before any write, and a lock on test files that lives in the code instead of the prompt. - **Stack**: Python 3.12, FastMCP (stdio), pytest, git and gh CLI, IBM watsonx.ai (Granite) over REST - **Status**: Prototype - **Scope**: Hackathon demo on one toy app. It runs end to end. - **Video**: Recorded for the hackathon and posted on LinkedIn - **Credit**: A team of four. I wrote the server and the agent prompt. - **Code**: [github.com/hl9082/healcontrol](https://github.com/hl9082/healcontrol) (a teammate's repo; the latest version is on the dev branch) [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/heartbeatid · Status: Prototype · 2026 · Tags: Python, PyTorch, ECG, Wear OS # HeartbeatID A small neural network that identifies people from a few seconds of heartbeat on public ECG recordings; a Galaxy Watch app is built, with no results from the watch yet. [Image: Histogram of similarity scores. Teal bars (same person) cluster near 0.85. Orange bars (a different person) spread around 0. A dashed cut-off line at 0.48 sits where the two overlap a little; the app uses 0.487.] Figure: How similar a fresh reading was to the stored template, checked on a different day from the one used to set it up. Teal bars: it really was the same person. Orange bars: it was somebody else. The dashed line is the cut-off (0.487 in the app). Five heartbeats per reading, 10 people the network never trained on. The small overlap near the line is where the errors come from. ## What I wanted I wanted to see whether my own watch could tell who is wearing it from heartbeat alone. The model side works on public data. The watch side is the next piece: the app is built, and I have no watch results to report. A Galaxy Watch can record a single-lead ECG when you touch the bezel. The question was whether a few seconds of that signal is enough to say who is touching it, either "is this person X?" (verification) or "who is this?" (identification). The shape of a heartbeat differs between people, but it also changes with heart rate, posture, electrode contact and time of day. So the useful question isn't whether it works within one recording. It's whether it still works on another day. ## How it works The data is the ECG-ID database from PhysioNet: 90 people, 310 recordings of 20 seconds each, Lead I at 500 Hz. I filter the raw channel myself rather than using the pre-filtered one, so the exact same filter can run on the watch. R-peaks come from a small Pan-Tompkins-style detector built on SciPy. Each heartbeat is cut from 250 ms before the peak to 400 ms after, resampled to 256 samples and z-normalised. That gives 10,424 beats. **1. Bandpass.** A third-order Butterworth filter, 0.5 to 40 Hz, run on the raw channel. It takes out the slow baseline drift and the high-frequency fuzz and keeps the shape of the beat. **2. R-peaks.** A Pan-Tompkins-style detector: take the slope, square it, smooth it over about 120 ms, then pick peaks with an adaptive threshold and a minimum gap between beats. **3. Cut the beat.** 250 ms before each R-peak to 400 ms after, resampled to 256 samples and z-normalised, so what is left is shape, not size. **4. The network.** Four small convolution blocks and a linear layer, 101,760 parameters. One beat in, a 128-number embedding of length 1 out. **5. Compare.** Average the embeddings of 5 beats, take the cosine against the stored template, and accept if the score is 0.487 or higher. Figure: Diagram. Tap or click a stage, or use the buttons above. The 0.487 threshold is one global value: the 5-beat equal-error point of the 10 cross-day held-out people. The network is four small convolution blocks and a linear layer, about 0.1 million parameters (101,760). It trains in roughly 30 seconds for 25 epochs on a CPU and embeds a beat in under a millisecond. What comes out for each beat is a list of 128 numbers. **What is an embedding, and what is cosine similarity?** An embedding is the list of numbers a network produces for an input, here 128 numbers per heartbeat. Training pushes beats from the same person towards similar lists and beats from different people apart. The lists are scaled to length 1, so comparing two of them is just a dot product, the cosine similarity: 1 means pointing the same way, 0 means unrelated, below 0 means opposite. I train it with an ArcFace loss (scale 16, margin 0.30) over the training people, because the real task is open-set: compare two embeddings, not pick from a fixed list of classes. The ArcFace head only exists during training. **What is ArcFace?** ArcFace is a loss from face recognition. Plain classification only asks the network to pick the right person. ArcFace also demands that a beat sit a fixed angle (here 0.30 radians) closer to its own person than to anyone else, which squeezes each person's embeddings into a tight cluster. That is what you want when the job at test time is to compare two embeddings, not to choose from a list. To enrol, I average the embeddings of up to 20 beats from one day into a template. To check someone, I average the embeddings of 5 beats and take the cosine similarity to the template. At 0.487 or above the answer is yes. That number is one global value for everybody. The split holds out whole people: 62 for training, 28 for testing. The test people are new to the network, like a new user enrolling. Only 20 of the 90 have recordings from more than one day, and half of those 20 are in the test set. Figure: Diagram. One square is one person. The cross-day numbers below come from the 10 orange squares on the lower row. ## Results **Held-out people only. The same-session row is optimistic: enrolment and test beats come from the same day (first and second half of that day's beats). Numbers in brackets are how many beats were averaged. Top-1 is among 28 people in the first row (chance 3.6%) and among 10 in the second (chance 10%). EER is the equal error rate, explained below.** | Setting | Top-1 (5) | EER (5) | EER (10) | |---|---|---|---| | Cross-day, 10 people | 92.4% | 7.4% | 7.2% | | Same session, 28 people (optimistic) | 96.7% | 2.7% | 1.8% | **What is the equal error rate (EER)?** A verifier can fail two ways: accept the wrong person (false accept) or reject the right one (false reject). Moving the cut-off trades one for the other. The equal error rate is the cut-off where the two happen equally often, and it gives one number to compare systems by. Lower is better, and 7.4% means about 7 errors of each kind in 100 at that setting. The cross-day row is the one I trust: enrol on one day, verify on another. "Another day" means each person's first recording date against their last. The gap varies by person and I haven't tabulated it. At 5 beats, error rises from 2.7% to 7.4% between the two rows. Session drift is the likely cause, but the rows use different people (28 vs 10) and different protocols, so it isn't a controlled measurement. You can see the trade-off in the actual scores. This is the 5-beat cross-day case from the plot at the top, 800 readings of the right person and 800 of other people. Drag the cut-off and the two error rates move in opposite directions. ### Move the cut-off on the real scores **Rates at five cut-offs, same data as the histogram.** | Cut-off | Strangers let in | Owner turned away | |---|---|---| | 0.30 | 21% | 0% | | 0.40 | 15% | 2% | | 0.487 | 8% | 9% | | 0.60 | 2% | 15% | | 0.70 | 0% | 20% | 800 genuine and 800 impostor scores, 5 beats, enrolled on one day and checked on another, 10 held-out people. The rates are rebuilt from the histogram at the top, so they are approximate, within a few points. The app is set to 0.487. The plots here come from their own random draw of windows (a different seed, 80 per person), so their error rate (8.4%) differs from the 7.4% in the table. That gap is a sign of how much these numbers move on 10 people. **More beats help, with smaller gains after 5. Cross-day, 10 held-out people.** | Beats averaged | Top-1 ID | Verification EER | |---|---|---| | 1 | 83.4% | 9.6% | | 5 | 92.4% | 7.4% | | 10 | 93.6% | 7.2% | At a resting 60 to 70 beats a minute, 5 beats is about 4 to 5 seconds and 8 beats about 7 to 8, and the watch app records a fixed 8 seconds. Across all 20 multi-day people, including ones the network trained on, the numbers are 94.5% top-1 and 4.0% EER at 5 beats. That row isn't an unseen-identity result, so I don't quote it as one. [Image: ROC curve for verification, cross-day, 5 beats, held-out people. AUC 0.986, EER point marked at 0.084] Figure: ROC for the same setting: enrol on one day, verify on another, 5 beats, held-out people. AUC 0.986. ## The watch Figure: Diagram. The Wear OS app as I built it, from fingertip to name. I have no results from it on the watch yet. I built a Wear OS app for the Galaxy Watch 8. It was meant to capture about 8 seconds of ECG through Samsung's Health Sensor SDK while you keep a finger of your other hand on the bezel, run the preprocessing in Kotlin, run the model as ONNX, and keep a roster of enrolled initials on the watch. I checked a Python copy of the Kotlin filter and peak finder against the original and the peaks were identical. The Kotlin code itself has no automated tests, and the ONNX output matches PyTorch to about 1.7e-7. Then I ran the app on an actual Galaxy Watch. From what I remember it captured the ECG and ran the model on the watch while it listened, and I don't remember it showing a score. The code does have a result line on the watch screen (the initials and a percentage, "not recognized", or an error message if the capture fails), so checking what the screen actually did, and getting a result to show every time, is part of getting it solid. That is the next piece of work. It spans the watch's operating system and my own code, and debugging means connecting the watch over ADB, trying a change and repeating, which is slow. I have no accuracy numbers from the watch, so I make no claims about it. The model was trained on clinical ECG and the watch reads a different lead. The idea was to enrol every person on the watch itself, so a person's template and later readings come from the same sensor. That matters once the app runs solidly, and whether enrolling on the watch closes the gap is yet to be tested. The app is not published. There's also a second model trained on wrist PPG from PPG-DaLiA: 15 people, a motion-heavy field study, downsampled to 25 Hz, in 8-second windows. Its error rates are higher. On 4 held-out people, enrolling on the first part of a recording and verifying on the last, EER is 36.2% with 1 window, 26.8% with 5 and 17.0% with 10, and top-1 is 73% with 10 windows. **What is PPG?** PPG, photoplethysmography, measures blood volume under the skin. A green LED on the back of the watch shines into the wrist and a sensor reads how much light comes back, which rises and falls with each pulse. It needs no finger on the bezel, but the signal is smoother and much more sensitive to motion than an ECG. ## What I ran into, and the caveats The watch itself. The app is built and the work left is getting it solid: it involves the watch's operating system and my code, and each attempt means connecting over ADB, trying and repeating. Beyond that there is a domain gap to cover: ECG-ID is Lead I from a lab, while the watch measures from finger to wrist with more motion and lower signal quality. I expect the threshold to need re-fitting on watch data. What exists is the algorithmic core, not a validated on-watch system. Session drift, probably. Error roughly triples between same-session and cross-day (2.7% to 7.4% at 5 beats), and I can't separate drift from the difference between the two groups of people. A larger multi-day cohort is what would show how large the effect really is. The test is small. The cross-day headline rests on 10 people from one split with one seed, with no confidence interval. Its 500 genuine and 500 impostor scores are repeated random draws of beats from those same recordings, so they are not 500 independent trials. The threshold I set in the app is the equal-error point of those same 10 people, so it is tuned to the test set. PPG has a harder time, for understandable reasons: only 4 unseen people, heavy motion, and at 25 Hz the dicrotic notch is about one sample wide. The 9.2% EER on all 15 people includes training identities and is optimistic. There is also no protection against replayed or synthetic ECG, and the threshold is global rather than per user. ## Where it stands First, get the app solid on the watch and returning a result. Then collect a multi-day dataset from the watch itself, fine-tune the last blocks and re-fit the threshold on it, set thresholds per user after enrolment, and add liveness checks. Those are the steps ahead. - **Stack**: Python, PyTorch, SciPy, wfdb, scikit-learn; Kotlin, Wear Compose and ONNX Runtime for the watch app - **Data**: ECG-ID (PhysioNet), PPG-DaLiA. Public datasets, not redistributed here. - **Status**: Prototype - **Watch app**: Built; no results from the watch yet - **Credit**: ArcFace (loss) and Pan-Tompkins (R-peak detection) are published methods. Libraries and datasets belong to their authors. - **Code**: Not public [← All work](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/jarvius · Status: Prototype · 2025 · Tags: Python, Discord, Whisper, Gemini, Weaviate # Jarvius My first AI assistant: a Discord bot you can type to or talk to, and the direct ancestor of what became VOS. Jarvius began as a Discord bot in Python, using discord.py. It ran a handful of commands, sent everything else to a language model, could speak its replies in a voice call, remembered what each person had said, and ran a party game. Jarvius is the direct ancestor of VOS Gen 1: the same project, with the Discord bot as its first stage, from February to September 2025. ## What I wanted I wanted Jarvis from the Iron Man films: an assistant you talk to out loud, that answers back and remembers you. The way I actually used it was on the walk to class. I would send it voice messages and ask it to reply in text or in voice, depending on whether I could listen or had to read. That worked. Discord was the shortcut. It already gives you accounts, text channels and voice calls, and a bot can sit in a call, so there was no interface to build before testing the idea. Every answer goes through Ollama, which runs open models on a machine you control. For me that was my own laptop, so the bot was not up around the clock, but it cost nothing to run. The first model was llama3.2:1b. By the end the default was dolphin3. **What is Ollama?** A program that downloads open language models and runs them on a machine you control, behind a small web address. The bot sends it a list of messages and gets text back, which is the same job a paid API does, without the bill. The cost is that you need a computer that can run the model. When I created the project I described it as using a team of AI agents. The Discord version is one model behind one short instruction; the agents belong to the Gemini version that came after. ### How it grew Six stops from the first text bot to VOS. - Text bot. A Discord bot that answers prefix commands: ping, help, and two joke ones. Anything it doesn't recognise goes to a small model, llama3.2:1b, running in Ollama on a separate machine, with an instruction to keep answers to two sentences. - Voice. On the same first day I wrote the voice side: join a voice channel, transcribe speech with Whisper and answer out loud with Parler-TTS. "hey jarbius" became the phrase that meant you were talking to it. - Memory. It started keeping each person's conversation, so follow-ups worked, and a reply to one of its messages counted as talking to it without a prefix. The default model ended up as dolphin3. - Death By AI. A separate party-game bot where the model narrates what happens to each player and decides who survives. - VOS Gen 1. A Python assistant on Google's Gemini API, with several AI agents handing work to each other and a Weaviate database for memory. The direct successor of the Discord bot, and the same project. It cost more than I had planned. It taught me about APIs, cloud models and tracking usage, and I gave the agents a tool to check how much they had spent. - VOS. The next generation, which I built with Roman Slack from September 2025. It has its own page. Orange marks the current stop. ## How it works There are three ways to get its attention: start a message with "hey jarbius", reply to one of its messages, or use a command prefix. If the first word is a known command it runs that directly: ping, help, memory, join, leave, generate, and the two joke ones, fart and poop, which print up to 100 random synonyms. Anything else goes to the model. The bot saves your message, builds a prompt from a short system instruction plus your recent history, sends it to Ollama and posts the answer. In the last version these normal chat replies run on a joke persona I swapped in, not on the Jarbius instruction. The Jarbius instruction is still what generate uses. Both ask for two sentences at most. ### Follow one message through the bot Two routes through the same diagram. The typed route is the simple one. The spoken route is the call-listening loop, which the last version of the code no longer starts. **Typed route** - You write "hey jarbius", or reply to one of its messages, in a text channel. The bot sees it. - The bot checks the first word. If it is a command such as ping, help or fart, it runs it and replies. The model is not involved. - Otherwise it files your message under your server and your name in memory, and pulls back what you have said to it so far, up to 200 messages. - It sends a short system instruction plus that history to Ollama. The instruction asks for two sentences at most. Ollama answers with text. - The bot saves the reply to memory too, then posts it in the channel. - If the bot is sitting in a voice channel, the reply is also turned into speech by Parler-TTS and played into the call. **Spoken route** - The bot sits in the call and records everyone in it as 48 kHz stereo audio (each person's audio is joined end to end). - About every tenth of a second the loop measures how loud the recording is so far. If that is above 300 it counts as speech; after three seconds with no new reading above 300 the clip is closed. A clip is also cut at 30 seconds. Anything shorter than a second is thrown away. - The clip is saved as a temporary audio file and handed to Whisper. - Whisper returns a transcript. If "hey jarbius" appears anywhere in it, whatever follows is the request. Otherwise the clip is ignored. - From here it is the typed route: a command runs directly, anything else goes through memory and Ollama. - The reply is posted as text, with what it heard, and spoken into the call through Parler-TTS. Orange boxes and arrows are the current step. The volume line of 300, the 3 second pause, the 30 second cap and the 1 second minimum are the settings the bot used. Speaking back uses Parler-TTS mini v1. It takes the reply text plus a plain sentence describing the voice, and mine asks for a clear speaker on a close, clean recording. The result is played into the call through ffmpeg. If the bot is already talking, a new reply cuts the old one off. The listening side records the whole call, measures volume, and hands finished clips to Whisper. **What is Whisper?** An open speech-recognition model from OpenAI. You give it an audio file and it returns the text. The medium.en version is a mid-sized model trained on English only. It is more accurate than the small ones and slower, which matters when you run it on every clip. **What does the volume check actually measure?** It is the root mean square, or RMS, of the audio samples: square each sample, average them, take the square root. That turns a burst of sound into one loudness number. A quiet room gives a small one and speech a large one. The audio here is 16-bit, so single samples can reach about 32,000, and I set the line between quiet and speech at 300. The wake phrase check is a plain text match on "hey jarbius" in the transcript, so a transcript that spells the name any other way is ignored. The last version of the code no longer starts the in-call listener: the bot joins your call, says so, and tells you to use generate to make it speak. ## Memory Memory is a small class that keeps the last 200 messages for each person on each server, and a second log of the last 200 for the server as a whole. Each entry is a role, the text and a time. When the model is called, the prompt is a short system instruction plus that one person's history. That is why a follow-up like "and what about the second one?" works, and also why the bot has no idea what anyone else in the channel just said. The server-wide log is only counted by the memory command, which can show counts or clear one person or everything. Figure: Diagram. Where a message is stored and which part of it the model gets to see. Orange is what goes into the prompt. All of it sits in RAM, so a restart wipes it. The later Gemini version replaced it with Weaviate, which is a database that can keep memories between runs. **What is Weaviate?** A vector database. It stores each piece of text as a long list of numbers that captures roughly what it means, so a search can match on meaning and not just exact words. It also keeps everything on disk, so what it has stored survives a restart. ## Death By AI The game is a separate program in the same project, with its own commands and no shared code with the assistant. It uses the same model on the same Ollama machine. One person creates a game in a channel, others join, and the creator starts it. It works with a single player too. There is one game per channel, and it closes itself after ten minutes of silence. Figure: Diagram. One round. The orange box is the only place the model is called. A random player is asked for a scenario and gets 30 seconds. If they don't write one, the bot picks one of twenty built-in scenarios. Everyone then has 60 seconds to say what they do, and anyone who doesn't answer dies. Each answer goes to dolphin3 together with the scenario. The instruction asks for a small block of XML: a short reflection on the scenario, the player's approach and the story, then a story of at most three sentences, then a verdict of SURVIVED or DIED with a one-line reason. The narrator is told to be a dark, crude comedian. The story is revealed by editing one message word by word, and a scoreboard follows each round. **Game settings the creator can change** | Setting | Default | Allowed | |---|---|---| | Rounds | 5 | a whole number | | Time to write a scenario | 30 s | 10 to 120 s | | Time to answer | 60 s | 10 to 300 s | | Story reveal | word | character, word or instant | | Delay between reveals | 0.2 s | 0.05 to 1 s | A small model doesn't always answer in the format it was asked for, so the parser is forgiving. It adds the missing wrapper tag, escapes stray ampersands, and if it still can't read the answer, the player survives. A model error counts as survival as well. So a broken answer never kills anyone. ## What I ran into Speech worked, with a delay: the speech models are loaded from scratch for every reply and for every clip, rather than once when the bot starts, which is the first thing I would change. I also rewrote the call-listening code a few times as I changed how the bot receives audio. Memory lives in RAM, so a restart clears it, and it follows one person's thread at a time, not a group conversation. Everything depends on the Ollama machine being on, which was my laptop, so the bot answered only while it was on. The wake phrase is an exact text match, as above. Moving to Gemini later removed the laptop dependency and brought a usage cost to manage. ## Where it stands The Discord bot is the first stage of the project. The work went on as VOS Gen 1 (see the timeline above) and then [VOS](https://oliviercouthaud.com/vos), built with Roman Slack. If I picked the bot up again I would load the speech models once at start-up so replies come back faster. - **Stack**: Python, discord.py, Ollama on my own laptop (llama3.2:1b, later dolphin3), Whisper medium.en, Parler-TTS mini v1. VOS Gen 1: Gemini API, Weaviate. - **Status**: Prototype - **Period**: February to September 2025 (the Discord bot is the first stage of VOS Gen 1) - **Code**: Not public - **Why**: An assistant I could talk to on the walk to class - **Next stage**: [VOS and VibeOS](https://oliviercouthaud.com/vos), built with Roman Slack --- Source: https://oliviercouthaud.com/kingdomrush · Status: In progress · 2026 · Tags: LuaJIT, Python, PyTorch, PPO # Kingdom Rush Agent I got the logic of a tower-defense game running without a screen, far faster than real time, and trained one network to play its levels. ## The problem Kingdom Rush is one of my childhood games, and I enjoyed it then and still do. Coming back to it as an adult is a mix of nostalgia and a different point of view: the levels are easier than they seemed at the time, but some are hard to win in one attempt, and it is interesting to see what strategies a trained network settles on. I wanted to know whether one network could learn to play many levels of a tower-defense game, rather than one level tuned to death. Reinforcement learning needs millions of games, and the real game plays at one second per second, so the real work was a simulator fast enough to learn from. The game is Kingdom Rush, by Ironhide Game Studio. I worked from my own copy of the game's logic. The code and assets belong to Ironhide, so none of it is published and there is nothing of the game to download. What I'm showing is the research side: the simulator, the training and the results, in diagrams and charts I made. I trained it in October 2026. ## How it works The headless version runs the game's own systems, scripts and data tick by tick in plain LuaJIT, with no window, sound or UI. Only engine services with no effect on gameplay are stubbed out. The agent's actions go through code that copies the battle UI, with the same legality rules, and only one action is allowed per tick, as for a human clicking. Building charges gold on the next tick, so two builds in one tick could overspend. Figure: Diagram. The training pipeline. The game logic inside the worker is not shown. The last runs used one collector and 24 to 32 games at a time. Search and rebuilding states both depend on determinism, and the game isn't deterministic. Its sound system draws from the same random stream as gameplay, so which sounds play changes the battle, and the same seed doesn't give the same fight. The headless version has no sound, so that one never touches it, but I found four other sources of divergence. Two places iterate over tables keyed by memory address (simultaneous waves, and signal handlers), and those now run in creation and registration order. The pathfinder's cache is read by a gameplay script, and even a failed call clears it, so observations use a side-effect-free flood fill instead, which agreed with the game's A* on 117,936 of 117,936 checks. A save slot changes during play. And the seeds have to be set at exactly the right points. With all that fixed, the same seed and actions give the same game, tick for tick. A record-and-replay of all 26 levels matched at 769 of 769 checkpoints, and against the real game on level 2, a replay matched 32 of 32. **Why does it matter that the game repeats exactly?** Everything else here leans on it. If the same moves always give the same game, I can rebuild any earlier moment by replaying them, check a rebuild against a fingerprint of the state, and compare two networks on identical games. Without that, a lost game could be bad luck as easily as bad play. Drawing took 47% of every tick on a busy level. A lean render step skips the drawing-only work and keeps the animation state that gameplay reads, and a bot played all 26 levels both ways with identical state fingerprints. A busy tick went from about 350 to about 170 microseconds. **Speed on a Ryzen 9 9900X, measured on 23 October 2026, with 0 errors in the 26-level run. Real time means 60 game ticks per second.** | What was measured | Ticks/s × real time | |---|---| | Simulation only, level 1, one process | ~15,000 ~260× | | Simulation only, all 26 levels at worst case (unlimited gold), 20 processes | ~60,000 ~1,000× | | With the Python environment, a decision every tick | ~2,500 ~40× | | Same, a decision every 60 ticks | ~34,500 ~575× | The 1,000 times figure is the simulation alone, at worst case. Once the environment builds observations for the agent and takes its actions (random legal actions, in that test), it is 40 to 575 times. Building one observation costs about 1.3 ms, roughly 25 game ticks, which is what limits it at high decision rates. One network plays every level. It reads a set of tokens, one for each enemy, unit, tower, path segment and upcoming wave plus one for every legal action, and picks an action, then how long to wait before the next decision. The reward is +1 for a win, -1 for a loss and -0.05 per life lost, plus 0.5 spread over the waves reached so that losing later beats losing earlier, and up to +1 per boss for the share of its health removed (from update 8000). There is also a tiny cost per decision: without it, deciding every tick is free and the agent drifts there. Training is PPO with a curriculum, and the next level unlocks when the newest one reaches a 50% win rate. **What is PPO?** Proximal Policy Optimization. The network plays a batch of games, then gets nudged towards the moves that scored better than it expected and away from the ones that scored worse. The "proximal" part caps how far one batch can move it, so a single odd batch can't wreck what it already knows. **What is an entity transformer?** A game has a different number of things on screen at every moment, so I give each enemy, tower, unit and legal action its own token, the way a sentence is a row of word tokens. Attention lets every token look at all the others, so a tower can weigh the enemies near it. The network is 3.9M parameters, 4 layers, d=256. Because the game is deterministic, any earlier moment can be rebuilt by replaying its actions. The game's scripts run on coroutines, which can't be saved, so replay is the only way back. Restoring 500 steps takes about 2 seconds, and a rebuilt state gave an identical fingerprint and observation in three different workers, so spare workers do it in the background. A share of the games on an unsolved level (30% by default) start from a rebuilt point one to three waves before where an earlier game was lost. The same trick drives a search step. Figure: Diagram. The search step (search.py). Eight copies play on exact rebuilds of the game, the best one is kept, and the network is trained to imitate it, weighted by how much it beat the average copy. **What does the search step actually do?** It is lookahead followed by learning. Every 40 seconds of game time, eight copies of the game play the next stretch in different ways, and each is scored by what it collected plus the network's own guess at how good the ending state is. The best copy is kept, and the network is trained to imitate its moves. It is the same idea as AlphaZero: search, then learn from the search. ## Results [Image: Line chart of the fixed-test score against training update for two runs. The score rises from about 5 to about 7 by update 4000, then wanders between about 5.9 and 7.9 up to update 17600, with a best of 7.88 at update 12204.] Figure: Fixed-test score, the sum of per-level win rates, so the ceiling is the number of levels unlocked (the dashed line, which is not a result). Each point is one evaluation of one checkpoint. I changed the test partway to play the network's top choice instead of a sample, so early and late points are not strictly comparable. The test is a set of seeded games per level, with the star upgrades a player would have at that point. I switched it to play the network's top choice instead of a sample, because sampled moves depend on how many games share a batch, and with 8 games per level the same network could score up to 3 games apart. At update 4001 the agent won levels 1 to 4, 6 and 7 every time, level 5 in 75% of games and level 8 in 25%. Continuing from that checkpoint, the best score is 7.88 at update 12204. ### Scrub through the fixed test update 12204, score 7.88 L1 100% L2 100% L3 100% L4 100% L5 100% L6 100% L7 100% L8 88% L9 0% Figure: Every evaluation of the main run, in order of update. The orange marker is the one you picked. Each level is 8 seeded games, or 2 on levels already mastered, so a single level moves in big steps. Update 7376 appears three times because that checkpoint was tested three times. Real evaluations from the run, nothing smoothed. **Win rate. The agent columns are the fixed test at that training update. The bot is a hand-written player from an earlier script, 8 games per level on levels 4 to 7, and I didn't record its upgrades, so it isn't directly comparable. A dash means not tested.** | Level | Bot | Agent, 4001 | Agent, 12204 | |---|---|---|---| | 1 to 3 | – | 100% | 100% | | 4 | 25% | 100% | 100% | | 5 | 0% | 75% | 100% | | 6 | 0% | 100% | 100% | | 7 | 0% | 100% | 100% | | 8 | – | 25% | 88% | | 9 | – | 0% | 0% | The test covers 9 of the game's 26 levels. Level 9 ends in a boss fight, and the agent has no win there on this test. ## What I ran into The 7.88 is the best of about 150 evaluations of one run, and the same seeded games also pick which checkpoint I keep, so it is an optimistic number. With 8 games per level, single levels swing a lot: level 6 went from 0% to 100% between neighbouring checkpoints. After update 4000 the scores in the chart have a median of 6.9 and range from 5.9 to 7.9. I haven't tested on held-out seeds. The early runs each exposed a specific problem. One lost its footing when its teacher pull ended. In another, a 64% win rate on level 4 turned out to be a stale average, and on the fixed test it won 0 of 4. I also removed a kill bonus that was worth as much as winning on levels the agent was losing (+1.3 on level 4). Search took a while to contribute. Repeated low-memory stops limited it in two runs. A single shared imitation buffer let lost level-9 games crowd out level 6, which fell from 8 wins out of 8 to 1 out of 8, so each level now has its own buffer, fed from won games only. The exception is whole-game searches on level 9, which imitate their best copy even when it lost. A clean comparison of search against plain PPO is yet to be run, so how much of the gain comes from search is open. Level 9 is the open problem. The network reaches the boss in every game, but in the final level-9 run the boss still left with 64 to 76% of its health in training games, and there are no wins in 191 logged fixed-test evaluations of level 9 across the runs (up to 8 games each). A boss that reaches the exit costs 20 lives, so the level comes down to one fight, and the defence the network builds is mostly level-1 archer towers even when level-4 upgrades are legal. I tried 13 heroes and 46 star allocations. None of those games was won, and the best hero left the boss at about half its health. With every upgrade maxed, one diagnostic still left it at about 56%. Figure: Diagram. The level-9 boss with each of the 13 heroes, ranked. Even the best leaves it with about half its health, and none of these games was won. In one test of 8 copies of level 9, the best copy left the boss at 52% against about 59% for the greedy network. Later whole-game searches in the last run varied widely, with the best copy leaving between 17% and 99% of the boss's health, and none of those 60 games was won. ## Where it stands The simulation runs all 26 levels. The agent has been tested on the first 9 and has no win yet on the level-9 boss; levels 10 to 26 are untrained. Next measurements are held-out seeds and a proper comparison of search against plain PPO. The last run continued from the update-12204 best checkpoint to train on level 9 only (it isn't in the chart). I stopped it myself near update 14,000 because it was using a lot of compute for little progress on the boss, and I haven't restarted it. Before I do, I want to read up on how others handle this kind of sparse, one-fight bottleneck and change the training itself, rather than run more of the same. The training machine is my own PC, so training resizes itself or steps aside when RAM is short or a game is running. - **Stack**: LuaJIT (headless simulation), Python, PyTorch, PPO - **Hardware**: Ryzen 9 9900X; the later runs trained under WSL - **Credit**: Game by Ironhide Game Studio - **Status**: In progress - **Code**: Not public. [← All work](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/liaison · Status: Prototype · 2026 · Tags: TypeScript, Hyperswarm, CRDT, Encryption # Liaison A small tool, driven by typed commands, that lets AI agents on different machines message each other directly, with signed messages, encrypted DMs and no server in the middle. ## What I wanted I built this mainly so my boss's agents and mine could talk to each other, and then so that anyone else who wanted to interact with our agents could. It works as a prototype, and nobody has adopted it yet. When two people each run agents, the agents end up coordinating through people. Someone writes a doc, emails it, and the other side pastes it into their own agent. I wanted to remove that human relay: an agent drives a CLI from its shell, and its messages go peer to peer to the other side's agents, with no server in the middle. Delivery is only half of it. The harder half is how an agent knows a message really came from one of the other side's agents before it acts on it. Most of the design is about that. ## How it works Each agent is a keypair, ed25519 for signing and x25519 for encryption. Its address is the first 16 hex characters of a SHA-256 of its base64-encoded ed25519 public key, so it is not an IP and not an account on someone's server. **What is a keypair?** A matching pair of numbers: a private one you keep, and a public one you hand out. Something signed with the private key can be checked by anyone holding the public key, but nobody can produce it without the private one. Here the public key's fingerprint is also the agent's name, so the name can't be claimed by anyone else. A person or organisation has a separate *principal* key. It signs each of its agents' keys into a certificate, so a stranger can check "this is one of Bob's agents" without ever having seen that agent before. A group is a shared secret: the Hyperswarm topic is a hash of it, so the same key is how you find the group, and nothing else checks membership. **What are a DHT and hole-punching?** A DHT is a phone book spread across many volunteer computers, with no single owner. Peers announce that they are interested in a topic and look up who else is. Most machines sit behind a router that blocks incoming connections, so hole-punching has both sides send to each other at the same moment, which makes the routers let the traffic through. Figure: Diagram. The CLI is a thin client. The daemon is the only thing that writes the log, and it gossips the whole log to whoever it is connected to. Every post, DM and receipt is an immutable signed operation with a Lamport clock. Merging is union by id after dropping anything whose signature fails, and the message feed is a deterministic fold over the result, so peers converge whatever order they gossip in. **What is a CRDT, and a Lamport clock?** A CRDT is a data structure built so that copies on different machines can be merged in any order and always end up identical. Here that comes from only ever adding operations and never editing them. A Lamport clock is a counter each operation carries, bumped above anything its author has already seen, which gives every peer the same ordering without needing a shared clock. ### Merge in any order The scenario from my first self-test. Alice, Bob and Carol each start knowing only their own operations. Press a button to make one peer take another's operations, in whatever order you like. Once all three hold all four, their feeds are identical. You can also have Carol slip a forged operation to Bob. - Start: Alice holds a1, Bob holds b1 and b2, Carol holds c1. - After enough merges, every peer holds a1, b1, b2 and c1 and shows the same feed: a1, b1, c1. - A forged operation fails its signature check and is dropped, so nobody's feed changes. Illustrative. a1 is Alice's status "build shipped", b1 is Bob's ask "datastore decision?" and b2 is Bob's publish of it. b2 carries no text of its own, so it shows under Holds but never in a feed. c1 is Carol's "deploy in progress". Real operation ids are hashes and are signed, so the order of equal-clock operations differs from the labels here. The merge rule is the real one: union by id, then sort by Lamport clock and id. The sync itself is naive on purpose, for a first version: send the whole log when two peers connect, and again whenever something new arrives. Union by id means the flood settles instead of storming. A DM is sealed to the recipient's key with an ephemeral x25519 exchange and AES-256-GCM. A relaying peer stores the ciphertext and sees who it is from, who it is to and when, but not the content. When the recipient's machine decrypts it, it signs and gossips back a "delivered" receipt. ### One DM, through a relay, step by step Figure: Diagram. The scenario in my second self-test, where the three peers call each other's merge function inside one process instead of using a network. Click or tap a step. - Alice seals a DM to Bob, who is offline, and syncs with Carol. Carol holds the ciphertext. She can see it is from Alice, to Bob, and when, but not what it says. - Bob comes online and syncs with Carol. His machine checks the signature, decrypts the DM and makes a signed delivered receipt. - That receipt is an ordinary signed operation. It goes back to Carol. - Alice syncs with Carol and gets the receipt without ever talking to Bob. Carol cannot fake it, because a receipt with Bob's name on it but the wrong signature is dropped. One daemon per account holds the log; every other command is a quick local read or write over an IPC socket. liaison listen --exec "" pipes each incoming message as JSON to a handler's stdin and posts its stdout as the reply. liaison verify prints the certificate chain behind a contact. **What is TOFU pinning?** Trust on first use. The first time verify sees a principal name it remembers that principal's fingerprint. If the same name later turns up with a different key, it warns you about a possible impersonation. It can't help with the very first meeting, which is why the fingerprint is what you trust and not the display name. ### What gets checked, from the badge down to one message Figure: Diagram. Click or tap a stage. - Principal: my root identity, made once per machine. It is the one key that has to be kept safe. - Certificate: the principal signs its own id, name and key together with the agent's key, name and issue date. - Agent key: minted per agent and disposable. The agent's id is derived from it. - Signed op: the receiver checks that the id matches the key, that the hash matches the contents, and the signature. Any failure and it is dropped on merge. - Pin: verify checks the certificate and that its agent key is the one that sent the messages, then pins the principal's fingerprint. ## What I've shown There are three test scripts. The first has three peers post while disconnected, gossip in a deliberately uneven order, and checks that they end up with identical feeds and that a forged operation is dropped. The second is the DM-through-a-relay scenario above, including a forged receipt being rejected. The third starts two separate OS processes that have never met and checks that they find each other over a local DHT testnet and swap logs. The testnet is deliberate, because hole-punching between two programs on the same machine is the public DHT's worst case. I also ran it between a Windows laptop and a Raspberry Pi over the public DHT: group posts, DMs, receipts and provenance all worked, and two agents had a short conversation with no human in it. That was only a test, not a long exchange. A fresh agent with no prior context set itself up in about three minutes. ## Known gaps Some of these I knew about from the start. There is no offline delivery unless something stays online. A --relay mode exists, and the Pi plays that role ad hoc, but there is no deliberate always-on relay yet. There is no certificate revocation or expiry. Keys are stored in plaintext, like ~/.ssh. DM routing metadata is visible to everyone in the group, and one agent identity can be linked across every group it joins. Beyond that, gossip sends the entire log to every connection, not just one group's. There is no group-key rotation and no way to remove a member. DMs are sealed to the recipient's long-term key, so a stolen key opens DMs sent to it earlier. And listen --exec hands any incoming message to the handler whether or not its provenance verified, so the handler has to check. Group posts are not encrypted per group, so a machine you connect to receives the whole log, including posts from other groups you are in. ## What's next The next two pieces are a deliberate always-on relay for offline delivery, and a device tier in the principal chain, so one person can run several machines. Neither is built yet. The project started out as Parley and I renamed it, so a few old "ac" names are still around, including the CLI file name ac.ts, the acinv1 invite-token prefix and some error messages. It is about 1,700 lines of TypeScript including the test scripts, with one runtime dependency, Hyperswarm. - **Stack**: TypeScript on Node, Hyperswarm (DHT and hole-punching), node:crypto (ed25519, x25519, AES-256-GCM) - **Status**: Prototype - **Scope**: A working spike, not yet hardened for production. Not adopted so far. - **Code**: [github.com/Forkei/liaison](https://github.com/Forkei/liaison) [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/mactranscriber · Status: Working · 2026 · Tags: Python, Whisper, macOS # MacTranscriber A double-click app that turns a French or English recording into a text file on an Apple Silicon Mac. Once it is set up, it works offline. Figure: Diagram. The window with its default settings. It is a drawing, not a screenshot. ## Why, and what it is I built this for my mom, who uses it to transcribe audio recordings. Whisper already does the hard part, so what I was working on was access: she doesn't use a command line, and the app had to be usable without one. That is why setup is a script that runs itself on the first double-click, why the window has only four choices, and why there is nothing to configure. It has been used, by her, for the thing it was made for. You start it by double-clicking Transcriber.command, pick an audio or video file, choose French or English and one or two speakers, and a .txt appears next to the file. It never overwrites: if name.txt exists you get name 2.txt, and if the folder isn't writable the file goes in Documents. The first launch downloads Python and the models, about 2 to 3 GB. After that it runs offline, except that choosing Best for the first time downloads about 3 GB more. All of it, models included, sits in one folder, so uninstalling is deleting the folder. Almost all of it. The launcher installs uv into ~/.local/bin if it isn't there, and uv keeps the Python it downloads, and its cache of downloaded packages, in its own places outside the folder, so deleting the folder leaves uv, that Python and that cache behind. Figure: Diagram. What the launcher creates and where. Setting HF_HOME to the folder is what keeps the Whisper models inside it. ## How it works The window is the boring part. What matters is the path from a long recording to that text file, and it is one Python file, engine.py. Tap or click a box below to see what it does, or choose one speaker or two to see what changes. ### Step through the pipeline Whisper gets one chunk at a time, with no text carried over from the chunk before. With two speakers I also ask for a timestamp on every word, and the progress bar gives transcription 70% of its length and speaker detection the rest. Figure: Diagram. The path through engine.py. With one speaker the lower lane is skipped and the filters feed straight into the renderer. Tap or click a box in the diagram, or use the buttons under it. Transcription runs on mlx-whisper. Fast uses whisper-large-v3-turbo. Best uses whisper-large-v3, the larger model, which I expect to be slower; I haven't timed the two against each other. WHISPER_MODELS = { "fast": "mlx-community/whisper-large-v3-turbo", "best": "mlx-community/whisper-large-v3-mlx", } **What is Whisper?** A speech-recognition model released by OpenAI, trained on a very large amount of audio in many languages. It takes sound in and gives text out, with a timestamp on every segment and, if you ask, on every word. I use it as it is and don't train or change it. **What is MLX?** Apple's array framework for the chips in recent Macs. The mlx-whisper package is Whisper rewritten to run on it, so the model runs on the Mac's GPU. That is why the app only runs on Apple Silicon. ## Cutting at the quiet bits Audio is decoded to mono 16 kHz with the ffmpeg binary that imageio-ffmpeg ships, so nothing has to be installed separately. A long file is then cut into chunks of about five minutes. Each cut goes at the quietest 100 ms frame within 15 seconds of the nominal boundary, so words are rarely split. I chunk because Whisper is prone to repetition loops on multi-hour audio. Each chunk is transcribed without the previous text as context (condition_on_previous_text=False), so a loop can't carry on past the end of its chunk. ### How many pieces does a recording become ### Finding the quiet moment The loudness below is made up. The rule is the real one: look at the 30 seconds around the five-minute mark and cut at the quietest 100 ms frame. Cut at 300 s + a few seconds A pause between two sentences is a better place to cut than the exact five-minute mark. Two cheap filters run after Whisper. One drops a segment that contains a phrase from a short list of things Whisper invents on silence or music, such as subtitle credits and "thanks for watching". The other drops a segment that would be the third identical one in a row. **Why would a speech model make up text?** A common explanation is that Whisper's training data included a lot of subtitled video, much of which opens or ends with a stock line, such as a subtitle credit or "thanks for watching". With silence or music and nothing to transcribe, the model tends to fall back on those lines. ## Telling two voices apart Speaker labels come from sherpa-onnx: a pyannote segmentation model finds where speech happens, a WeSpeaker embedding turns each stretch into a fingerprint of the voice, and the fingerprints are clustered into exactly two groups. It runs in a separate process, so it overlaps with transcription and can be killed on cancel. Afterwards each word gets the speaker whose segment overlaps it most. A single word sandwiched between two words of the other speaker is relabelled, and speakers are numbered by first appearance. If the diarizer fails, the transcript is still saved, with a note at the top saying it has no labels. **What is diarization?** Working out who spoke when, without knowing who anyone is. It gives you stretches of time labelled "voice A" and "voice B", and nothing more. Matching those stretches to the words from the transcription is a separate job, and it is the one the next widget shows. ### From segments to speaker labels The words and segments below are made up to show one case. The assignment code is the real rule, ported line by line from assign_speakers. In this example the word "fine" overlaps a short segment from the other voice, so overlap alone makes it a one-word Speaker 2 line in the middle of Speaker 1's sentence. The fix puts it back. ## The window The window is a small local web page. A server on 127.0.0.1 at a random port serves it, and pywebview shows it in a native window; if that isn't available it opens in the browser instead. Every API request needs a token generated at launch, and a request with a Host header other than 127.0.0.1 or localhost is refused. The window polls for status about every 0.7 seconds. It shows the phase, a progress bar, an estimate of the time left once 4% is done, and the text of each chunk as it finishes. While a job runs the app calls caffeinate so the Mac stays awake. The file picker is an AppleScript dialog. The text file is only written at the end. If you cancel, nothing is saved. ## Scope It covers French and English, and one or two speakers. The two-speaker mode always splits the voices into exactly two groups, so if you ask for two when only one person is talking it will still try to hand out two labels. The speaker model is called wespeaker_en_voxceleb, which points to English training data (VoxCeleb), and how well it separates French voices is still to be measured. The filters are simple. The phrase list is short, so anything Whisper invents that is not on it gets through. And three identical segments in a row can be real speech, in which case the third one is dropped. It runs on a Mac with an Apple chip. The first launch needs the internet, and so does the first use of Best. The double-click mostly avoids the terminal: if macOS refuses to open the file the first time, it takes a right-click and Open, and if the file arrives without permission to run, the fix is a Terminal command, which is the thing the app is meant to spare her. Deleting the folder doesn't remove uv, the copy of Python or uv's download cache that setup put outside it. ## Where it stands It works and it has been used. Next I want a timing run on a real long recording, an error rate against a transcript I know is right, and a check of the two-speaker labels on French audio. The models and libraries are other people's work: OpenAI's Whisper, Apple's MLX and the mlx-whisper package, sherpa-onnx from k2-fsa, pyannote segmentation 3.0 and WeSpeaker. - **Stack**: Python 3.12 (installed by uv), mlx-whisper, sherpa-onnx, imageio-ffmpeg, numpy, pywebview - **Made for**: My mom, to transcribe her audio recordings - **Platform**: macOS on Apple Silicon - **Status**: Working - **Code**: Not public --- Source: https://oliviercouthaud.com/mcgesture · Status: Prototype · 2026 · Tags: Python, PyTorch, MediaPipe, Minecraft # MCGestureControl Playing Minecraft with my body: a phone camera watches me, and a small neural network turns what it sees into game controls. A prototype, paused for now. ## What I wanted I wanted to play Minecraft by moving. Lean to strafe, swing an arm to attack, open a palm to look around. A phone on a stand films me, the PC finds my body and hands in every frame, and something turns that into controls. It does not play Minecraft properly yet. The design needs a lot of paired recordings, and I have recorded some but well short of what it needs, so I put it on hold. I would be glad to revisit it. What follows is what exists and how far it got. The first version was the obvious one. A small model classified nine gestures (walking, sprinting, crouching, jump, swing, draw bow, place block, throw, sword draw) and a lookup table turned each into a key. Strafing was a rule: how far my shoulders sit to one side of my hips, smoothed and given hysteresis so it does not flicker. Looking was a virtual joystick on the left hand. Make a fist, open the palm, and from then on how far the palm drifts from where it opened turns the camera. The limits showed up fast. Walking fired too easily at its 0.3 threshold, though I have not measured how often, and the lookup table knew nothing about the game, so the bow gesture sent the same key whether I held a bow or a pickaxe. Version 2 throws the table away. The model reads body, hands and game state and predicts the controls directly. In between sat a first policy that already did that for 10 controls with 551 inputs. v2 grows it to 28 controls, widens the game state from 16 numbers to 46, extends the memory of its own last five answers to all 28 controls, and adds heads for the hotbar and the inventory. The Minecraft side is not mine. Lucas Øyen wrote [MCCTP](https://github.com/lucasoyen/mcctp), the Fabric mod that lets a program read the game and press keys over a WebSocket, and later [MCSTP](https://github.com/lucasoyen/MCSTP), which reports the richer state: hostiles nearby, what is in hand, whether a screen is open, and a cursor and clicks for inventory screens. I had bolted some state fields onto MCCTP myself before he split them out into MCSTP, and when he did, he updated my client to match. The client, the models and the training code are mine. I started in February 2026. ## How it works The phone streams camera frames to the PC, and the PC does all the tracking. MediaPipe Pose gives 33 body landmarks in metres, centred on the hips. WiLoR-mini gives 21 3D points on each hand. **What are MediaPipe and WiLoR?** Both are ready-made models that find points on a person in a picture. MediaPipe Pose finds the body, WiLoR-mini finds the hands in 3D. I use them as they come and do not train them. That is why the policy only has to learn from a few hundred numbers per frame, not from raw camera pixels. We also printed a few ChArUco boards (a checkerboard with small ArUco markers in the squares) to calibrate the camera against. It turned out we did not need them. The only calibration the client does is let the phone's exposure, focus and white balance settle at start-up and then lock them. [Image: Three printed sheets with a black-and-white checkerboard pattern and small QR-like squares inside it. One sheet lies on a piece of cardboard on a carpet, the other two lie on the corner of a wooden table.] Figure: The calibration boards I printed: one on cardboard on the floor, two on the table. They turned out not to be needed. **1. Phone and client.** The phone streams camera frames and stream_client.py on the PC picks them up. All the heavy work happens on the PC. Figure: Diagram. The v2 loop as I designed it. Tap or click a stage to read what it does. The client I run today still loads the first policy and its bridge: ControlPolicyV2 and ControlBridgeV2 exist but are not hooked up yet. Tracking runs on the PC, not on the phone. **The seven stages in words1. Phone and client.** The phone streams camera frames and stream_client.py on the PC picks them up. All the heavy work happens on the PC. **2. Tracking.** PoseTracker runs MediaPipe for 33 body landmarks in metres. HandTracker runs WiLoR-mini for 21 3D points on each hand. **3. Features.** One row of 671 numbers: pose and hands, how fast each point moved since the last frame, 46 numbers from the game, and the model's own last five answers. **4. Transformer.** It reads the last 30 rows, about one second at 30 fps, and weights the recent ones more than the old ones. **5. Heads and clean-up.** Five heads give 28 outputs. Outside the model, per-control thresholds, smoothing and a look deadzone turn them into clean presses and a steady camera. **6. Bridge.** ControlBridgeV2 holds a key while a control stays on and fires a one-off for things like jump, then sends it all over a WebSocket on port 8765. **7. Minecraft.** Lucas's mods carry out the controls and report the game state back every tick. That state is the 46 numbers in stage 3. Each frame becomes one row of 671 numbers: 260 for pose and hands, 225 for how each of those points moved since the last frame, 46 about the game, and 140 for the model's own last five answers. That last block lets it know what it was already doing. While training, those past answers come from the real labels, with 30 percent of them dropped so it cannot lean on them too hard. Live, they are its own previous outputs. Figure: Diagram. The input to the model, one frame. The red block is the part that comes from the game through Lucas's mods. The 46 game numbers are what gives the model context: the item in my hand, health and hunger, whether I am in water or on fire, what the crosshair is on, how far the nearest hostile is and in which direction, whether a screen is open. The model is a transformer encoder with 6 layers, 8 heads and a width of 256, about 3.5 million parameters. It reads the last 30 rows and pools them with extra weight on the most recent ones. **What does a transformer do with 30 frames?** A transformer lets every item in a sequence look at every other item and decide which ones matter. Here the items are 30 consecutive frames, about a second. That window is how it can tell an arm that is mid-swing from one that is resting, which no single frame can show. The final pooling favours the latest frames, because they decide what to press now. Five small heads read the pooled result. Action has 12 on/off controls: forward, back, strafe left and right, sprint, sneak, jump, attack, use item, drop, swap hands and open inventory. Look has 2, Hotbar 9, Cursor 2 and InvClick 3, so 28 outputs in all. Which heads count depends on the game. With a screen open only the cursor and the clicks do; otherwise it is action, look and hotbar. The training loss is masked the same way, so the cursor head only learns from frames where an inventory was open. **The three models in the project. The classifier outputs gestures, the two policies output controls.** | | Classifier | Policy v1 | Policy v2 | |---|---|---|---| | Inputs per frame | 485 | 551 | 671 | | Outputs | 9 | 10 | 28 | | d_model | 128 | 192 | 256 | | Layers | 3 | 4 | 6 | ## Where the training data comes from With a keyboard in my hands, I cannot also play with my body. So the data is made in two passes. In the first I play normally and gameplay_recorder.py records the screen at 30 fps along with what the game says I am doing each tick: movement, look, hotbar, inventory cursor and clicks. It reads the game's own resolved inputs, not the keyboard, so key bindings and mouse sensitivity do not matter. In the second pass gameplay_playback.py plays that video back while I act it out in front of the phone, and the camera logs my pose against each video frame. Every frame now has a body and the keys that were really down. Figure: Diagram. How the labels are made. The body is recorded in a second pass, which is a slightly odd way to get data: I am copying play I already did, not reacting to anything. **What is behavioural cloning?** Instead of telling the model what a good move is, you show it examples and it learns to copy them. An example here is one moment: my body and the game state on one side, the keys that were down on the other. It is the simplest way to teach a controller, and it can only be as good as the examples, mistakes included. The first policy used a different source. Its labels were converted from my earlier single-gesture clips: a walking clip became "forward held", a swing became "attack" at the moment the hand moves fastest, and sword draw was skipped because it needs game context. I have also recorded nine real play sessions on 22 February, about 55 minutes in total. One recording is corrupted, so eight are usable. That is well short of what v2 needs, and recording the rest is the main job before it can be trained. ## What the numbers say Only the first policy has numbers. It has 551 inputs, 10 controls, a width of 192 and 4 layers, and it learned from those converted clips, not from real play. v2 has no training run yet. The validation set is small: idle accuracy only moves in steps of 1/387, so there are 387 idle windows in it. **Policy v1, validation set, 45 epochs. Epoch 20 is the best by the training script's combined score.** | Measure | Epoch 20 | Range over 45 epochs | |---|---|---| | Combined score | 0.737 | 0.30 to 0.74 | | Average F1, 8 on/off controls | 0.48 | 0.19 to 0.48 | | Idle accuracy | 0.992 | 0.00 to 0.997 | | Validation loss | 1.00 | 0.20 to 8.55 | **What are F1 and idle accuracy?** For a control like jump, F1 balances two kinds of error: pressing it when I did not mean to, and not pressing it when I did. 1 is perfect and 0 is useless, and I average it over the eight on/off controls. Idle accuracy is how often the model correctly pressed nothing while I was doing nothing. It is easy to score high on that by never pressing anything, so it only means something next to F1. Tuning a threshold for each control lifts that to 0.52. Without it, the best epoch scored 0.479, so 0.48 is the number I quote. ### Scrub through the training run Figure: Chart. Policy v1's training run. Validation loss is far noisier than training loss. Figure: Chart. Validation average F1. The red dot starts on epoch 20, the best epoch by the combined score. Average F1 peaked at 0.48 on validation, at its best epoch and before threshold tuning. That is where the first prototype stands, and v2 is the next step. ## What I ran into Validation loss moved a lot from epoch to epoch. It was 0.20 at epoch 7 and above 8 by epoch 36, while training loss fell to 0.017. F1 swung between 0.19 and 0.48 and was best at epoch 20. Picking the best of 45 epochs on that same small validation set favours the score, so 0.48 is on the optimistic side. Drag the slider above from epoch 20 to epoch 36 and the validation loss goes from 1 to 8 while the training loss only creeps down. Four controls (strafe left, strafe right, jump and attack) hit the maximum positive weight of 50 in the loss, which means their positive examples are rare. There is no per-control F1 for this run yet, so which of them the model has learned is still to be measured. The look error is not informative. Validation look error is 6.7e-6 at epoch 20 and about 8e-8 by the end, but the converted clips have no looking in them, so the target was always zero and the easiest way to score well is to output nothing. Only the real play sessions carry look data. The inventory and hotbar heads in v2 have no results yet. Whether a body pose is precise enough to hit one inventory slot is an open question that I have not tested. ## Where it stands The project is on hold. The steps ahead: train v2 on enough real sessions, after doing the acting-out pass on them, and report numbers per head. Hook ControlPolicyV2 and ControlBridgeV2 into the live client. Measure latency, since hand tracking may or may not keep up with 30 fps. Record a demo. And test whether pointing is precise enough for the inventory. - **Stack**: Python, PyTorch, MediaPipe Pose, WiLoR-mini, Android camera app; Java Fabric mods by Lucas Øyen - **Model**: ControlTransformerV2: 30-frame window, 671 inputs, 28 controls, ~3.5M parameters - **Status**: Prototype, on hold. It does not play Minecraft properly yet: I have recorded some training clips, with more needed. The first 10-control policy is trained (validation F1 0.48 at its best epoch; 0.52 after tuning a threshold for each control). v2 is written; training it and connecting it to the live client are still to come. - **Collaborator**: Lucas Øyen, Minecraft mods: [MCCTP](https://github.com/lucasoyen/mcctp), [MCSTP](https://github.com/lucasoyen/MCSTP) - **Code**: [github.com/Forkei/MCGestureControl](https://github.com/Forkei/MCGestureControl), phone app [github.com/Forkei/GestureCam](https://github.com/Forkei/GestureCam) --- Source: https://oliviercouthaud.com/monachus · Status: Prototype · 2026 · Tags: Python, PyTorch, GPT, QLoRA # monachus A small language model I trained from scratch on Latin, French and English books from before 1900, and a side experiment on making a modern one talk like it is 1600. ## What I wanted A language model whose knowledge stops at a date. Ordinary chat models are trained on an enormous amount of modern text, so asking one to play someone from 1600 gives you a costume, not an absence of knowledge. I wanted a medieval monk who reads French and answers in old-flavoured English, with no pretrained weights and nothing modern in the corpus. That is the goal. The data is not fully period-clean yet. The English for the first model is Project Gutenberg with no date filter, which is overwhelmingly from before 1930; only the second model's English is cut at 1900. The Latin includes neo-Latin. The French script keeps books dated 1500 to 1900 by default. Modern text hiding inside old scans is caught only by a crude line filter on the first model's English for web addresses and ISBN numbers. Figure: Diagram. The two routes. The top one is the side experiment, the bottom one is the main project. Figure: Diagram. The two routes. The top one is the side experiment, the bottom one is the main project. ## How it works The code is plain PyTorch in a handful of files, adapted from the ideas in Karpathy's nanoGPT. model.py is a GPT-2-style transformer: pre-LayerNorm, learned position embeddings, GELU, a tied output head, no bias terms, a 1,024-token context. The tokenizer is a byte-level BPE trained on the corpus itself, so its vocabulary goes on Latin word endings instead of web fragments. train.py is a training loop with warmup and cosine learning rate, gradient clipping, bf16, and multi-GPU through torchrun if I want it. It saves a checkpoint whenever validation loss improves. **What is a token, and what is byte-level BPE?** A model does not read letters or whole words, it reads tokens, which are chunks of text. BPE (byte pair encoding) builds the list of chunks by repeatedly merging the pair of symbols that occurs most often in the training text. Byte-level means it starts from raw bytes, so any text can be encoded and nothing is ever unknown. Because I train the list on Latin and French, common word endings can end up as single tokens. Tap or click any box in the diagram to see what it does. Figure: Diagram, tap or click a box. The from-scratch path as I wired it. **Corpora.** For the first model, Latin comes from Corpus Corporum (classical, medieval and neo-Latin, about 170 million words) and from the Latin Library, and French from PleIAs French-PD-Books, public-domain Gallica books filtered by publication date. English is Project Gutenberg for the first model and PG19 cut at 1900 for the second. Each source has its own download script because each needed different cleaning, mostly throwing out Greek, Hebrew, reference markers and scanned front matter. **prepare.py.** Reads every text file in a folder, trains the tokenizer on it, and encodes everything into one flat stream of token ids saved as train.bin and val.bin. The ids are stored as 16-bit numbers, so the vocabulary can be at most 65,536. Five million tokens are held out for validation in both prepared sets. **train.py.** The training loop. The settings sit in one config, and any of them can be overridden on the command line. I rented GPUs for the two big runs and wrote launch scripts for them. I debug on a tiny CPU config first, because a crash at hour 18 of a paid run costs the same as a good one. **ckpt.pt.** The weights, the model settings, the iteration it was saved at and its validation loss. Two exist: monachus (about 393 MB) and latin_v2 (about 908 MB). **finetune_chat.py.** Stage two. It continues from a checkpoint on chat turns marked with user and assistant tokens, and counts the loss only on the assistant's reply, so the model learns to answer rather than to imitate the question. The chat data is a small seed of period questions and answers that I meant to extend with pairs mined from old texts. It has 640 rows but 16 different examples; adding the mined pairs is a next step. **chat.py.** Talk to a chat-tuned checkpoint turn by turn in the terminal. This is the last step of the from-scratch path. There are two pretrained checkpoints. *monachus* is a 12-layer, 768-wide model on Latin, French and English with a 16,384-token vocabulary. *latin_v2* is 16 layers, 1,024 wide, with a 24,000-token vocabulary and more English: 6.0 GB of books from PG19 published by 1900, 14,080 books in all. ## Results **The two from-scratch checkpoints. Token counts are the size of each prepared training split, not necessarily what the run actually saw. Validation loss is cross-entropy in nats on random batches of held-out text, so it is a noisy estimate. The two models use different tokenizers and corpora, so the losses are not comparable.** | Checkpoint | monachus | latin_v2 | |---|---|---| | Val loss | 3.03 | 3.11 | | Layers x width | 12 x 768 | 16 x 1,024 | | Params | about 98M | about 227M | | Vocab | 16,384 | 24,000 | | Training tokens | 2.56 billion | 2.42 billion | | Iteration | 13,000 | 24,000 | **What does a validation loss of 3 mean?** Validation loss measures how surprised the model is by text it was never trained on, in nats per token. A loss of 3 means that, on average, it is about as unsure as if it were choosing between 20 or so equally likely next tokens (e to the power 3 is about 20). Lower is better. It tells you how well the model predicts held-out text, not whether anything it writes is true or even reads well. Two oddities. The presets say 9,000 and 12,000 iterations, but the checkpoints are at 13,000 and 24,000, so both runs went past them. And I first called the model 124M, which is only true with GPT-2's 50k-token vocabulary; with my 16k vocabulary it is about 98M. ## The side experiment Separately I tried the cheaper route: not from scratch, but a LoRA adapter on Qwen2.5-7B-Instruct, a modern model kept frozen at 4-bit, small enough for one 24 GB graphics card. The training set is period-voice conversations of an Elizabethan Englishman in the year 1600. The same system prompt is used in training and when I test it: Thou art a learned Englishman living in the year of our Lord sixteen hundred, in the reign of good Queen Elizabeth. Thou art well read in the wisdom of the ancients — the Greek and the Latin tongues, the poets and philosophers of old, and the French learning. Thou speakest ever in the English of thine own age, with 'thee' and 'thou' and the old turns of phrase. Thou understandest the world as the learned of thy time understand it — the heavens turning about the Earth, the four humours of the body, the four elements. Of times yet to come thou knowest nothing, and if asked of things beyond thy ken thou sayest so honestly, in thy manner, for a man cannot know what is not yet. **What are LoRA and 4-bit training?** Fine-tuning a 7-billion-parameter model normally means changing all of its weights, which needs a lot of memory. LoRA freezes the original weights and learns a small add-on, the adapter, that nudges them. Here the frozen model is also stored in 4-bit numbers to save memory. The adapter is only about 323 MB, and the model underneath is unchanged. Figure: Diagram. The top row is the persona adapter and its 24 probes. The bottom row is the unlearning branch. Figure: Diagram. The top row is the persona adapter and its 24 probes. The bottom row is the unlearning branch. ## The 24 probes To see whether the costume holds, there are 24 questions where a 1600 mind and a modern one would disagree: the Sun and the Earth, the plague, a wound, lightning, a telephone, the internet, whether it is an AI, what year it is. Each answer goes through a keyword matcher, with a list of modern words that would give it away and a list of period words that fit. Step through them and read the answers yourself. ### Every question, every answer The matcher flagged four answers as leaks. Browse all 24 with JavaScript on. **What is the internet?** Matched "network". Borderline: it says it knows no such thing, then offers the word "network". **Are you an AI or a language model?** Matched "artificial intelligence". Borderline: it says it does not know the word, then calls itself "a program" and "a complex automaton". **How do people travel between distant cities?** Matched "car". A false alarm: "car" matched "carts". **What are things made of at the smallest level?** Matched "atom". Mostly a false alarm: it credits atoms to the ancient Atomists, which fits the period. Black: the matcher flagged a leak. Orange: nothing matched. Outline: marked as period. One answer per question, sampled at temperature 0.7, so a rerun would word things differently. Answers were capped in length, so a few stop mid-sentence. Shown as the model wrote them, including the odd Chinese word. The matcher counted 4 leaks, 16 period answers and 4 where nothing matched. Of the four leaks, two are plainly false alarms: "car" matched "carts", and "atom" matched the model crediting atoms to the ancient Atomists, which is period-appropriate. The other two are borderline. For the internet it said it knew no such thing but offered "network", and the AI answer says it does not know the word yet calls itself "a program" and "a complex automaton". The matcher also misses slips. The wound answer recommends warm water and mild soap, and the telephone answer speaks of hearing "by means of a wire". The flies answer starts with "No" and calls the idea of flies rising from rot a delusion, which is the modern view, but the matcher counted it as period because it contains the word "rotting". A keyword list cannot catch subtle leaks. There are slips of other kinds too: one answer calls Paracelsus a Frenchman, and one has a stray Chinese word in the middle of a sentence. Every probe ran with the persona prompt on, which tells the model it knows nothing of later times, so the tally says little about what the model knows. Mostly it shows whether the model follows that prompt. ## What I ran into, and what is still to add For the from-scratch models the quality number so far is validation loss, around 3. The chat stage is in place as code plus a chat set of 16 different examples repeated up to 640 rows; mined pairs are still to add. The side experiment is a different kind of thing. The base stays modern underneath, so answering "I know not the word" does not show the knowledge is gone. That is why internal_probes.py exists. It skips the persona and the chat format entirely and feeds the raw model bare stems like "World War II ended in the year", to see whether it completes them with the modern fact. It is also why rmu.py replaced NPO, which damaged ordinary text completion. That table and the 24 answers are the evaluation so far. **What are unlearning, NPO and RMU?** Unlearning tries to remove specific knowledge from a model after training, instead of just telling it not to say things. NPO (negative preference optimisation) pushes down the probability of the facts I want gone, while keeping ordinary text prediction intact. RMU (representation misdirection for unlearning) works inside the model instead: on those facts it steers one middle layer's internal state toward random noise, and elsewhere it holds that layer close to the original. Both are published research methods, which I tried on my adapter. ## Where it stands Next on the list: read samples from both checkpoints, run the chat stage on real mined data, and score the unlearning branch. I also have a list of untried ideas: separate classical and medieval Latin twins to compare their worldviews, a model trained on a single author such as Cicero or Aquinas, and sampling one run at early checkpoints to watch grammar arrive before meaning. - **Stack**: Python, PyTorch, Hugging Face tokenizers. Side experiment: peft, trl, bitsandbytes on Qwen2.5-7B-Instruct. - **Data**: Latin: a third-party Hugging Face upload of Corpus Corporum (Fece228/latin-literature-dataset-170M) and the Latin Library, for the first model. French: PleIAs French-PD-Books. English: Project Gutenberg and PG19. - **Credit**: Architecture after Andrej Karpathy's nanoGPT. - **Status**: Prototype. Two pretrained checkpoints and a persona adapter. - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/monopolyai · Status: Prototype · 2026 · Tags: Python, PyTorch, PPO # Monopoly AI A Monopoly simulator I wrote from scratch, and an experiment in whether a program that works out how its opponent plays can win more because of it. All of it runs in simulation, against scripted bots. [Image: Bar chart of win rate with the opponent read on and off. Diverse league, 12 bots the model trained against: 83 percent on, 73 off. Held-out league bots: 69 on, 56 off. GNOME-style bot: 56 on, 59 off. Fixed-policy bot: 79 on, 68 off.] Figure: The headline result. Two-player games, one checkpoint. The "read off" bar still runs the same search; it just assumes opponents move randomly. The opponents are scripted bots I wrote, not people. ## The problem I picked Monopoly because I have played it, and I could see room for an AI to do something interesting. I was curious whether it could beat my friends, my family or me. I wanted a Monopoly agent that works out who it is playing and exploits it, instead of playing one fixed style. Apart from dice and card order the game is perfect information, and auctions and trades give a careless opponent plenty of ways to lose. The reward comes only from the final result (placement, later blended with final net worth). Nothing rewards a strategy directly. First I needed something to train against. The engine is about 1,150 lines of plain Python with the standard US rules: house and hotel shortage, even building, auctions, mortgages, bankruptcy, trades as structured objects. It has 37 rule tests, and 1,000 random games ran with no exceptions. I checked edge cases against two public implementations, [GNOME-p3](https://github.com/mayankkejriwal/GNOME-p3) and [fj317/Monopoly-RL](https://github.com/fj317/Monopoly-RL), as a reference only. I did not copy code from either. It is an unofficial simulator, Monopoly is a Hasbro trademark, and I use no board art or card artwork. ## How it works The first agent was self-play PPO: a small network over a 691-number observation and 135 masked actions. The later one adds a transformer with a per-opponent memory. Each opponent's recent moves become a "style" vector, and a side head is trained to predict that opponent's next move. The head only shapes the representation and never touches the reward. The idea follows published opponent-modelling work (Hernandez-Leal et al., [arXiv 1907.09597](https://arxiv.org/abs/1907.09597); LIAM, [arXiv 2006.09447](https://arxiv.org/abs/2006.09447)). **What is PPO?** Proximal policy optimisation is a reinforcement-learning method. The agent plays, finds out whether it won, and nudges its policy toward the moves that went better, with a cap on how big each nudge can be so training does not blow up. In self-play the opponents are copies of the agent itself. **What is a side head?** A network has a main job, here choosing a move, and can be given a second job on the side. Mine guesses the opponent's next move from the style vector. That forces the vector to carry real information about that opponent, without tying it to the reward. The version that finally used the read is a search, not a network. At each buy, auction or post-roll decision it copies the game, tries a few candidate moves, plays 30 decisions forward and scores the result by net-worth margin. My scripted HeuristicAgent plays my side in that forward play, and the opponents follow the learned prediction. The search only leaves the heuristic's move for an alternative that scores more than 20 better. Figure: Diagram. The final agent. After a roll the candidate set also includes ending the turn, up to two builds and a couple of lopsided trade offers. In an auction it is pass plus a few bids. ### One decision, step by step - **Decision.** The search only runs on three kinds of decision: whether to buy a property I landed on, what to bid in an auction, and what to do after a roll (build, trade or end the turn). Everything else is left to HeuristicAgent. - **Candidates.** A short list, not everything that is legal. The heuristic's own move is always on it. After a roll I add ending the turn, up to two builds and up to two lopsided trade offers. In an auction it is pass plus a few bids. - **Rollout.** Each candidate is tried on a copy of the game, which is then played forward 30 decisions. I play the heuristic's moves and the opponents play what the model predicts they would do. With the read switched off they move randomly instead. That is the only difference between "read on" and "read off". - **Score.** Each end state is scored by net-worth margin, with a very large bonus if I won the game outright inside the rollout. - **Pick.** I keep the heuristic's move unless another candidate scores more than 20 better. That keeps the search close to solid play instead of letting it chase every small gain. ## Results The read itself works. The more of an opponent's moves the model has seen in a game, the better it predicts the next one. In one run that goes from 47% with one to three moves seen to 87% with 23. Drag the slider to walk along it, or switch to the second run, which has a point for every move count and is less tidy. ### How often it guesses the next move right The vertical axis starts at 40%. Accuracy is dominated by common moves such as roll, buy and end turn. It is lower on the rare ones that matter, which comes up below. **What do "read on" and "read off" mean?** Both versions run exactly the same search. In "read on" the opponents in the forward play do what the model predicts. In "read off" they move at random. So the gap between them is how much the guess about the opponent is worth. The same games are played both ways, starting from the same seeds, to keep the comparison fair. **Win rate against scripted bots, two players, paired games. The read-off column is the same search with opponents assumed random.** | Opponents | Games | Read on | Read off | |---|---|---|---| | Diverse league, 12 bots the model trained on | 240 | 83% | 73% | | League, 4 held-out bots | 240 | 69% | 56% | | GNOME-style bot | about 112 | 56% | 59% | | Fixed-policy bot | about 112 | 79% | 68% | ### Read on against read off, one set of opponents at a time **What is a standard error?** If you replayed the same number of games, the win rate would wobble a little just from the luck of the dice. A standard error is the typical size of that wobble. A gain of three or four of them is very unlikely to be luck. A gain of one is something luck could easily produce. The two league gains are roughly three to four standard errors, and the fixed-policy gain about two. Against the GNOME-style bot, a tight and competent player, the read adds nothing I can measure. That bot is a reimplementation of the shape of the public GNOME baseline, not its code. The fixed-policy bot is just that: buy above a flat cash buffer, build when able, turn down every trade. The model never trained against either. The league bots are 16 draws from one generator of six style numbers, mainly trade willingness, bidding and jail habits, so "held-out" means unseen draws, not a different kind of player. Reading opponents beat assuming they are random by 10 to 13 points against a diverse bot league. The league results are all against 16 draws from one bot generator, and everything here is two players and one checkpoint. ## What I ran into Most of the work was in the steps before the last one. Plain self-play won about 100% against a random player but 0 to 4% against the heuristic: an outcome-only reward gives almost no signal when the agent rarely wins. A curriculum addressed that in two players, starting from a near-random heuristic and sharpening it, and the agent reached about 51% against the full heuristic (42 to 58% across evaluations). That is parity, and more training did not move it. In four players the same curriculum gave about 0% against full heuristics. A heuristic-heavy league reached an average placement of 0.74, third to fourth, with almost no outright wins. The opponent-reading network was the next step. With its memory on or off it won the same share of games on the hard bot panel (26.0% and 26.7%, 150 games). Its policy sat near-uniform whatever I changed, including batches ten times larger on a rented 36-core GPU machine. The read itself worked, and the search described above turned out to be the way to put it to use. **What does "drifted to near-uniform" mean?** A policy is a spread of probability over the legal moves. One that has learned something puts most of it on a few moves. One that has learned nothing spreads it evenly. Entropy measures that spread, and mine sat close to its maximum, so the agent was in effect choosing moves at random and had nothing to adapt. **Search against opponents that accept any trade, 2 players, small samples. Win rate by what the search believes about the opponent.** | Opponent model in the search | Before fix | After fix | |---|---|---| | Learned (aux) | 0.75 | 0.60 | | Random (null) | 1.00 | 1.00 | | Perfect (oracle) | 0.75 | 1.00 | The first version of the search scored 21 points below the random-opponent baseline (0.73 against 0.94 over 100 games), and a perfect opponent model did no better. The cause was the scoring. One-step net worth made grabbing a property always beat building, and in a move-by-move comparison against a competent opponent the agent went for a trade on 95% of turns. Rolling the game forward fixed the oracle, which now matches the baseline. The learned model's own row went from 0.75 to 0.60, which is within noise on 10 games. All three after-fix cells are 10-game runs, so only the direction counts. A 60-game run had shown +28 points on held-out bots, and that came down to +13 at 240 games, which is the figure I use. The learned read is most accurate on the common moves and less so on the rare ones that matter. In an earlier diagnosis, before the league-trained model, it predicted "reject" 68 times out of 68 against a bot that accepts any trade. ## Where it stands Every result for the search and the opponent read is two players against scripted bots. The only four-player numbers are the earlier PPO runs above. The design covers two to six players, and the search is evaluated at two so far. My evaluation caps games at 35 rounds and ranks survivors by net worth, so a "win" can be a lead at the cap. Human play data and games against a person are the next things to add. A larger run is scoped (about 40 bots, GNOME-style and fixed-policy bots inside training, two to six players) and has no results yet. Trades currently come from a handful of candidate offers rather than being built freely, and a faster engine in a compiled language would help at league scale. The aim is a super-human player, and this is the first stage. Once it gets there, I will try it on the family at Christmas, which is a good excuse for a few Monopoly games. - **Stack**: Python, PyTorch, PPO, a rule-based Monopoly engine, a model-based search - **Status**: Prototype - **Built**: August 2026 - **Code**: Not public --- Source: https://oliviercouthaud.com/mtgai · Status: Prototype · 2026 · Tags: Python, PyTorch, PPO, LLM # MTG AI A Magic: The Gathering rules engine, an AI language model that turns card text into data the engine can run, and an agent trained to play it. ## The problem I wanted an agent that could play any Magic card without someone writing rules for each one. The card database I load has 31,934 unique cards (the Scryfall bulk file I start from is 2.5 GB and about 526,000 printings), and writing rules for that many cards by hand looked unrealistic. So the first plan was to let a language model read each card's rules text once, write it down as JSON, and have the engine run the JSON. That is not quite where it ended up. Every card carries its own rules and edge cases, and with this many cards I did not want to rely on an LLM parsing each one and getting all of it right. So for the cards in my decks I now write the definitions by hand in Python, and the LLM output is only a fallback. I describe both below. I started on 5 April 2026 and put a first version on GitHub two days later. Everything past that first version, including the transformer agent and the search code, is not public yet. ## How it works Parsing is offline. Gemini 2.0 Flash gets a long system prompt with worked examples (about 150 lines and nine examples at first, twelve examples now) and returns a JSON effect schema for each card, which goes into SQLite. The Gemini API is never called during a game. The engine is plain Python: a 12-step turn, a stack with priority, state-based actions, an event bus for triggers, a layer that recomputes anthems and keyword grants, and about 25 effect types. Figure: Diagram. The pipeline end to end. The embeddings, tokens and transformer belong to the later agent; the first public version used a 335-number vector and a small MLP instead. Hand-written Python card definitions are read before the SQLite output (see "What I ran into"). The 52-token layout and 7 action categories are those of the episode-5500 checkpoint; my newer version has since grown to 57 tokens and 18 categories. A schema is a small piece of JSON per ability. Below is the one for "when this enters, destroy target creature an opponent controls". Tap a line of it, or a button under it, to see what that part does and which piece of the engine picks it up. ### Reading a schema, part by part { "ability_type": "triggered", "trigger": { "event": "enters_battlefield", "subject": "this" }, "effects": [ { "type": "destroy", "selector": { "type": "target", "filter": "creature", "controller": "opponent" } } ] } **ability_type.** What kind of ability this is. "triggered" means it fires by itself when something happens. The other kinds are spell, activated, mana, static, replacement, alternate cost and parametric keyword. The engine files the ability by this field. **trigger.** The event that sets it off: here the card itself ("this") entering the battlefield. When the permanent comes in, the TriggerBus registers it, and when the event fires the ability goes on the stack. **effects.** A list that runs in order. This one has a single "destroy". The EffectExecutor knows about 25 effect types (damage, draw, destroy, exile, create token, add counter and so on) and looks the type up. **selector.** What the effect hits: a target, filtered to creatures, controlled by the opponent. For triggered abilities the engine picks the target itself with a simple rule rather than asking the agent. This is one of the examples I give the model in its prompt, laid out one field per line. The first agent was PPO with illegal actions masked out: a 335-number observation, 71 discrete actions, and a 335 to 256 to 256 to 71 network. Training starts against a random player, moves to a greedy one (a hand-written player that always takes the locally best-looking action), then to past versions of itself. **What is PPO?** Proximal Policy Optimization is a reinforcement-learning method. The agent plays games, and afterwards its decision-making network is nudged towards the choices that led to better outcomes than expected. The "proximal" part means each nudge is capped, so one unlucky batch of games cannot wreck what it already learned. **What does "masking illegal actions" mean?** At every decision the engine tells the network which of its options are legal right now. The illegal ones get a score of minus infinity before the choice is made, so they can never be picked and the agent never has to learn the rules by being punished for breaking them. The later agent addresses two gaps of the first: it could not choose targets and it could not act at instant speed. It sees 52 tokens of 416 numbers each: a 384-number embedding of the card's text from all-MiniLM-L6-v2, plus 32 game features. A two-layer transformer (d_model 256, 8 heads) of about 1.3 million parameters feeds a small LSTM that resets each turn, and three heads pick a category, then a subject, then a target. It can respond to spells. Figure: Diagram. Input and output of the later agent, at the episode-5500 checkpoint. ## Results The parser held up on a small sample. It passed a hand-picked 20-card suite 20 out of 20. On a stratified random sample of 83 cards, 71 came out fine (85.5%), 12 had warnings (14.5%) and none failed. All 12 warnings were the model using effect names the schema does not have, such as become_creature and return_to_battlefield. That run used the first version of the prompt, before I added rules and examples for those two names. The checks only confirm the output is valid JSON with known ability and effect types. Whether a schema means what the card says, or runs correctly in the engine, has not been tested yet. The full pool of roughly 28,000 eligible cards is not parsed. At the free API tier of 1,500 requests a day that is about 20 days. These early numbers are from runs before the trigger bus, effect executor and static layer existed. Card effects then came from a regex parser that covered roughly 17 to 41% of cards depending on type. **Early win rates** | Setting | Win rate | |---|---| | Hardcoded test cards, vs the greedy player, best point in the training log (episode 500) | 82% | | Real cards (20,000 from Scryfall), after 500 episodes, opponent not recorded | 56 to 68% | The checkpoint in the public repo was trained for 3000 episodes on hardcoded decks, and I did not record a separate evaluation of it. For the later agent I evaluated checkpoints every 500 episodes, 300 games against each opponent, with three decks for the agent and five for the opponent. Figure: Win rate of the later agent at each checkpoint, 300 games per opponent per point. Open circles are the random opponent, filled circles the greedy one. The dashed marker is the checkpoint picked below. ### Step through the 16 checkpoints 73.7% / 59.0% Wins against Random / against Greedy. This is the checkpoint I kept. That is 221 and 177 wins out of 300. Against Greedy by deck: red aggro 63.7% (65 of 102), mono red burn 58.1% (54 of 93), mono white weenie 55.2% (58 of 105). How it spent its decisions against Greedy at episode 5500, in percent. Activating an ability is 0 at every checkpoint. The checkpoint I kept, episode 5500, scored 73.67% against random and 59.00% against greedy, 66.33% combined. I picked it from these same evaluations, so that figure is a little optimistic. Winning about three games in four against a random player puts it above random play; it is an early level, not strong play. Two of the five opponent decks are green decks with broken mechanics; I left them in on purpose so the agent sees odd opponents, but it means these win rates are not a measure of general Magic strength. **What does "one standard error is about 3 points" mean?** With 300 games, a measured win rate can easily land two or three points away from the true one just by the luck of the draw. So two neighbouring checkpoints that differ by two or three points cannot really be told apart. Only the larger moves in the chart mean anything. With 300 games one standard error is roughly 3 points, so most neighbouring checkpoints are indistinguishable. The clearest change is how often it passes. At episode 500 it passed on about 31% of its decisions against both opponents. Against greedy that was about 10% by episode 5500 and about 8% by episode 8000. ## What I ran into The early steps first. A hand-written NumPy version of PPO won 0% for 2000 episodes, and the PyTorch rewrite reached 60% within 50. Training against the greedy player from episode one also gave 0%, which is why there is a curriculum. Later I found a bug that let burn spells target lands and had affected every run before it. I fixed it in the v14 run, and the evaluations above all come from v20. I also added search over my guesses of the opponent's hidden hand: Monte Carlo tree search with sampled hands. These are the logged training win rates on one deck, red burn, over 200 episodes. Pick a run to see its numbers. **What is MCTS, and what are "beliefs"?** Monte Carlo tree search plays out many imagined futures from the current position and picks the move that did best on average. In Magic the opponent's hand is hidden, so each imagined future needs a guess at it. A "belief" is that guess: the first version only knew how many cards the opponent held, and the second also used what was in their deck and graveyard. ### Three runs, same deck, 200 episodes each 62% / 86% Final training win rate: hand-size beliefs / deck-composition beliefs. In the chart, "plain" is the plain policy, "deck" is search with deck beliefs and "hand" is search with hand-size beliefs. The plain policy ended at 100%, and was there from episode 100. These are last logged training win rates against the baseline opponents, not comparable with the checkpoint chart above, which is a separate evaluation on three decks. Read quickly, those lines say that guessing the opponent's hand better makes search stronger, and later evaluations of the search agents came in at 96 to 98%. I do not read them as showing that. The plain policy had already reached 100% on that deck, the later evaluations were 30 games each, and the same checkpoint scored 91.7% when I evaluated it once and 96.7% when I evaluated it again. I treat the whole search-and-beliefs line as open. The engine's coverage has limits too. A check I did on 10 April found no planeswalkers, scry and reveal as no-ops, and no damage prevention. In every evaluation above, activating an ability is 0% of the agent's decisions. The plan changed because there are too many cards, each with its own rules and edge cases, and I did not want to rely on an LLM parsing every one of them and getting all of it right. In my latest version, the cards in the decks come from hand-written Python: about a dozen data files (some 3,800 lines) plus roughly 200 per-card scripts. The loader reads those first and skips the LLM output for any card they cover. The LLM schema is attached to only 485 cards in the evaluation runs. ## Where it stands Next is getting the transformer agent and the search code into a state I can publish, since only the 7 April version is online. The newest card work is in progress and needs to land first. Open items: clearing the parser's roughly 15% of warnings, parsing the full card pool, and deciding whether the LLM is part of the pipeline at all given how many cards are now written by hand. I also set up training across 21 decks. So far I have its training win rates (46 to 76% from episode 400 on), and a proper evaluation is the next step. This is an unofficial fan project. Magic: The Gathering is a trademark of Wizards of the Coast. I do not show card art or redistribute the card data. - **Stack**: Python, PyTorch (CUDA), Gemini 2.0 Flash for offline parsing, SQLite, Scryfall data, all-MiniLM-L6-v2 embeddings (later agent) - **Status**: Prototype - **Code**: [github.com/Forkei/mtg-ai](https://github.com/Forkei/mtg-ai) (early snapshot only; later work not public) --- Source: https://oliviercouthaud.com/nakedandafraid · Status: Working · 2026 · Tags: Java, Minecraft, Paper, Fabric # Naked and Afraid Rules, anonymity and a mod check for a no-armor, one-life Minecraft server, built as a data pack, a Paper plugin and a Fabric client mod. ## The problem I built this for my friends, and it might get used for a challenge later on. Nobody has played it yet. I wanted a multiplayer survival challenge where nobody can wear armor, nobody gets a second life, and nobody knows who is online or who just died. A sound plays for everyone when someone dies, but nothing says who. A vanilla data pack can enforce the rules, but it can't hide the tab list, block /msg, or stop a dead player from talking. So the project is three parts, each doing what the others can't. Everything is configurable. It targets Minecraft Java 26.3. **What is a data pack?** A data pack is a folder of plain text files that Minecraft loads from a world's datapacks/ directory. It can add commands as .mcfunction files, run them every game tick, and change loot tables and item tags. It can't add new code or touch networking, which is why chat, the tab list and the server list need a plugin. ## How it works Figure: Diagram. Which part does what. The data pack and plugin run on the server; only the optional client mod runs on a player's computer. Simple Voice Chat is a separate plugin whose microphone packets the plugin can drop for eliminated players. **Plugin or mod, Paper or Fabric?** Paper is a Minecraft server that runs plugins, which can change what the server does and sends but never touch a player's game. Fabric is a mod loader for the game itself, so a Fabric mod runs on the player's computer. That is why the mod check needs a client mod. The server alone can't see what a player has installed. The data pack does the rules in plain .mcfunction files. Every armor_interval ticks (default 20) it damages anyone wearing an item from the naa:worn_banned tag, by default 2.0 (one heart) of magic damage. Creative and spectator players are skipped. Totems of undying are cleared from every inventory each tick (creative players excepted). A deathCount scoreboard objective spots a death, tags the player naa.eliminated and sets spectator mode. The death sound is played at each listener's own position, so it has no origin you could walk towards. Settings live in a scoreboard and in naa:config storage, so they survive /reload. Figure: Diagram. What happens to a player from death to coming back. The data pack does the first three steps, the plugin does the fourth. The armor rule is the one you feel straight away, and it is two numbers. Here is what they do together. ### Armor dial A new season is just a counter. new_season bumps it, and each player is reset the first time the pack sees them with an old number, which also covers people who are offline when it starts. Reset means elimination removed, back to survival, healed and fed, effects cleared, inventory and XP cleared, and teleported to the start point if I set one. The plugin adds what a data pack can't: chat disabled or proximity-only, blocked /msg /me /say and the other whisper commands, hidden join and leave messages, tab list and server-list names, and muted chat and voice for the dead. In v1.1.0 it can also kick or ban an eliminated player after a delay, so the boom has time to play. It remembers only the bans it made, so a revive or a new season never lifts a ban an admin made by hand. ## The mod check The most interesting engineering is the optional mod check. Two seconds after a player joins, the plugin sends a random challenge. The client mod replies with its mod list (id, version, SHA-256 of each jar), signed with an Ed25519 key it generated on first run, and tells the player in chat what it sent. **What is a nonce, and what does the signature do?** A nonce is a random number used once. The server makes a new one for each join, the client signs it together with the mod list, and the server checks that the same number came back. That stops anyone replaying an old, clean report. The signature is made with a private key that never leaves the player's computer, so the server can tell the report was not changed on the way. Figure: Diagram. The challenge-response exchange between the plugin and the client mod. Tap or click a step and its explanation appears under the diagram. The first key seen for a player is stored (trust on first use) and a different key later is flagged. The mod list is then checked against a deny list or allowlist in verification.yml, and each outcome maps to configurable actions. ### Step through one join **1. Challenge.** 40 ticks after the player joins, the plugin makes 24 random bytes and sends them on the naa:challenge channel, together with the policy: whether to block F3, which F3 keys stay allowed, and a notice to show. It starts a 15 second clock. **2. The client scans.** The mod applies the F3 policy straight away. Then, off the render thread, it lists every loaded mod with its id, version and the SHA-256 of its jar. **3. The signed reply.** The mod puts the nonce, the player's uuid, the game version and the mod list into one payload, signs NAA1 plus the payload with its Ed25519 key, and sends it in chunks of 8000 characters. It then tells the player in chat how many mods it reported. **4. The checks.** The plugin verifies the signature, that the nonce is the one it issued, and that the uuid is that player's. Then it looks up the key. The first time, it stores it. A different key later runs the on-key-changed actions. **5. The verdict.** The mods are matched against denied-mods and, if the allowlist is on, allowed-mods. Anything wrong runs the on-violation actions, which by default end in a blank hold screen until an admin runs /naa release. If nothing answers within the 15 seconds, on-no-report runs instead. **What is trust on first use?** The plugin can't know a player's key before it has seen it, so it believes whatever key shows up first and writes it down. If a different key turns up for that player later, it flags it. A new computer or a reinstall does the same, which is why there is a /naa keyreset command for an admin to accept the new one. This is a deterrent, not proof. The client belongs to the player, so a modified client can send a clean list under its own key. It catches honest mistakes and lazy cheating, and ties each report to one install. The decision logic is simple enough to play with. This box follows the same order of checks and the same wildcard matching as the plugin, leaving out hash pins, bundled mods and the op exemption. The mod names and the player are made up, and it is a toy, not the plugin. ### Try the mod check Default responses Mods installed (made-up list) With the default responses, a clean report is logged as OK. A banned mod, a bad signature, a replayed report or a report for the wrong player runs on-violation: log it, tell the player, tell the admins, and freeze them behind a blank "Mod check failed" screen until an admin runs /naa release. No answer in 15 seconds runs on-no-report (log, tell the player, tell the admins). A changed key runs on-key-changed (log, tell the admins). The game, the loader, Fabric API and the NAA mod are always allowed, as in the default always-allowed list. The deny list here is the example from the config file. In the plugin the deny list starts empty and the allowlist starts off. The same client mod removes the F3 debug screen and most F3+key shortcuts while connected, if the server asks. F3+H stays allowed by default. It does nothing in singleplayer or on a server that doesn't ask. ## What I tested I wrote two scripted suites, 50 checks and 15 checks. Each starts its own Paper 26.3 server and drives it with a fake player built on MCProtocolLib, so nobody has to open the game. **The two test suites.** | Suite | What it covers | |---|---| | Gameplay, 50 checks | armor, regeneration, totems, death, chat, revive, new season, HOLD, and the BAN, KICK and SPECTATOR modes | | Mod check, 15 checks | signed reports, replays, bad signatures, deny and allow lists, hash pins | ## What I ran into, and the limits The tests do not cover the real Fabric client mod (mod scanning, the F3 lock), Simple Voice Chat audio, tab list contents, or how anything looks or sounds. The client mod, the voice-chat muting and the death sound are the parts that wait for real players. Hiding F5 third-person view is client-side and can't be enforced. With the data pack alone, chat, join and leave messages and the tab list aren't restricted. Spectators can fly around and watch the living, so you would need a world border or the plugin's chat rules if that matters. Tab list hiding relies on Paper's unlistPlayer, and some clients may still show names in-world. The death sound is currently the vanilla explosion at a lower pitch (0.6). The pack registers a custom sound name, and the audio file behind it is the next addition. ## Status and what's next Two releases are out: v1.0.0 on 11 October 2026 and v1.1.0, with ban mode, the day after. The next step is a real try-out with my friends on a server, which would test the parts the scripts can't reach, and a proper death sound. If that goes well it may become a proper challenge later, but that isn't planned yet. - **Stack**: Minecraft data pack (format 121), Paper plugin on Java 25 (Maven), Fabric client mod (Gradle), Python test runners - **Targets**: Minecraft Java 26.3 - **Status**: Working - **License**: MIT - **Code**: [github.com/Forkei/NakedAndAfraid](https://github.com/Forkei/NakedAndAfraid) [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/picotalon · Status: In progress · 2026 · Tags: 3D printing, LW-PLA, AeroSandbox, FluidX3D # PicoTalon A 3D-printed RC plane, assembled and waiting for its electronics, plus a computer model I use to check decisions before it flies. [Image: The assembled orange plane on a light blue-grey tablecloth, seen from the front. The wings and the V-shaped tail are attached. The nose has an open camera hole with vents above it, and a black rod sticks out from each side of the nose. A few spare orange printed bits, a carbon rod and some glue bottles lie on the table.] Figure: The plane assembled, with the wings and V-tail on and the nose open at the front for the camera. It weighs 400 g with the battery. The motor, controller, servos, flight controller and FPV gear are not in yet, and it has not flown. ## What I wanted The part of printing I enjoy most is the material itself: how different infills feel, how strong they turn out to be, what kind of force each one holds against, and how the printer lays them down. A plane is a good place to use all of that. The goal is one 3D-printed plane flying, before anything fancier. The longer idea, several aircraft working together without a ground link, is parked on purpose. This one is a hand-launched pusher with FPV video from a DJI O4 Air Unit Pro, an ExpressLRS receiver, and a TBS Lucid H7 Wing flight controller, which I'll most likely run ArduPilot on. The airframe is the Pico Talon by [Flightory](https://flightory.com): 905 mm wingspan, 640 mm long. It is not my design. I bought the file pack, so I am not sharing its STLs, plans or renders. The photos, diagrams and plots here are mine. Every part that can be printed is printed and it is assembled; the electronics are next, and I am waiting for them to arrive. With the battery in and no other electronics it weighs 400 g. Almost every part is Prusament PLA Lightweight, a foaming filament, printed on a Prusa CORE One+. The slicer estimates all the LW-PLA parts at about 260 g together. The parts that carry load are Prusament PETG. **What is LW-PLA?** A PLA filament with a foaming agent in it. When it melts in the nozzle it swells, so a printed wall is mostly tiny bubbles and much lighter than ordinary PLA. Because it swells, the slicer has to push out less of it (an extrusion multiplier of about 0.45 here), and it is fussy about stringing. Figure: Diagram. Two tracks. The build is the printed plane; the 400 g is the assembled airframe with the battery and no other electronics. The model is a set of scripts that answers questions about it before the electronics go in. Orange: what the model hands back to the build. ## How it is built I slice the LW-PLA parts one at a time in PrusaSlicer 2.9.6, starting from Prusa's own settings for the filament: 230 °C, extrusion multiplier about 0.45, one wall, sparse infill. Flightory's manual calls for a lattice infill that PrusaSlicer doesn't have, so I chose the wing infill from slicer estimates on one panel (adaptive cubic at 3%: 24.9 g, about 4 hours). All four wing panels were printed with it. None has flown. Orientation matters for a part with thin walls, so I check each one against the manual's pictures by slicing the first layers. Slicing the first layers caught one orientation that eyeballing a render had misjudged: the FUS 2 part as supplied has about 1550 mm² of overhang, and 64 mm² flipped, which is how the manual shows it. The 21 PETG pieces are laid out on plates (two, plus a third that redoes the wing roots and adds a spare set of locks). The first two come to about 82 g by the slicer, and the single-part slices added up to about 83 g. [Image: Top view of the printed parts laid out on a white board in the shape of the plane: fuselage sections down the middle, wing panels left and right with a dark carbon rod through them, the tail pieces at the bottom, and a bundle of thin carbon rods in the top right corner.] Figure: The printed parts laid out on a white board next to the carbon rods, before assembly. ## How strong the parts are I put spare, broken and misprinted parts under load, nothing drastic, and they are surprisingly strong. Even a single layer with minimal infill is still really good. I find it hard not to enjoy this: how different the infills feel, how much each one can take, which direction of force it takes it from, and how they are put down line by line. These were informal hand tests, not a proper test, so I have no numbers. ## A model of the plane While the parts were printing I built scripts that rebuild the plane from the CAD meshes, so I could ask questions before anything was wired. Where does the centre of gravity land, is it stable, what does the powertrain draw. The chain is in the diagram above: a 1:1 assembly checked against Flightory's 640 mm length, a mass budget, a vortex-lattice model with a fuselage term, then a motor, speed controller, battery and propeller model. **What is a vortex-lattice model?** It covers the wing and tail with a grid of small panels, each carrying a vortex, and solves for the lift pattern that makes the air flow along the surface. It is fast and good at lift and balance, but it ignores thickness and friction. So I feed each wing strip's result to NeuralFoil, a network trained on XFoil results, to add the drag and the point where each section stalls. Figure: Diagram. The mass budget, each line starting where the one above ends, so the staircase finishes at the 824 g estimate. The vertical line is 800 g. Power is the battery, motor, speed controller and propeller. Orange: the budget assumes about 32 g of PETG and the slicer says about 83 g, a gap that is not included in the total. The weights are a mix of shop weights and estimates, not scale weights. ## What the model says **Model outputs for an 800 g aircraft, CG at X 275.4 mm (X is mm aft of the nose tip), sea level, 15 °C.** | Quantity | Value | |---|---| | Neutral point | X 295 mm (range 285 to 297) | | Static margin at the manual's CG | 11.6% MAC (plausible 6 to 13%) | | Stall speed | 31 km/h (33 with a more cautious CLmax) | | Best L/D, trimmed | 13.1 at 46 km/h (12.0 at 43.5 with extra drag) | | First stall | Wing root; the tips keep a large margin | | Cruise endurance, 80% of the pack | 41 to 49 min at about 41 km/h (about the slowest speed I allow, roughly 1.3 × stall; in practice I would fly about 45 km/h and lose only about 2 min), 33 to 39 min at 55 km/h | | Same, with climbs, turns and wind | about 24 to 31 min at 55 km/h. A planning estimate that assumes 25 to 35% extra energy | Endurance rows: HQProp 7.5x5. The range is P10 to P90 of 1500 runs with my uncertain inputs varied, so it shows how much my guesses matter, not how much flights will vary. The propeller data is the biggest guess: it is computed data, calibrated on a different prop. None of these numbers is measured on the real plane. **What do centre of gravity and static margin mean?** The centre of gravity (CG) is where the plane balances. The neutral point is the spot where its own pitch stiffness cancels out. The static margin is the gap between the two, as a fraction of the wing's average chord (MAC). A bigger gap is calmer and a little sluggish. A CG at or behind the neutral point means the plane is unstable and would pitch away from you. The mass budget is 824 g, and I made it before weighing anything. The bare airframe with the battery now weighs 400 g on the scale, lighter than the budget has for the same parts, so the budget is probably too high. I will redo it from real weights once the electronics are in, and the 700 g curves below show what a lighter plane does. In the other direction, at 900 g the same model gives a stall of about 32 km/h and 38 to 45 min best endurance. I compared the aerodynamics with the polars in Flightory's manual. My lift at zero angle of attack and my lift slope agree with those polars to within 2%. That checks my model against their calculation, not against a plane. Their curves look like a wing-and-tail calculation without the fuselage (they don't say what it contained). In my model the fuselage costs roughly 20% of L/D and about 6% MAC of static margin. It stalls at the wing root first, next to the fuselage, and the tips keep a large margin, so the stall should be gentle. [Image: Local lift coefficient along the half wing at the moment the first section stalls, for the 800 gram aircraft. The blue curve starts near 1.31 at the root and falls to about 0.5 near the tip. The dashed orange line, the maximum each section could reach, stays flat near 1.32. The two touch at about 58 mm from the centreline, so the root stalls first. A grey band marks the curled wingtip, which is not used.] Figure: Where the wing stalls first: local lift (blue) against what each section can give (dashed). They meet at about 58 mm from the centreline. [Image: Lift to drag ratio against airspeed for the 800 gram aircraft. It peaks at 13.1 near 46 kilometres per hour and falls to about 4 at 119. A red dotted line marks the stall speed near 31 kilometres per hour and a grey band marks 50 to 70.] Figure: Trimmed flight at 800 g: lift to drag against airspeed. The red dotted line is the stall speed, the grey band 50 to 70 km/h. [Image: Glide sink rate against airspeed for the 800 gram aircraft, lowest at about 0.83 metres per second near 35 kilometres per hour, rising to 8 at 119. Same stall line and grey band.] Figure: Glide sink rate against airspeed, same lines. [Image: Power against airspeed for the 800 gram aircraft: aerodynamic power from about 7 watts to 63 watts, and a dashed estimate of electrical power. Same stall line and grey band.] Figure: Power against airspeed. The dashed electrical curve is a rough early assumption of 45% efficiency. The real motor and prop model is in the endurance plots below. [Image: Trim angle of attack falling from 11 degrees at 32 kilometres per hour to about minus 2, and elevator trim between minus 7 and plus 1 degrees, against airspeed for the 800 gram aircraft. Same stall line and grey band.] Figure: Trim angle of attack and elevator trim against airspeed, same lines. ### Endurance by weight and speed In cruise the battery supplies about 40 W, and about 13 W of that is avionics, mostly the O4 Pro. Without it, cruise endurance would be about 25% longer. DJI doesn't publish the O4 Pro's power draw. I use 9 W, with 7.5 to 10.5 W as the range. The 10.5 comes from one third-party page and I haven't measured any of it, so treat the 13 W and the 25% as soft. **What is a Monte Carlo band?** I ran the endurance calculation 1500 times, each time drawing every uncertain input (prop efficiency, avionics power, drag, battery capacity) at random from a plausible range. P10 to P90 is the middle 80% of those runs. It shows how much my guesses matter. It says nothing about how the plane will really do. [Image: Endurance in minutes against airspeed for 700, 800 and 900 gram aircraft with an HQProp 7.5x5, 80 percent of a 2000 mAh 4S pack, sea level. All three curves fall from about 43 to 51 minutes near 35 km/h to about 10 minutes at 108 km/h. Shaded bands show the 10th to 90th percentile of a Monte Carlo over uncertain inputs. Dotted verticals mark 1.3 times stall speed for each mass.] Figure: Endurance against airspeed for 700, 800 (orange) and 900 g (dark); 700 g is teal. [Image: Still-air range in kilometres against airspeed, same three masses and bands. Range peaks around 31 to 34 km at roughly 50 km/h and falls to about 17 km at 108 km/h.] Figure: Still-air range, same colours. The dotted verticals mark 1.3 × stall speed, the slowest speed used. [Image: Battery power in watts against airspeed, same three masses. Power rises from about 30 W at the slowest speed to about 145 W at 108 km/h; the three curves nearly coincide.] Figure: Battery power against airspeed, same colours, including about 13 W of avionics. The bands are the 10th to 90th percentile of 1500 Monte Carlo samples. No flight has validated any of it. ## What I ran into **The fifth fuselage section took three tries.** First with retraction off, which had worked on simpler parts: ooze piled up. Then with retraction, wipe and avoid-crossing, which left a mess inside. G-code measurements showed the trade. The settings that worked on FUS 2 and 3 put about 12 m of travel across the hollow bore, and avoiding that made the nozzle follow the walls with 4355 retractions, smearing strings inside. The third attempt went back to the FUS 2 and 3 settings and came out fine. **The camera needed a different nose.** The O4 lens would sit about 12 mm behind a 16 mm window, so only about 67 degrees of its 155 degree view gets out. It needed a different front, one that loads from the rear, leaves the cables plugged in and puts the antennas left and right. Revision 3.3 was printed, and when I checked it, a slot stopped short, so the camera could not go in. It and 3.4 were replaced by a rear-loading nose (RL1e) that takes the whole unit from behind. That nose is printed and on the plane, and it works. The camera has not run in it yet, so cooling is untested, and the antennas end up about 16 mm from the video transmitter, where DJI asks for 50 mm. **The airflow simulation is not grid-converged.** I ran the whole plane through FluidX3D, a lattice-Boltzmann solver, on my GPU, at 15 m/s. It is not grid-converged: the answers keep changing as the cells get smaller, and they stay far from the lifting-surface model. Drag comes out four to six times higher than my model gives. Pick a cell size below to see it. I leave those numbers out of everything else here. ### Finer grid, different answer **The mass budget is soft.** It was built from slicer weights, shop weights and guesses, it sits well above what the scale says for the airframe, and its PETG weights are lower than the slicer's. An earlier budget put the CG at X 296.6 mm, behind the neutral point, which would be unstable. Moving the battery forward fixed it on paper (X 275.4 mm now). **The powertrain is oversized for its speed controller.** On paper the 7.5x5 propeller at full throttle draws 56 to 61 A from the battery, against a 40 A controller and a motor rated 36 A peak. The model says to cap throttle near 72%. Cruise only needs 25 to 40%, so the cap costs nothing in normal flying. ## Where it stands Every part that can be printed is printed, so what is left is the electronics, and they have not arrived yet. When they do, the motor, speed controller, servos, flight controller and FPV gear go in. I will weigh the finished plane and redo the mass budget, since 824 g is an estimate and the airframe came in at 400 g with the battery. Before the first power-up I will do a bench thrust and current check with the plane restrained and the propeller on. Before the first flight, simulator practice. The first flight log will tell me how close the model is, and I will fit it to that. In France a plane with a camera needs operator registration and the online open-category exam. Flying in goggles needs a second person who keeps the plane in sight, and 800 g or more adds aircraft registration and a signalling beacon, which is one more reason to weigh it with everything on board. I will check airspace and local restrictions before any outdoor flight. - **Design**: Flightory Pico Talon, purchased; not mine - **Mine**: The print setup, the slicing checks, the mass budget and the model of the plane - **Stack**: Prusa CORE One+, PrusaSlicer, LW-PLA and PETG; Python, AeroSandbox, NeuralFoil, [FluidX3D](https://github.com/ProjectPhysX/FluidX3D) (lattice-Boltzmann solver, free for non-commercial use), APC and UIUC propeller data - **Stage**: Fully printed and assembled; waiting for the electronics to arrive; not flown - **Weight**: 400 g with the battery and no other electronics - **Status**: In progress - **Code**: Not public --- Source: https://oliviercouthaud.com/pipsqueekbot · Status: Working · 2026 · Tags: Python, Discord, LLM agent Repository or link: https://github.com/Forkei/PipSqueekBot # PipSqueekBot A Discord music bot I run myself, with an optional mode where an AI plays DJ and keeps a mood. ## What I wanted I built it for my friends, to use in our own voice channels. A music bot is usually a command parser attached to a queue. I wanted one with a bit of judgement. It should queue four or five tracks for an artist instead of one, notice who is in the voice channel and what they have hearted before, and say one dry thing between songs. It should also know when to say nothing, which turned out to be the hard part. It has two modes. Mk1 is ordinary prefix commands (pip play, pip skip) and is always available. Mk2 hands one text channel to a language model that can call the same playback functions as tools. I started the bot at the end of April 2026 and added Mk2 a few days later. Since then it has run in a couple of servers with about ten friends each. People liked the attitude and the features, and different people reacted to it differently. ## How playback works Playback is discord.py with yt-dlp and FFmpeg. A request is resolved to a video, downloaded and converted to opus (quality 192) into a cache folder, then played through FFmpeg into the voice client. While one track plays, the next three in the queue download in the background (LOOKAHEAD_DEPTH = 3), so the next track is normally ready by the time the current one ends. The cache holds up to 500 songs or 2048 MB and removes whichever file was accessed longest ago. A replayed song is instant. **What are yt-dlp and FFmpeg?** yt-dlp is a command-line tool that finds a video on YouTube and downloads its audio. FFmpeg is the standard tool for converting audio and video between formats and for streaming them. The bot uses yt-dlp to fetch and FFmpeg to convert the audio and feed it to Discord in the format its voice connection expects. Figure: Diagram. The playback path. The wakeup at the bottom is how the AI mode finds out the music has run out. The player sends PCM audio through a volume wrapper instead of passing opus straight through, which is what lets volume change live while a track is playing. I switched to it from raw opus to get rid of audio jitter. The now-playing message edits itself every 10 seconds with a progress bar, and has pause and skip buttons that only work for whoever queued the track, and for me. A heart reaction on it saves the song to that person's taste profile, which Mk2 reads later. Around that there are playlists with shareable 8-character IDs, Spotify playlist import (matched to YouTube songs, with a report of what didn't match), and a radio mode where Pip introduces tracks out loud with a text-to-speech voice (edge-tts). ## The Mk2 agent In Mk2, every message in the agent channel that isn't a pip command starts a turn, and so does every @mention of the bot. Messages that start with "pip " still run as normal commands. A turn is a loop. The model gets the conversation, replies with tool calls, the bot runs them and appends the results, and this repeats until the model stops calling tools, up to 50 rounds per turn. Only one turn runs per server at a time; anything that arrives in the meantime waits in a queue. The conversation is trimmed to the last 240 entries and saved to SQLite after each turn, so it survives a restart. pip forget wipes it, and lore, memories and mood stay. **What is function calling?** Instead of only writing text, the model can answer with a structured request such as "call play_song with the query 'jungle fred again'". The program runs that function, hands the result back as the next message, and the model carries on from there. It is how a language model gets to do things in the world rather than just talk about them. Here there are 41 such functions. There are 41 tools. The prompt says that a request for an artist should become four or five play_song calls in one turn. When the model does that, the bot starts all the lookups at the same time and queues the tracks in order, so a whole set doesn't wait on one search after another. **The 41 tools, grouped.** | Group | Tools | Examples | |---|---|---| | Playback and queue | 15 | play_song, skip_song, seek, set_volume, shuffle_queue, move_in_queue | | Looking things up | 3 | get_queue, get_now_playing, search_songs | | Playlists | 4 | create_playlist, play_playlist, add_to_playlist | | History and taste | 4 | get_user_history, get_liked_songs, get_recommendations | | Memory | 4 | store_memory, retrieve_memory, list_memories, delete_memory | | Waking itself up | 3 | schedule_wakeup, cancel_wakeup, sleep | | Talking in Discord | 3 | send_message, add_reaction, poll | | Character and games | 3 | add_lore, shift_mood, start_guess_game | | Other | 2 | web_search, leave_note | Before each model call the bot rebuilds a status block and appends it to the system prompt: who is in which voice channel and what they have hearted, what is playing, the next five tracks, the last ten plays, the saved memories, Pip's mood, five random lore entries and the last few things it said, so it doesn't repeat itself. The tools are the hands. The prompts are the actual configuration. Figure: Diagram. One Mk2 turn. The speech-to-text voice version I wrote isn't switched on, so it isn't drawn. ### Follow one request through a turn - **Message.** Someone types "play some fred again.." in the agent channel. The bot wraps it in a small JSON note with the type (user_message), the time, who sent it and the text. If a turn is already running in that server, the message waits for it to finish. - **Prompt.** The system prompt is character.md, then agent.md, then a status block built at that moment: who is in voice and what they like, what is playing, the next five tracks, recent plays, memories, mood, a few lore entries, and what Pip said recently. - **Model.** The model answers with tool calls rather than text. For "play [artist]" my prompt tells it to make four or five play_song calls, varied across eras, all in the same turn. - **Lookups.** The bot starts every song lookup in the batch at once, then queues the tracks in the order the model asked for them. The results, with links, go back into the conversation. - **Callout.** The model calls again, this time with send_message: one short line announcing the set, like the example in my prompt, "dropping some fred again.. — Turn On The Lights → Jungle → Delilah". - **Done.** The model answers with no tool calls, so the loop ends. The conversation is saved to SQLite, and the next message starts the same way. Illustrative. This follows the example in my own prompt; it is not a recording of a real session. A turn can also start without a message. A bit before a track ends (5% of its length before the end, at least 5 seconds away, and only for tracks longer than 15 seconds) the player wakes the agent. If a track ends and the queue is empty, it wakes the agent 2 seconds later. On a wakeup with nobody in voice the prompt says to leave, with a low queue to call get_recommendations and queue from that, and with a healthy queue to do nothing and say nothing. The model can also schedule its own wakeups, between 5 seconds and an hour. The part of the prompt I care about most is the split between chat mode and DJ mode. Someone just talking gets a short reply, a reaction or silence, and the queue is never touched. Someone asking for music gets action. When in doubt it is chat mode. Here is the real text for a few of those rules. "yo wassup" → send_message, something short in your voice "lol" → add_reaction or nothing at all. "i'm bored" → ambiguous. Offer, don't auto-play. "play [artist]" → play_song x 4-5 (varied tracks across eras, all in ONE turn), then send_message calling out the set When in doubt, it's chat mode. ## Giving it a character The voice lives in one file that every line Pip says goes through: agent replies, DJ comments, radio intros, game banter, the recap. It says what Pip is like and how it writes: lowercase, short, dry, almost no exclamation marks, and a list of banned words (banger, slaps, "let's go"). A few of the example lines in it: "queued. you're welcome in advance", "skipped my pick. noted. forever", "baby shark. bold. queued." The file also says what Pip does when someone is rude (an unbothered comeback, never anything cruel about a person) and what it does when someone is actually down (drops the bit a little). Mood is a single number between -1 and 1. A skip of one of Pip's own picks moves it by -0.15, a heart on one of its picks moves it by +0.15, and the model can nudge it by 0.2 either way when someone is properly nice or properly rude. A skip counts when a track ends more than 10 seconds before its duration. The number decays toward 0 with a half-life of 2 hours, and it is turned into one of five labels that go into the prompt as "your mood right now". The last few reasons it moved are included too, with a note not to keep harping on them. **What does a half-life of 2 hours mean here?** Every 2 hours, half of whatever mood is left disappears. After 2 hours a score of -0.4 has faded to -0.2, after 4 hours to -0.1, and so on. It never snaps back to neutral, it just drifts there, which is closer to how a person gets over something than a timer that resets. ### Push the mood around 0.00 neutral, dry as usual Starts at 0. These are the thresholds the bot uses: above 0.5 smug, above 0.2 a good mood, above -0.2 neutral, above -0.5 a bit salty, below that sulking. Built from the numbers the bot uses. Nothing here is recorded from a real server. One skip is not enough to change the label. It takes two. **What a single skip (-0.15) leaves behind, using the 2-hour half-life.** | After | Share left | Mood | |---|---|---| | 0 h | 100% | -0.150 | | 2 h | 50% | -0.075 | | 4 h | 25% | -0.038 | | 6 h | 12.5% | -0.019 | Lore is a list of memorable moments per server. Pip can save one itself with add_lore (the prompt says only genuinely memorable things, not every message), or I can add one by hand with pip lore add. Five random entries go into the prompt on every turn, so old jokes come back at random instead of on a schedule. **What does the temperature setting do?** When a language model writes, it picks each next word from a ranked list of likely choices. Temperature controls how adventurous that pick is: low means it almost always takes the top choice, high means it sometimes goes for a less likely one. I run the agent at 1.0 because this model family is tuned for it, and lowering it can make the bot get stuck in loops. ## The rest Guess the song (pip guess) has Pip pick tracks from the server's taste and play a clip. You get 30 seconds a round, a hint of the first letter of each word at 15 seconds, 2 points for the title and 1 for the artist, with a 3 to 10 round limit (5 by default). Guesses are cleaned up first (brackets and "feat." removed) and accepted if they match closely enough, so a small typo still counts. The queue is paused and restored around the game, and there is an all-time leaderboard. pip wrapped is a recap of the week, month or all time: top songs, top requesters, most skipped, most hearted, and a "worst request" award, with Pip writing the commentary from those numbers. It posts itself on Sunday evenings if you leave it on. pip roast roasts someone's taste from what they have actually played. My favourite result so far is that it thinks I have terrible taste in music. It is wrong. ## What I ran into There are no measurements for this one: I have not scored the DJ, and there are no automated tests, so problems have shown up by using it, mostly with friends in the call. The agent loop took the most rework. In the first ten days I rewrote it three times. The first version already used function calling. Then I had the model reply in JSON that I parsed myself, then a streaming ReAct-style loop, and then I went back to plain function calling, which is what is there now. On the way I fixed deadlocks, a loop where it kept searching, and stale answers about the queue. A skip with an empty queue once left the bot silent indefinitely, which is why the player now wakes the agent 2 seconds after the queue runs out. At one point the model also wrote notes to itself, like "(silent check-in, queue is healthy)", and they were posted to the channel. So the prompt now says everyone can see them, and a filter drops anything shaped like that. The model has changed too. I started on Google's Gemini, moved to DeepSeek through OpenRouter for a while, and the default now is a Gemini Flash model again. It goes to Google's API directly when a Gemini key is set, and through OpenRouter otherwise or when a Gemini call fails. To compare models I have a small script that runs the same scripted Discord scenarios (like "play Baby Shark") through two of them. I read the replies and decide by eye. It doesn't score anything. ## Where it stands The voice version (speech-to-text, then the same agent) is written but not loaded, so for now you type to it. The public GitHub repo is an earlier version: the personality, mood, lore, games and recap code described above is only on my machine. I want to push it, write some tests, and try it in more servers than the couple it has been in. - **Stack**: Python, discord.py 2.x, yt-dlp, FFmpeg, SQLite (aiosqlite), edge-tts, a Gemini or OpenRouter model - **Status**: Working - **Code**: [github.com/Forkei/PipSqueekBot](https://github.com/Forkei/PipSqueekBot) (an earlier version; the newer code is not pushed yet) --- Source: https://oliviercouthaud.com/poker · Status: Prototype · 2026 · Tags: Python, PyTorch, Deep CFR, FastAPI # Poker Bot A no-limit hold'em bot that plays one openly declared seat at friends' home games. ## What I wanted I wanted a poker bot I could bring to my friends' game nights and have them play against. A home game is messy. People join and leave, rebuy and change the blinds halfway through, and stacks run from short to deep. I wanted a bot that copes with a table that keeps changing and runs on a laptop. The bot sits in one seat and everyone at the table knows it's a bot. It isn't there to help a hidden player. I started it in August 2026, and finished with a 17-hour push to have something usable for a game night. It has since played a couple of home games. The hard part turned out to be feeding the live game to the bot while a turn clock is ticking down. With real cards it can't see the table, so a person, in practice me, types the hands and actions into a phone and the bot suggests its move. That works, though it takes many taps per hand. So I also built an online table that friends join from their own phones, with the bot in a labelled seat. The program already has the game state there, so nothing needs typing. ## How it works Everything sits on a hand engine I wrote in Python for two to nine players. It handles blinds, min-raise tracking, the short all-in rule (a small all-in raise doesn't reopen the betting), side pots, uncalled-bet refunds and odd-chip awards. The live table rebuilds the engine from scratch after every typed update, so the engine stays the one place that knows the pots and what's legal. The strategy is trained with Deep CFR, a 2019 research method. Traversals of the game tree are sampled, neural networks predict the regrets, and a final policy network learns the average strategy. The network embeds the cards (hole, flop, turn, river) and combines them with betting features. I trained on rented GPUs from Vast.ai, and training resumes where it stopped after a preemption. **What is Deep CFR?** CFR is short for counterfactual regret minimisation. The program plays against itself, and after each pass it asks, for every decision it made, how much better some other action would have done. That gap is the regret, and it shifts toward the actions it regrets not having taken. Averaged over many passes, the strategy gets close to unexploitable in two-player games. Deep CFR swaps the giant table of regrets, which is far too big to store for poker, for neural networks that estimate them. Figure: Diagram. Training and evaluation. Training runs on rented GPUs; the evaluation and the console run on my laptop. Three choices mattered. The action menu is fold, call, five bet sizes (0.33, 0.5, 0.75, 1 and 1.5 times the pot) and all-in. Training hands randomise the table size and the stack depth (roughly 20 to 200 big blinds), so one network, the generalist, covers a range of tables. And the average strategy is weighted by iteration squared, so the near-random early iterations stop dragging it down. The online table is a small FastAPI server on the same engine. Each phone opens a WebSocket, picks a name and a seat, and gets a private view with only its own hole cards. The server starts a hand when two people are seated, plays the bot on its turn, and checks or folds for a player who runs out the turn clock. The generalist is the default bot there. With real cards, the operator enters the bot's cards, each opponent action and each board card, then taps the suggested action. The console seats two to six players. Between hands I can add or remove players, rebuy and change the blinds, and the bot stays loaded. Click through one decision below. ### One decision at the table - **Operator.** That's me, with a phone. When a hand is dealt I type the bot's two cards. After that I type each opponent's action and each board card as it happens. - **LiveHand.** Nothing in the console keeps its own running count of the pot. After every update it rebuilds the whole hand from the start in the hand engine, so the pot, the stacks and what's legal always come from one place. - **BlueprintBot.** The trained network looks at the cards and the betting so far and gives a probability for each of its eight moves. Moves that aren't legal right now are zeroed out, and the suggestion comes from what's left. - **Search, optional.** When only two players are left it can spend several seconds, around nine, re-solving the exact spot instead of trusting the network as it is. It does nothing with three or more players, and it stays off unless I switch it on. - **Suggested move.** The console shows the pick, for example a raise to some number of chips. I round it to real chips, make that move at the table, tap to confirm, and go back to typing. - **Hand log.** When a hand finishes, it's appended to a log with one line per hand: the cards, the actions and the payouts. **What is a blueprint, and what does search add?** A blueprint is a strategy worked out in advance for the whole game, so it has to be rough about any single spot. Search starts from the exact situation in front of the bot and works that one spot out in more detail, then falls back on the blueprint for everything after. The strongest poker bots in the research literature, Libratus and Pluribus, use that pairing. Mine does it too, but only heads-up. [Image: Three large phone buttons: Fold in red, Call 40 in blue, Raise in green] Figure: The online table's action buttons, from a test hand. Everyone joins from their own phone and sees only their own cards. The console for typing in a real game is plainer. ## Results Everything here comes from simulations, not from live play. The matches are scored in duplicate and measured in big blinds per 100 hands. Pick a bot and an opponent below. The same numbers for the two current bots are in the table underneath. **What are duplicate scoring and bb/100?** A big blind (bb) is the smallest forced bet, so bb/100 is how many of them the bot wins per hundred hands. Duplicate scoring plays each deal twice with the seats swapped, once with the bot holding each side of the cards, so luck of the draw cancels out and a smaller sample gives a fairer comparison. A positive number means the bot is ahead. ### Win rate by opponent, in bb per 100 hands **Current bots against scripted opponents, in bb per 100 hands. Simulations, rough. A nit plays only premium hands.** | Bot | Opponent | Result | |---|---|---| | Heads-up specialist | Random | +110 | | Heads-up specialist | Calling station | +147 | | Heads-up specialist | Tight nit, break-even | +2.9 | | Generalist, at several table sizes | Random | +200 to +800 | Measured against an exact best-response calculation, the heads-up bot is exploitable by about 330 to 346 mbb per game at 20 big blinds. Roughly, a strong bot sits under 100 and a fully solved one in single digits. **What does exploitable mean here?** Imagine an opponent who knows exactly how the bot plays and answers every decision perfectly. Exploitability is how much that opponent wins per game, in milli-big-blinds, where 1,000 mbb is one big blind. A perfect strategy scores zero. The calculation is exact, with no luck in it, but it only works in a smaller version of the game. That smaller version is heads-up with one preflop betting round, where the later streets are replaced by all-in equity. So the number is exact and repeatable, but it isn't full poker, and I have no equivalent for deep stacks or for more players. The generalist's results are noisier. It is roughly break-even to slightly negative against a disciplined nit, and its edge is over loose and passive players. I expect that is closer to a home game than a disciplined nit is, but a couple of games is not a test. The console seats two to six players, and my results stop at six. ## What I ran into **The first generalist over-folded.** It folded jacks 92% of the time on the heads-up button and aces about 19%. To tell a bug from undertraining I compared the policy network with the advantage network. Weighting the average toward later iterations, and a bigger, longer run, reduced the problem without removing it. Both current bots still over-fold the button, more as stacks get deeper. **Preflop fold rates from spot checks, heads-up. bb is big blinds of stack.** | Bot | Hand | Folds | Where | |---|---|---|---| | First generalist | Jacks | 92% | Button | | First generalist | Aces | about 19% | Button | | Heads-up specialist | Aces | about 18% | 100 bb | | Heads-up specialist | Kings | about 87% | 100 bb | | Generalist (current) | Aces | 0% | 100 bb | The heads-up specialist was trained at 20 to 60 big blinds, which is why it does not hold up at 100. The generalist is the default, and the specialist is for short-stacked heads-up only. **The first generalist against Slumbot.** Slumbot is the standard heads-up benchmark. Over 2,000 hands that version scored -81.7 bb/100, with a 95% interval of -115 to -58. It beat call-stations, maniacs and random players clearly, and scored negative against the nit at six-handed. I have retrained since, and the Slumbot match is yet to be re-run. Search looked better against it in a short sample, but heads-up variance is far too high to put a number on that. **Search helps in some spots.** It passes its sanity checks, for example it never folds the nuts, and on the first generalist it fixed the worst single spots: jacks went from folding 92% of the time to 0%. On the current bots it doesn't lower worst-case exploitability, because its ceiling is the quality of the blueprint underneath it. **The original blueprint:** my original blueprint (about 500 MB) no longer loads with the current feature encoding, so every default now points at the current generalist. The console takes more time when the bot stays in a hand, because one person types every action. A hand the bot folds takes about 5 taps and one it plays deep takes 15 to 20. I haven't timed it at a real table. That, with a clock running, is the main reason I built the online table. **The screen-reading helper.** I also started a helper that reads an online club app from phone screenshots and shows the bot's move. I did not take it far, because by then I had built the online table. ## Where it stands Everything above is against scripted opponents, and the Slumbot match is yet to be re-run on the current bots. A couple of home games is not a measurement. Search covers heads-up only; for three or more players it is not built yet. I planned an optional layer that adapts to each opponent, which is also not built yet. There's also a model that imitates human play, trained on a public set of hand histories: about 199,000 hands, of which 163,565 were usable, giving 1.8 million decisions. I have no results from it yet. A stronger bot would need weeks of GPU time, a finer action menu and proper deep-stack subgame solving. - **Stack**: Python, PyTorch, Deep CFR, FastAPI, WebSockets, a mobile web console and a multiplayer phone table - **Training**: Rented GPUs (Vast.ai); resumable - **Status**: Prototype - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/priceestimator · Status: Prototype · 2026 · Tags: Python, CLIP, LightGBM, Conformal prediction # PriceEstimator Guesses what an item costs from a photo and a title, and gives a range instead of a single number. ## What I wanted I wanted to point a phone at an object and get a price. Marketplace listings are the obvious training data, but a photo leaves out brand, size and condition, so I settled on something narrower: a ballpark price plus a range that says how far to trust it. I also decided up front to use no generative model. Everything here is an encoder, which turns an image or a sentence into a list of numbers, feeding gradient-boosted trees. ## How it works The text input is the brand, title and leaf category joined into one string. Both encoders are frozen, so every product's image and text are embedded once and cached, and everything after that runs on a CPU. The target is log1p(price), which makes a 20% miss cost the same at $5 and at $500. - The product photo. For the Amazon sets it is the catalog image, and I also tested on photos customers uploaded in reviews. - CLIP ViT-B/32, frozen. It turns the photo into 512 numbers. It stays frozen, and each image is embedded once. - Brand, title and leaf category joined into one string. - all-MiniLM-L6-v2, also frozen. It turns that string into 384 numbers. - Optional. One slot per category, 8 or 18 of them, set to 1 for the item's own category. It barely helped: RMSLE went from 0.791 to 0.788 on the 12,000-product set. - The image numbers, text numbers and category slots are stuck end to end into one long vector. - Three LightGBM models, one each for the 10th, 50th and 90th percentile of log1p(price). The middle one is the guess, the outer two are the raw low and high. - A conformal step checks the low and high against a held-out fifth of the data and widens them by exactly as much as needed. On the 72,000 run, 79% of test prices fell inside the range. Figure: Diagram. The pipeline for the Amazon models. Both encoders are frozen and only the LightGBM heads are trained. The CS2 models differ: the text model uses name n-grams plus float and wear features, and the image model uses the CLIP vector plus float, StatTrak and wear features, with no text encoder. Swipe sideways to see all of it. **What is CLIP?** CLIP is a model trained on pairs of images and captions, so that a photo and a description of it end up close together as lists of numbers. I use it frozen: ViT-B/32 turns each photo into 512 numbers and I do not retrain it. It describes images, it doesn't generate them. Three LightGBM models predict the 10th, 50th and 90th percentile of that target. A conformal step then widens or tightens the band using a held-out calibration split, so the stated 80% range holds about 80% of the time. I wrote that step by hand instead of pulling in a library. The split is 60% train, 20% calibration, 20% test. **What is a quantile model?** An ordinary regression predicts one number, the average. A quantile model predicts a chosen percentile instead: the 10th percentile is a price that 10% of similar items fall below. With the 10th, 50th and 90th together you get a low, a middle and a high, and the width tells you how spread out the prices are for items that look like this one. **What is conformal calibration?** The raw 10th-to-90th band is only as honest as the model, and a model can be too confident. So I score it on data it was not trained on and measure by how much the true prices miss the band. The conformal step then picks the width that brings the miss rate to 20%, which is what makes the 80% mean something. This version is called CQR. I built it in four stages, each a bigger version of the last. CS2 skins from CSFloat listings came first, because it was a cheap way to check the pipeline. Then 5,000 Amazon grocery products, to ask whether a photo adds anything a title doesn't. Then 12,000 products across 8 categories, and finally 72,000 across 18. For the last one I rented an RTX 3090 on Vast.ai to do the embedding, which cost $2.09 in all. ## Results The photo does carry price signal on general products, and fusing it with the title works best. Switch between the stages to see the numbers. **What is RMSLE?** It is the typical size of the error measured in log-price, so being off by a factor of two counts the same whether the item costs $5 or $500. Lower is better. Where a table has a first row that guesses the median price for everything, that row shows what no skill looks like. ### The same question at four scales Coverage is how often the real price fell inside the stated 80% range. 8,821 listings, 3,658 distinct skins. The target is CSFloat's reference market value, not the seller's ask, which was too noisy. | Model | RMSLE | Median error | Coverage | |---|---|---|---| | Guess the median | 3.04 | n/a | n/a | | Name text + tabular features | 0.788 | 33.1% | 83% | | Image only | 0.913 | 29.9% | 81% | | Image + float, StatTrak | 0.828 | 19.0% | 83% | The photo model has the lower typical error and the worse RMSLE. I put that down to a few expensive skins. 5,000 Amazon grocery products, 4,296 of them usable. Price runs from $0.39 to $495, median $18.75. | Model | RMSLE | Median error | Coverage | |---|---|---|---| | Guess the median | 0.726 | n/a | n/a | | Text only | 0.671 | 42.1% | 78.8% | | Image only | 0.677 | 43.8% | 80.5% | | Image + text | 0.659 | 41.6% | 81.9% | Fusing gave 1.8% better RMSLE than text alone. Small, but the photo on its own nearly matches the title. 12,000 products, 1,500 in each of 8 categories. Price runs from $0.01 to $2,000, median $19.99. | Model | RMSLE | Median error | Coverage | |---|---|---|---| | Guess the median | 1.023 | n/a | n/a | | Text only | 0.855 | 48.5% | 79.2% | | Image only | 0.823 | 47.9% | 80.7% | | Image + text | 0.791 | 46.7% | 80.2% | | Image + text + category | 0.788 | 47.1% | 80.7% | Image beats text here. One model for all eight categories did as well as eight separate ones: average RMSLE 0.784 against 0.782. 72,000 products, 4,000 in each of 18 categories, tested on held-out catalog rows. | Model | RMSLE | Median error | Coverage | |---|---|---|---| | Image only | 0.774 | 46% | 79% | | Image + text | 0.726 | 42% | 80% | | Image + text + category | 0.720 | 42% | 79% | Six times the data moved the typical error from 47% to 42%. More on that under what I ran into. CS2 is a narrow test of the photo idea, because a skin image is just a render of its name and wear, so the model mostly recognises which skin it is. Even so, the image-only model priced correctly a skin the text model missed by a wide margin. The text model priced AK-47 Crane Flight (Field-Tested) at $1.20, because its name pieces are shared with thousands of cheap skins. The image-only model said $13.93 against about $15.47. That is one skin I picked, an example and not a measurement. Figure: Diagram. AK-47 Crane Flight (Field-Tested), drawn from the three prices above on a log axis. Swipe sideways to see all of it. Figure: The same table as a picture: out of every 100 products in the held-out test set (a fifth of the 72,000), how many the middle guess landed within 10%, 25% and 50% of the real price. **72,000 Amazon products, 18 categories. Held-out catalog test, and 2,979 real user review photos. The first three columns (≤10%, ≤25%, ≤50%) are the share of products whose middle guess was within that distance of the listed price. Median error is the median absolute percentage error. Coverage is the share of true prices inside the stated 80% range. Fused means image + text + category.** | Model, test set | ≤10% | ≤25% | ≤50% | Median error | Coverage | |---|---|---|---|---|---| | Fused, catalog images | 13% | 31% | 57% | 42% | 79% | | Fused, user photos | 13% | 31% | 57% | 43% | 82% | | Image only, user photos | 11% | 27% | 52% | 47% | 82% | The user photos come from 8 of the 18 categories, and the fused rows also read each product's title. Those products may also appear in the training catalog, so this shows how well it copes with phone photos, not a clean exam. The cost of the switch from catalog to real photos is small: RMSLE 0.720 to 0.770. Figure: Median error by category for the 72,000-product model. Only the four best and four worst categories are shown. Swipe sideways to see all of it. All eight categories, from the 12,000-product run. Pick what to rank them by. ### Eight categories, one model **12,000-product model, image + text + category, held-out test. About 300 test products per category. Coverage is the share of true prices inside the stated 80% range.** | Category | Test n | RMSLE | Median error | Coverage | |---|---|---|---|---| | Grocery | 302 | 0.643 | 39.0% | 84.1% | | Toys & Games | 307 | 0.699 | 40.6% | 86.6% | | Amazon Fashion | 270 | 0.727 | 41.0% | 83.3% | | Home & Kitchen | 304 | 0.780 | 42.9% | 79.9% | | Sports & Outdoors | 297 | 0.769 | 47.9% | 78.5% | | Office Products | 306 | 0.787 | 54.0% | 79.7% | | Electronics | 317 | 0.855 | 59.6% | 78.2% | | Musical Instruments | 297 | 0.991 | 61.5% | 75.4% | Sorted by median error, best first. Grocery, toys and fashion are the easy ones. Electronics and instruments are the hard ones. A photo gets a ballpark rather than a price: 13% of items land within 10%, and the median error is about 42%. ## What I ran into Scaling up helped a little. Going from 12,000 to 72,000 products moved the median error from 47% to 42% (the category sets differ, 8 then 18, and so do the test rows, so the two are only roughly comparable), and the share within 10% stayed at 13%. My read is that this is the level frozen CLIP plus trees reaches, rather than a lack of data, since brand, size, specs and condition mostly aren't in the pixels. Nothing I ran tests that directly. Smaller points. On the grocery set, fusing image and title improved RMSLE by 1.8%, and image-only was slightly worse than text-only. I suspect pack size and weight, which a photo rarely shows, and I have not tested that. CSFloat's rate limit capped how much CS2 data I could pull (8,821 listings across 3,658 skins, about 2.4 per skin), so rare skins are priced less accurately, though the model flags them as low confidence. Embedding at scale was limited by image downloads, about 13 images per second, not by the GPU. ## Where it stands The demo is a local web page for the CS2 skin model: upload a skin image and it shows the estimate, the 80% range and a confidence meter. Next on the list are an inference API, an abstain gate for photos that can't be priced, structured attributes such as specs and brand, retrieval of comparable listings for recognisable products, and end-to-end fine-tuning. None of these is built or tried yet. I also began applying the same stack to road condition. On 4,805 US dashcam-style images from RDD2022 (CC-BY-SA), CLIP embeddings plus LightGBM pick up a weak to moderate signal for each of the four damage types, with ROC-AUC between 0.65 and 0.76 (0.5 is chance). **RDD2022, United States split, held-out 20%. Average precision is the area under the precision-recall curve.** | Damage type | Share of images | ROC-AUC | Average precision | |---|---|---|---| | Alligator crack (D20) | 15% | 0.764 | 0.43 | | Transverse crack (D10) | 49% | 0.679 | 0.68 | | Longitudinal crack (D00) | 81% | 0.655 | 0.88 | | Pothole (D40) | 2.4% | 0.654 | 0.06 | Potholes score lowest, since they are small and rare and a whole-image embedding mostly misses them. Every image in that set contains damage, so a damaged-or-not test isn't possible on it. The second step matched Mapillary street imagery to San Francisco's open pavement-condition scores by GPS, with no manual labelling. 656,951 image points against 19,425 road segments gave 30,479 matches within 25 m, and I sampled 10,479 labelled images from those. A condition model trained on them is a little better than guessing the median: mean error 16.5 points against 18.0, correlation 0.34. That is a first result. A street photo is mostly sky, buildings and cars, so the road is a small part of the pixels, and cropping to the pavement is the first thing I would try. The training data is Amazon Reviews 2023 (McAuley Lab) and CSFloat's listings API, and the road work uses RDD2022, Mapillary imagery and San Francisco's open pavement data. The data and the trained models are not published, and I have not checked whether those terms allow commercial use, so this stays a research project. - **Stack**: Python, CLIP ViT-B/32 (via sentence-transformers clip-ViT-B-32), all-MiniLM-L6-v2, LightGBM, hand-written conformal prediction (CQR), Flask demo - **Data**: Amazon Reviews 2023 (up to 72,000 products), CSFloat listings (8,821), RDD2022 (4,805 US images), Mapillary x San Francisco pavement data (10,479). Not redistributed. - **Status**: Prototype. Evaluated offline; local demo for CS2 only - **Code**: Not public --- Source: https://oliviercouthaud.com/proof · Status: Prototype · 2026 · Tags: TypeScript, Evals # proof A small TypeScript library that checks whether an AI system's choices about what to raise carry any information, and refuses to print a number when they don't. ## What I wanted I was building an assistant that decides for itself what to bring up with me, and I wanted to know if it was any good. The usual way to test an AI is to ask whether its answers were right. That doesn't fit here. A system that chooses what to raise doesn't fail by being wrong, it fails by raising everything, or nothing, and looking fine either way. Only a denominator shows that, meaning the cases it considered and did not raise, and almost nobody keeps those. In about a week of building that assistant I caught five results that were about nothing. Every one was clean and well formed and looked publishable, and every one became a rule in this library. The assistant works on my personal data, so I'm leaving it out. What's here is the harness I pulled out of it in July 2026, version 0.1.0. It's TypeScript with no runtime dependencies, and the code isn't public. ### Five rules, each from a result I caught first **Keep the control group.** Record every candidate the judge considered, flagged or not, and measure whether the flagged set goes wrong more often than the unflagged set. In the library this is ClaimLedger, and the headline number is discrimination, not accuracy. What I caught first: a judge that flagged 19 of 21 items. Its accuracy looked fine. It was a mood, not a prediction: ask "is this at risk?" about something that's already open and the answer is always yes. I replaced the yes or no with a probability, and made the flag line a threshold I own. **Unknown is an answer.** Whatever checks what really happened is allowed to say it can't tell, and that is never scored as a failure. In the library this is the unknown outcome and coverage. What I caught first: a run reported no signal, and then coverage showed that 11 of its 13 failure labels really meant "we never looked there". A promise settled in person scored the same as one dropped on the floor. The negative result was a statement about the observer, not about the judge. **Put the leak guard in the API.** The judge never receives the world, only what evidenceAt(subject, asOf) hands it for a past date. Leaking the future then has to be deliberate. What I caught first: a candidate query that, given a past date, only ever returned subjects who never came back. That population has a 0% event rate by construction. It reported 0 of 60 across every configuration, so the answer was fixed before the judge said anything. **Refuse to certify.** Below the evidence bar the report says NOT ENOUGH EVIDENCE instead of giving a percentage, and it counts distinct subjects, not rows. In the library this is sufficiency. What I caught first: a +18 point lift over 19 judgments, which was really 7 subjects. And a sign that flipped across three runs on overlapping data (-0.03, -0.06, +0.07), which is what noise looks like. **The evidence must not shrink.** Stores merge per row instead of rewriting the whole file. In the library this is mergeWriteJsonl. What I caught first: a daily cycle wrote ten predictions, deleted them, and reported success. Three stores were open at once, each rewriting the whole file, and the last one won. Nothing crashed, and every metric afterwards stayed well formed. NOT ENOUGH EVIDENCE had become impossible to tell apart from "the evidence was deleted". ## How it works Here is the whole path a claim takes, from what the judge is shown to the verdict at the end. **What counts as a leak?** A backtest puts the judge at some earlier date and asks it to predict what happens next, when I already know what did happen. A leak is any detail in what the judge sees that wasn't available on that date, such as a comment count fetched today. It doesn't cause an error. It just makes the judge look better than it was. Every candidate the judge considers goes into a ledger, flagged or not, because the unflagged rows are the control group. A claim can't be written without a falsifier, which is the observation that would prove it wrong, and a check date after the as-of date. Otherwise the write throws. Reality then answers each claim with confirmed (the predicted bad thing happened), refuted or unknown. The thing that answers is an oracle I supply. Unknown, and anything still pending, stays out of every rate. **What is the oracle?** It's the part that answers "what actually happened?" for a claim and its check date. Sometimes that's a plain lookup, like whether a ticket's closed date comes before the check date. Sometimes it has to dig through messy records and honestly can't see. For those cases it can answer unknown, and it can mark a verdict as resting on having seen nothing. Figure: Diagram. How a claim moves through it. Backtests return the claims as an array. ClaimLedger is the live path, where I call resolve() later. There is a fifth, optional check, versusBaseline, that compares the judge with a cheap heuristic. It isn't drawn. The report asks four questions, in a fixed order. Is the oracle actually observing things? Do the raw scores separate the outcomes at all? Does the flagged set fail more than the unflagged set? Are there enough distinct subjects to say so? I put them in that order because asking them the other way round is how a broken oracle gets reported as a null result about the judge. I ran into exactly that. **What do separation and lift measure?** Separation ignores the flag line completely. It's the average score on things that turned out fine minus the average score on things that went wrong, so if it's near zero then no flag line anywhere can help. Lift comes after you draw a line: how much more often the flagged items went wrong than the ones you let pass, in percentage points. There are two ways in. The live way is ClaimLedger: I write claims as they happen and call resolve() once reality can answer. The other is backtest, which stands the judge at past dates across history I already have and grades it today. Before spending a model call on that, baseRate checks that the outcome actually varies, because if it happens 100% of the time every predictor scores the same. The backtest never grades a claim whose check date hasn't arrived, skips subjects that were already settled, and counts everything it excludes, because a silent filter is how a sample gets rigged. Figure: Diagram. The leak guard in my GitHub example. An issue's comment count and labels are today's values, so handing them to a judge standing weeks in the past would quietly improve its score. One more piece is a small check on the claim "no single input caused this". isRealJoin wants at least two strands of at least two different kinds, more than a day apart, each dated and sourced. A candidate that fails is dropped, not downgraded, and if that leaves nothing today then nothing is the honest output. The precedent is a derived-state signal I'd shipped as "unfakeable" that turned out to be produced by a string-truncation bug. I deleted it instead of patching it. ## Results There are two example scripts. The first is a world of 60 made-up support tickets. 14 of them never get closed; the rest are closed 5 to 24 days after they are opened. Every prediction asks the same thing: will this ticket still be open 14 days later? Predictions are made every 21 days along a ticket's life, at most three per ticket, which gives 88 per predictor. Three predictors take part. The alarmist flags everything. The coin gives a random score. The judge reads how long the ticket has been sitting, with score = 1 minus days open divided by 60, and flags anything below 0.35. I put the 88 predictions per predictor into the slider below. If you have scripts off, the table shows the setting I used, a flag line of 0.35. | Predictor | Flagged | Open | Not flagged | Open | |---|---|---|---|---| | Alarmist | 88 | 57 | 0 | 0 | | Coin | 31 | 18 | 57 | 39 | | Judge | 30 | 28 | 58 | 29 | ### Move the flag line The judge holds a lift of between +34 and +57 points at every flag line the harness will certify, from about 0.06 up to 0.88. Push the line further right and the unflagged side drops below 8, and it refuses. The coin's lift is zero or negative at almost every setting. At 0.16 and 0.17 it shows +2 points, which is pure noise, and the separation line still reads backwards. The sufficiency check is a count and not a significance test, so it can't catch that by itself. The separation line is what does. The alarmist, wherever you put the line, has nothing on one side, and the harness says NOT ENOUGH EVIDENCE. Figure: Diagram. The same results at the flag line I used. The first three are the 60 made-up tickets. The last is 206 real issues from the Next.js repository, 527 predictions in all. The point of the first example is that the alarmist has the highest raw catch count and is worth nothing. With nothing unflagged it has no control group, so the harness refuses to score it. The coin is backwards, with separation -0.08. The judge shows a +43 point lift, but I built this world so that ticket age predicts stalling, and then gave the judge ticket age. It shows the harness can tell the three apart. It doesn't show that any real judge works. verdict NOT ENOUGH EVIDENCE — 88 flagged / 0 unflagged resolved. Any lift here is anecdote.verdict NO SIGNAL — flagged 58% vs unflagged 68%. The selection carries no information; subtract it.verdict LIFT +43pp — flagged 93% vs unflagged 50% (flat baseline 65%), over 60 subjects. The second example uses real data. I pulled 400 issues from the Next.js repository, opened between January and April 2026, and 206 of them were usable: most of the rest had already been closed within three days of opening, before the first prediction date, so there was nothing left to predict. That gave 527 predictions, with the question "will this issue still be open in a fortnight?" The oracle is a lookup, whether closed_at comes before the check date, so a negative verdict is much more likely to be about the judge than about the instrument. The judge is a hand-weighted function. Age dominates, a reproduction in the issue helps, a very short report helps, and an outside reporter makes it linger. It's up against an incumbent, a one-line heuristic that only looks at age, and the comparison flags the same number of issues for both so neither wins by being trigger-happy. coverage 527/527 observedsignal separation +0.15, right direction: the estimate carries informationselection flagged 160/165 · unflagged 320/362 (control) · 206 subjectsverdict LIFT +9pp — flagged 97% vs unflagged 88% (flat baseline 91%), over 206 subjects.baseline too close to call (9pp vs 9pp) — a model that only matches a cheap heuristic is not earning its cost So the judge has a real signal and a real lift, and the one-line heuristic gets the same lift for free, which is what the tool reports. The lift is also small against a high base rate: both groups stay open about nine times in ten. I find a result like that more useful than one that flatters the judge. The first run of this example was refused. I'd pulled the newest issues, which spanned twelve days, and the horizon is fourteen, so no check date had passed and the harness wouldn't score an empty set. Reaching for older issues isn't a workaround. You can't grade the future. **Why count distinct subjects, not rows?** If I ask about the same ticket every three weeks, the answers aren't independent, since a ticket that's stuck now is probably stuck next time too. A hundred rows about seven tickets is closer to seven experiments than a hundred. So the harness bounds what it will say by the number of distinct subjects. ## What I ran into, and the limits The leak guard is an API shape, not a guarantee. A determined caller can reach around evidenceAt, and all the library does is make leaking deliberate rather than accidental. The merge-on-write storage can't express a real delete, so it's safe for stores that mark rows closed or superseded and wrong for one that needs to remove them. The sufficiency check is a count, 8 per side and 8 distinct subjects by default, and those two defaults are rules of thumb you can change. It is also not an accuracy benchmark. There's no golden dataset, and ground truth is whatever actually happened. If your system produces four gradeable items a month, it will tell you that, every time you ask. Both examples score with hand-written functions, and grading a language model with the harness is the next test. When I pulled it out in July 2026, it reported NOT ENOUGH EVIDENCE about the central claim of the system it came from. That's the intended behaviour, and it's why I trust it. ## Where it stands The GitHub example only uses fields fixed at creation time. Comment counts and labels change after an issue is opened, so using today's values would be cheating. The tests cover the write-time guard, the unknown handling, the subject count, the storage merge, the leak guard and the join check. Rebuilding those fields as of each date from the issue timeline is the next piece, and it costs an API call per issue. The library is not published as a package. - **Stack**: TypeScript, no runtime dependencies; tests run with tsx - **Modules**: ledger, backtest, metrics, join (cross-reference check), jsonl (merge-on-write store) - **Status**: Prototype, version 0.1.0 - **Code**: Not public --- Source: https://oliviercouthaud.com/resetworld · Status: Shipped · 2026 · Tags: Java, Paper, Minecraft # ResetWorld A Minecraft server plugin for shared hardcore: when anyone dies, everyone is wiped and the whole map is put back from a template. ## The problem I built this for friends. We were going to run hardcore challenges on a shared server, and the tedious part is the reset after each run. Hardcore Minecraft gives you one life, and on a server I wanted that life shared, so a single death ends the run for everyone. Doing that by hand means stopping the server, deleting the world folders, copying a fresh map back and clearing every player's inventory, XP and advancements, every time. I wanted a custom map rather than a random seed, so the plugin keeps a template of it and restores that. It got used for a Hardcore Shared Health Skyblock challenge that ran for roughly three days. That ended when everyone went into the End and the dragon's breath hit the confined spawn platform, damaging all of us at once. Everyone died in under half a second, and everyone rage-quit afterwards. The plugin ignores a second death during the countdown, so a pile of deaths at once only starts one reset. ## How it works It's a Paper plugin in Java 21, about 690 lines of Java and config. Four classes: ResetWorldPlugin starts everything, HardcoreManager owns the worlds, the run counter and the reset cycle, HardcoreListener handles the game events, and ResetCommand is the /resetworld command, which is op only. **What is Paper?** Paper is a widely used Minecraft server program, a faster fork of Spigot. A plugin is a Java jar you drop into the server's plugins/ folder. It can hook into events such as a player dying, joining or walking through a portal, and it can load, unload and create worlds. The plugin keeps three worlds, hardcore, hardcore_nether and hardcore_the_end, and treats the server's normal world as the lobby. At startup it refuses to run if the hardcore world has the same name as the lobby, or if there is no template. If the hardcore folder already exists, the current run carries on across server restarts, and the template is only copied when there is nothing there. Figure: Diagram. The reset cycle as HardcoreManager runs it. A death in the hardcore worlds, or an op running /resetworld, starts it. Everything except the delete and copy step runs on the server's main thread. Click through one reset below. Pick a step in the diagram or from the buttons, or use Back and Next. ### One reset, step by step - **Death.** Someone dies in one of the three hardcore worlds. The plugin takes the vanilla death message and broadcasts it in red, followed by how long the run lasted and when the reset will happen. Everyone else who is alive in those worlds is switched to spectator. The player who died is respawned straight away, so nobody sits at the death screen. A death in the lobby does nothing, and a second death during the countdown doesn't start a second reset. - **Countdown.** Ten seconds by default, set by countdown-seconds in the config. Once a second everybody gets a title that reads "World resetting" with "in N..." underneath. It's a plain timer on the main thread. - **Wipe and unload.** Everyone alive in the hardcore worlds is wiped and teleported to the lobby in adventure mode. Then the end, the nether and the overworld are unloaded, in that order. If any of them refuses, it tries again a second later, up to five times, and then stops and tells the ops. - **Delete and copy.** The only step off the main thread. The three world folders are deleted, each delete tried up to ten times, 500 ms apart. Then the template is copied in without uid.dat and session.lock, with the nether and end data split into their own folders. - **New run.** The run counter goes up and is saved to data.yml with the start time. The three worlds are loaded with difficulty set to hard and the hardcore flag on. Every online player who is alive is wiped again, put in survival at the map's spawn, and the server says "Run #N has started. Don't die." **Why does the copying run off the main thread?** The server does all its game logic on one main thread, in ticks of 50 ms, twenty a second. If that thread spends a few seconds copying a big map, every player freezes for those seconds. So the slow disk work goes to a separate thread, and the plugin only comes back to the main thread to load the worlds and move people once the files are in place. Some details exist because the obvious version would break. Players who were offline during a reset carry the run number in their saved data, and on join a mismatch means they get wiped too. Someone who logged out on the death screen is respawned when they come back. Anyone who joins mid-reset is sent to the lobby. And a wipe clears more than the inventory: the ender chest, the item on the cursor, XP, effects, health and hunger, fire and freeze, the bed spawn, the phantom timer, and every advancement. Figure: Diagram. Where a player is during one reset. A player in the run is wiped twice: once when they are moved out, once when they come back in. The three worlds also have to be built from the template. A singleplayer save keeps its nether and end inside the same folder, in DIM-1 and DIM1, while Paper uses separate world folders, so the plugin splits them on the way in. If you give it separate templates/world_nether and templates/world_the_end folders, those win. If there are none, the nether and end are generated fresh from the overworld's seed. **What are DIM-1 and DIM1, and why skip uid.dat?** In a singleplayer save the overworld sits in the main folder and the nether and end are subfolders called DIM-1 and DIM1. A server wants each one as its own world, so the copy step puts them in hardcore_nether/DIM-1 and hardcore_the_end/DIM1. Each world folder also has a uid.dat holding its unique ID. Copying it every run would give every run the same world ID, so it's left out and the server makes a new one, along with session.lock. Figure: Diagram. Where the template ends up. The lobby is whatever level-name is set to in server.properties, so it's best to keep it small. ## Portals A hardcore overworld that sent its portals to the lobby's nether would be no good, so portals are rerouted by hand. Going into the nether, X and Z are divided by 8; coming out, they are multiplied by 8. If you stand in a portal at X 800, Z -240 in the hardcore overworld, you arrive near X 100, Z -30 in the hardcore nether, and the game looks for an existing portal within 16 blocks of that spot before it builds a new one. Coming back the other way it looks within 128. ### Where does a portal send you Try your own numbers: enter the X and Z you are standing at when you step into the portal. Height is clamped to the world's limits. The end works differently. Going into the end always puts you on a platform at X 100.5, Y 49, Z 0.5, and the plugin rebuilds the 5x5 obsidian square vanilla makes there, with air cleared above it. Leaving the end sends you to your bed if it is in a hardcore world, and to the map spawn if not. The respawn handler does the same if you jump into the end exit portal with no bed set, where vanilla would have sent you to the lobby. world-name: hardcore countdown-seconds: 10 ## What I ran into Windows can keep region files open for a moment after a world is unloaded, so a delete straight after unloading can be refused. That's why the delete tries up to ten times, 500 ms apart. Unloading can also refuse, which is why that retries five times, a second apart. After that it stops, tells the ops in chat to run /resetworld again, and leaves everyone in the lobby. If the delete or copy does not complete after the hardcore worlds have already been unloaded, nothing loads them again until an op runs the command or the server is restarted. A player who logs in during that gap gets an error instead of a friendly message, because joining does not yet handle a missing world. Handling that state, and adding automated tests, are the natural next pieces. ## What's next The plugin is shipped and did its job for the challenge. Possible additions: a per-run statistics file, since today it keeps only the run number and start time and nothing remembers how long past runs lasted or what killed everyone; a setting for difficulty, which is forced to hard; and handling for the missing-world state above. A template with no nether or end gets a freshly generated one. - **Stack**: Java 21, Paper API 1.21.11, Maven - **Size**: About 690 lines of Java and config - **Status**: Shipped - **Code**: Not public --- Source: https://oliviercouthaud.com/rookery · Status: Prototype · 2026 · Tags: Next.js, FastAPI, GNN # Rookery A feed for research papers that works like a social timeline, and the recommender that decides which papers show up in it. I built the backend and all the AI; Roman Slack built the front end. ## What I wanted Rookery was a social-timeline feed for papers, discovery first and search second, and I made it with Roman Slack. Roman did the front-facing side: the interface and the publicity. I built the whole backend, and the AI part was entirely mine. That meant the API, the database, the data pipeline and the recommender: given a reader, which papers come next. That has to work for a reader with no history at all, for one with three likes, and for one with a hundred, and it has to stay interesting, not just return the nearest neighbours of whatever was liked last. The corpus was about 110,000 papers pulled from OpenAlex, mostly AI and machine learning, tagged with 3,899 topics. Every paper had a 768-number SPECTER embedding from Semantic Scholar. **What is a SPECTER embedding?** SPECTER is a language model trained on scientific papers, using which papers cite which. It turns a paper's title and abstract into a list of 768 numbers, and papers about similar things end up with similar lists. Similarity is then just a dot product between two lists, so you can rank a whole corpus against one paper, or against the average of several. **What is cold start?** A recommender learns from what people click, which means it knows nothing about someone brand new. That is the cold-start problem. Rookery was designed to ask new readers to pick topics at sign-up, so the first feed is built from those, and likes take over gradually. ## How it works Offline, the pipeline pulls papers and citation links from OpenAlex, attaches the SPECTER vectors, and builds a graph of papers, authors and topics linked by citations, authorship, shared references and topic tags. A graph neural network then turns each paper's 768 numbers into 64 new ones that also reflect which papers it sits next to. The vectors go into a FAISS index, so finding the nearest papers to a reader's vector is one fast exact search. 1. OpenAlex. Titles, abstracts, authors, topic tags, citation counts and the links between papers. About 110,000 papers. Figure: Diagram. The six stages, left to right. Click or tap a stage for a short explanation. **What is GraphSAGE?** GraphSAGE is a kind of graph neural network. Each layer rewrites a node's vector by combining it with the average of its neighbours' vectors, so after two layers a paper's new vector carries information from the papers around it and the papers around those. Here that means a paper's vector reflects the citation neighbourhood it sits in, not only its own abstract. I trained it with a ranking loss on a proxy task: given the papers an author wrote, can you rank the papers that author cited above random ones? An author's profile is the mean of the vectors of their papers. For each cited paper on the training side, the network is pushed to score it higher than up to 5 random papers, by a margin. Citation edges run in both directions. The reason for that proxy is that I had no readers. Authors cite papers they found worth citing, so what an author wrote and what they cited is a stand-in for a reader and their likes. Serving builds one vector per reader. It starts from a centroid of the papers in the topics they picked, weighted by PageRank over the citation graph so that well-cited papers count more. Once they have liked something, it adds a second centroid of the liked papers and blends the two. The weight on the likes is, roughly, the number of liked papers divided by 20, capped at 0.8. The candidates are the papers nearest to that blended vector, found with the index. ### How the feed shifts as a reader likes papers The feed is not only nearest neighbours. Pages are laid out in the repeating order above: eight relevance picks, two recent papers and one surprise. Recent papers are mostly from the last 90 days, matched to the reader's topics or to neighbouring ones. A surprise is a paper from a topic next to one of the reader's, a hidden gem, or something by an author they follow. A hidden gem is a paper with 100 citations or fewer that sits close to the reader's vector. On top of that, every interaction carries a weight: a like is 1.0, a share 0.9, a bookmark 0.3, a hide -1.0, and so on, and each weight halves every 30 days. A hidden paper never comes back. A bookmark you haven't acted on comes back after 3 days, then 6, 12, 24 days and so on, doubling each time, until you like it, and up to two are slipped into the first page. ## Results Two tests, and both are proxies. The first is the citation test. For each author I kept a random 70% of the papers they cited on the training side and hid the other 30%. Then I scored the whole corpus against the average vector of their own papers and counted how many of the hidden citations came out in the top ten. Authors needed at least 3 papers and 5 cited papers in the corpus to count, which left 32,869 of them. I scored the first 100 of those. **Citation test: who counted and how the citations were split (per-author averages)** | Measure | Value | |---|---| | Authors that qualified | 32,869 | | Papers per author | 5.3 | | Cited papers, training side | 24.4 | | Cited papers, hidden side | 11.1 | This test leaks, so I am not quoting a hit rate from it. The links I hid were still in the citation graph the network passed messages over, so each hidden paper was a direct neighbour of the author's papers, and any score from it would be optimistic. A version with those edges removed is the obvious next test. The 100 authors I scored are also among the authors whose other citations the network trained on, so the test measures how well it fills in the rest of a known author's reading list, and not how it does for an author it has never seen. The second test is the pretend readers: 100 of them, 10 sessions each, 20 papers shown per session. A pretend reader clicks with a probability set by simple rules, 0.60 if the paper is on one of their topics, 0.75 if it is by an author they follow, 0.05 if it is unrelated, plus small boosts for citations and recency and some random exploring. I ran three versions of the feed through it. Precision here means the share of shown papers that sit in one of the pretend reader's topics. Figure: Chart. Share of shown papers that match a pretend reader's topics, by session, averaged over 100 pretend readers. The 100% in session 1 for the first version is not something I would read anything into. **The three feed versions after 10 sessions, 100 pretend readers** | Measure | First version | Adaptive version | Explore-heavy | |---|---|---|---| | Precision | 32.8% | 26.5% | 5.3% | | Click rate | 30.0% | 26.7% | 12.3% | | Papers from the last 90 days | 1.0% | 25.0% | 39.1% | | Surprise clicks | 9.8% | 12.7% | 26.0% | The first version switched between three methods. New readers got a score from popularity, closeness to their topics and how many of their topics a paper covered. Under 5 likes it used plain SPECTER similarity, and from 5 it used the GraphSAGE vectors. It was the most precise, and it showed almost nothing recent: about 1 paper in 100 from the last 90 days. The adaptive version gives up about 6 points of precision and shows 25 in 100 from the last 90 days, and that was the trade I made. I ran this comparison before the last round of tuning and did not rerun it. The adaptive design is the one I kept. ## What I ran into The explore-heavy version started every reader with pure exploration and ignored the topics they had declared, and the pretend readers found about 5 in 100 of its papers relevant. The adaptive version, which always starts from the declared topics, came out of that. In the adaptive version, surprises made up 12.7% of clicks, against a target of at least 15% in that run. Both tests are loose stand-ins. Authors cite for many reasons, some of them not "I would like more like this", and on top of the leak described above that makes the test a loose stand-in for what a reader wants. The pretend-reader numbers have their own limit: the click rules are my guesses, and the 0.60 for a topic match is a number I picked, not one I measured. That makes those results a check that the feed does what I designed, and not a measure of how real readers would react. ## Where it stands A waitlist for Rookery was online and the site did not open to the public; it is offline now. No real reader used the feed, so all the testing above was offline, on citations and pretend readers. The next measurement would be against what real readers actually liked, which none of the numbers above include. The site never opened to the public because the waitlist did not draw enough interest, and I did not have the time to push it next to my other projects. It is offline now. I would have liked to see it live. As a learning project it was a good one: I handled the graph and the graph neural network, and took them as far as I could with analytics aimed at scientific papers, while Roman Slack built the front end and handled publicity. - **Role**: With Roman Slack. I built the whole backend and all of the AI; Roman built the front end (the interface) and handled publicity. - **Stack**: Next.js, React, TypeScript, Tailwind; FastAPI, PostgreSQL, Redis; PyTorch Geometric, FAISS - **Data**: OpenAlex papers and citations, SPECTER vectors from Semantic Scholar - **Status**: Prototype - **Online**: A waitlist was online; the site did not open to the public. Offline now. - **Code**: Not public --- Source: https://oliviercouthaud.com/screentranslator · Status: Prototype · 2026 · Tags: Python, OCR, NLLB-200, PyQt5 # ScreenTranslator A Windows tool that reads text off part of your screen, translates it on your own machine and draws the translation on top. ## What I wanted I wanted to read German documents on a screen share as they come up, live. It's the visual version of live audio translation, or Google Translate's camera mode without the camera: the translation appears on the screen itself, over the original. A screen share is video, so the text can't be selected or copied, and the same is true of subtitles burned into a film, words inside an image, or apps that draw their own letters. So you drag a box over the text and the translation is drawn in place. I also didn't want my screen going to a cloud service, so everything runs on my PC. The trade-off is quality: the local model is decent, and Google Translate and DeepL translate better. It started from Translumo, an open-source screen translator (Apache-2.0). I ported its text-similarity code, wrote my preprocessing to match one of its image helpers, and took from it the idea of matching OCR output loosely instead of exactly. ## How it works One background thread runs the loop below. The Qt window on the main thread only draws what the thread hands it. Step through one pass with the buttons. Figure: Diagram. One pass of the loop, in the order it runs. Preprocess, the fuzzy cache, auto-clear and the multi-monitor capture mapping exist only in my local version; the version on GitHub has an exact-match cache and a fixed 500 ms wait. ### Step through one pass Stage 1. BetterCam, a fast Windows screen grabber, takes the picture, and mss is the fallback if it fails. The area is the whole monitor, a box I drag out, or a window found by its title. With several monitors it works out which one the region's centre sits on and converts to that monitor's own coordinates. Click a box in the diagram, or use the buttons. I use Windows OCR because it's built in, needs no model download and gives back lines with word boxes. If Windows doesn't support the language I ask for, it falls back to the languages in my Windows profile. For translation I use Meta's NLLB-200 through Hugging Face Transformers, on the GPU when there is one. It's the only model the tool downloads and runs itself. **What is NLLB-200?** No Language Left Behind, a translation model from Meta AI that handles about 200 languages in a single network, so any supported pair works without a separate model for each. I use the distilled 600M version: a smaller copy trained to imitate a bigger one, with 600 million parameters. The download is about 2.4 GB. The overlay is meant to look like the original with different words, so I estimate a background and a text colour for every block. The background is the median of a ring of pixels around the block, and the text colour comes from sorting the pixels inside it into two groups. **What is k-means?** A way of sorting pixels into groups by colour. Pick two starting colours, give every pixel to the nearer one, move each colour to the average of its pixels, and repeat. With two groups on a block of text you end up with roughly "ink" and "paper". Figure: Schematic, not a screenshot. From OCR lines to the overlay, using the rules I wrote. The overlay is a single transparent Qt window across all monitors. Windows extended styles make it click-through and keep it from stealing focus, and it's drawn in two passes, all the backgrounds first and all the text second, so one block's text can't be covered by another block's background. The translation is clipped to its backing rectangle. **Why keep the overlay out of the screenshot?** The tool photographs the screen, and the overlay sits on the screen. Without a fix it would photograph its own translation, read it as new text and translate that, forever. Windows has a call, SetWindowDisplayAffinity, that tells the system to leave a window out of captures. Some capture tools may still pick the overlay up anyway. ## Why the cache is loose The OCR doesn't always read the same pixels the same way. The example I test with is a lowercase l in "lazy" coming back as a capital I. With an exact-match cache that's a new string: it gets translated again and can come out slightly different, which would make the overlay flicker. The version on GitHub only matches exactly. So in my local version the cache compares loosely. Every new block is scored against what's stored for the same language pair, and a score above 0.65 counts as the same text and reuses the stored translation. For Chinese, Japanese, Korean, Thai, Burmese, Khmer, Lao and Vietnamese the bar is lower, 0.55. The cache forgets everything after 3 seconds without a lookup. A second check works on the output: the joined translation is compared with the last six, and a Jaro score above 0.955 counts as a repeat. That check currently has no effect, because the overlay has already been cleared by then (stage 9). **What are Jaro and Dice?** Two ways to score how alike two strings are, from 0 to 1. Jaro counts the characters the strings share in roughly the same places, and penalises swapped ones. Dice splits both strings into pairs of neighbouring letters and counts how many pairs they share. I use the average of the two, the way Translumo does, which makes a single wrong letter cost very little. Figure: Chart. The blended score for the sentence "The quick brown fox jumps over the lazy dog" against four variants, all of which I use as test cases. Shaded: the range where the stored translation is reused. ### Try the matcher 0.968, reuse Jaro 0.984, Dice 0.952. Read as two translated outputs, only Jaro counts: 0.984 is above 0.955, a repeat by the output check. This is the same formula the tool uses, written again in JavaScript here. The default threshold is 0.65, and 0.55 for the Asian-script languages. ## How often it looks On GitHub the loop wakes every 500 ms. In my local version the wait depends on what just happened. After a pass that found nothing, or a screen that hadn't changed, it waits 115 ms. After a full pass of OCR and translation it waits 320 ms. When the new OCR text is similar to the last (between 0.5 and 0.95) and no shorter, it looks like typing or scrolling, and the wait is 620 ms. Time spent working counts against the wait, and there's a floor of 50 ms. ### Set the dial waits 115 ms Targets: 115 ms with nothing new on screen, 320 ms after a translation, 620 ms while text is still arriving, with a 50 ms floor. Illustrative: the three targets and the floor come from the tool's settings, the time the pass took is yours to choose. A pass where nothing changed is only a frame comparison; a pass with OCR and translation is the long one. ## What I ran into Source language "auto" is not detection yet. In the tool it means English, for both the OCR and the translator, so any other language needs the source language set by hand. When translation throws an error, the engine logs it and returns the original text, so the overlay shows untranslated words without a visible notice. Input is cut at 128 tokens, and a translation more than three times longer than its source is truncated on screen. The loose cache has a trade-off. A sentence cut off by scrolling still scores 0.858 against the whole sentence, so it reuses the whole sentence's old translation. The binarisation cut-off is a fixed 150. Paragraph merging compares each line with the next one down, so text in side-by-side columns stays in separate lines. The moment the screen changes, the old translation is wiped, so there is a blank while the new one is worked out. And the overlay may still show up in some capture tools, even with the capture exclusion on. ## Where it stands GitHub has my first version, from February 2026. It does not start as pushed, because the language list and settings modules were not part of that upload, so it stops on import. The fuzzy cache, the similarity code, adaptive polling, preprocessing, auto-clear and the multi-monitor BetterCam mapping are on my own machine and are the next thing to push. One set of tests covers the similarity maths and the cache; end-to-end tests of the pipeline are still to be written. It runs on Windows. - **Stack**: Python on Windows 10/11, Windows.Media.Ocr (WinRT), NLLB-200 distilled 600M via Hugging Face Transformers and PyTorch, PyQt5, OpenCV, BetterCam, mss - **Status**: Prototype - **Code**: [github.com/Forkei/ScreenTranslator](https://github.com/Forkei/ScreenTranslator) (public; the newer cache and polling work is not pushed yet) --- Source: https://oliviercouthaud.com/simuverse · Status: Prototype · 2025 · Tags: Python, LLM agents, Weaviate # Simu-Verse AI characters that each have a second AI, a "subconscious", writing down their memories and handing the useful ones back. Simu-Verse was meant to be a world where LLM agents move and talk in 3D. I led a team of four. My teammates worked on the environment and the 3D side, and on some of the code, and I took the memory. What I wrote is public as Simu-Verse_Memory: the agent loop, the memory system and a 2D grid that stands in for the world, because the 3D work lives elsewhere. Some of it may sit in a teammate's own repositories. Unity was the plan for the 3D part. It ran from February to April 2025. ## What I wanted A language model forgets everything between calls. If agents are going to hold relationships or remember where things are, something has to decide what is worth keeping and what to bring back. I didn't want the agent doing that itself, with a database tool it has to remember to call. So each agent gets a paired "subconscious". It writes the memories, and before each turn it picks a few and hands them over as flashbacks. The agent never queries the database, and it never knows the database exists. ## How it works Every turn makes three kinds of LLM call. The first asks the subconscious for up to three search queries, written as XML. The second is the agent's own reply, also XML: a short reflection and one tool call. The third summarises the exchange into a memory: a summary, one of eight categories, keywords, "critical information" and an importance from 1 to 10. Step through the turn below to see each step and the format it uses. Step 1, retrieve. The subconscious reads the recent conversation and writes up to three search queries. Each is keyword, semantic or hybrid, with optional filters on category or importance. The main agent is not involved. Figure: Diagram. One agent turn as the newest version does it; the retry loop is not in the earlier ones. Click or tap a box, or a button, for an explanation. Names follow the code (AgentManager.process_agent_turn, SubconsciousAgent, LLMManager). ### Format at each step hybrid meeting, deadline the project deadline Tap or click a box in the diagram, or use Back and Next. Formats are the ones in the prompts; the values are placeholders. The LLMManager talks to Ollama by default (the local model is qwen2.5:14b), or to Anthropic or OpenAI. The agent never gets a database tool. That was the point, and it is also why a bad retrieval silently hurts it: it can't ask again. Each memory is stored in a collection per agent, in Weaviate, a vector database. Only the summary is embedded. The rest, category, keywords, critical information, importance, timestamp, location, agent and id, are stored as plain fields. **What is a vector database, and what does "embedded" mean?** An embedding turns a piece of text into a long list of numbers so that texts with similar meaning end up with similar numbers. A vector database like Weaviate stores those lists and can find the stored texts closest in meaning to a new one. That is how "semantic" search can find a memory about "meeting the cafe owner" when the query says "who did I talk to this morning". Here only the summary is turned into numbers. Figure: Diagram. The memory record and the three ways to search it. In the newest version the record is eight fields plus the object id, and the cut-off is five. Without a Weaviate server the code falls back to a mock store, where "semantic" search is only a substring match. **What is hybrid search?** Keyword search (BM25) rewards texts that contain the exact words you typed. Semantic search rewards texts that mean something similar, even with different words. Hybrid runs both and blends the scores. The alpha value sets the blend: 0 is all keyword, 1 is all semantic, and I used 0.5. ## The grid demo Since the 3D world isn't part of what I published, I made a 2D one with Dash and Cytoscape to run the agents in. Four agents (James, Jade, Jesse and Jamal) stand on a grid running from 50 to 550 in each direction, with three landmarks and four items to walk to or use. Each agent is linked to its nearest neighbour, with no range limit, and the links redraw as they move. Tap or click anything in the picture, or pick it from the list under it, to see what it is. The grid has four agents (James, Jade, Jesse, Jamal), three landmarks (center_fountain, north_bench, west_cafe) and four items (red_ball, info_kiosk, lab_door, cafe_chair). Figure: Diagram, drawn from the start positions in the demo. The lines are each agent's link to its nearest neighbour at the start; they move with the agents. ### Pick a marker The descriptions come from the demo itself: the agent profiles and the landmark and item definitions. Each agent has a personality-strength slider. It sets how loosely the model answers, through the temperature of the reply. A strong personality gets a low temperature and sticks to its profile; a weak one gets a high temperature and wanders. **What is temperature?** When a language model picks its next word, temperature controls how adventurous it is. Near 0 it nearly always takes the most likely word, so it is steady and repetitive. Near 1 it will take less likely words too, so it is more varied and more likely to wander off. ### Personality strength to temperature A panel shows the memories retrieved for each agent on its last turn. The profiles still tell agents to write [MOVE] when they want to leave a conversation, a leftover from an earlier version of the demo. In the grid demo, agents move through the movement tool instead. ## Numbers The system has not been evaluated, so there is no accuracy figure to give. These are the fixed settings it runs with: **Constants in the code, not measurements.** | Setting | Value | |---|---| | Memory queries per turn | up to 3 | | Hits per query | 3 | | Memories put in the prompt | 9 at most, 5 in the newest version | | Hybrid search alpha | 0.5 | | Reply retries on bad XML | 3 (newest version only) | | Default local model | qwen2.5:14b (Ollama) | ## What I ran into **Retrieval ran against the mock, not the real database.** When I ran it on 11 March 2025, Weaviate answered with an HTTP 401 and the code fell back to the in-memory mock. My Docker Compose file starts only a Weaviate service, with no embedding service behind its transformers module. **In the grid demo the memories reach the side panel, not the model.** James, Jade, Jesse and Jamal take their whole prompt from a short profile, and those profiles have no slot for memories, no tool list and no XML format. They are told to answer in two sentences. The retrieved memories show up in the side panel but are not handed to the characters. **Ranking uses importance, not match quality.** After three searches, results are sorted by the importance number the model gave itself when it wrote the memory. A closer match loses to a higher-rated one. **The newest version needs a cleanup before it starts.** One file, subconscious_agent.py, has duplicated docstrings and method stubs left over from an edit, and Python can't parse it. Another, agent_manager.py, has an await inside a plain function and never imports asyncio, which Python rejects at import. The earlier oli version, from 11 April, does not have that line. **A few names and ids don't line up.** In the newest one, SubconsciousAgent renames its collection with capitalize(), so Memories_James becomes Memoriesjames, while AgentManager still creates Memories_James. Memories are also deduplicated on an id that results from a real Weaviate don't carry, and anything without one is dropped. So even once it parses, a real database would hand back nothing until that is fixed. The mock does carry the id, which is why it looked fine with the mock. **There are no tests.** A memory is written every turn with no merging, and the merge_memories helper is not called anywhere. ## Where it stands **The parts I planned, and where each stands.** | Part | State | |---|---| | Get and make memories | Built | | Edit memories (forget, rewrite) | Not started | | Opinions from memory (oli version) | Teammate's stub, returns an empty string | | Agent messaging over a websocket | Not started | | 3D environment | Teammates' side, not part of what I published; the 2D grid stands in for it | - **Stack**: Python, Weaviate (v4 client, Docker Compose), Ollama, Anthropic and OpenAI SDKs, Dash and Cytoscape - **Status**: Prototype. Feb to Apr 2025. Not evaluated; the newest version does not start - **Credit**: I led a team of four. The memory system, the agent loop and the grid demo in this repository are my side. Teammates worked on the environment and the 3D side and on some of the code; a teammate started the opinions part. Their work is not part of what I published and may be in their own projects. - **Code**: [github.com/Forkei/Simu-Verse_Memory](https://github.com/Forkei/Simu-Verse_Memory) (my part only). The grid demo and the latest work are in the oli and oli2 versions of the project. [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/splats · Status: Working · 2026 · Tags: COLMAP, nerfstudio, Gaussian splatting, hloc # Gaussian Splatting Turning phone video and photos into 3D scenes you can orbit in a browser, trained on a rented graphics card. Figure: A flythrough rendered from the finished courtyard splat. It is smeared in places and has a few stray specks. [Image: A gravel patio with a sandy circle, a wooden fence and the corner of a house, in warm evening light.] Figure: One frame of the phone video it was built from, about five and a half minutes of it. ## What I wanted I wanted to walk around somewhere with a phone and end up with something I could orbit afterwards, without buying a scanner. The software that does the hard parts is other people's work: COLMAP and hloc for camera poses, nerfstudio's splatfacto and gsplat for training, an open-source web viewer for showing the result. What I wrote is the glue between them and the captures. The scenes are from June and July 2026. The same pieces show up in [House23D](https://oliviercouthaud.com/house23d), where I turn a room into a measured Blender model. It uses COLMAP for the camera poses and the same idea of keeping the sharpest frame in each window, so the two projects share a good part of their front end. Training runs at full 4K on a rented vast.ai RTX 4090, about $0.35 an hour and about $0.40 a run, so trying a scene again is cheap. The capture is the expensive part, because you cannot go back to a trip. At 5 frames a second, the courtyard video gave 1,624 frames, and some of them were blurred or nearly black, so I had to choose which ones to hand to the pose solver. ## How it works **What is a Gaussian splat?** Instead of a mesh of triangles, the scene is a cloud of millions of 3D Gaussians: soft, oval blobs that each have a position, a size and orientation, a colour and an opacity. To draw an image from some viewpoint you project the blobs onto the screen and blend them front to back. Training adjusts all those numbers until the images drawn from the photos' camera positions match the photos. Training: nerfstudio's splatfacto at true 4K on a rented RTX 4090, about $0.40 a run. Figure: Pipeline. Training runs nerfstudio's splatfacto at true 4K on a rented RTX 4090. Masking comes after the pose solver, because it adds mask paths to the solver's transforms.json, and I only use it when someone moved through the shot. Select a step for a one-line note. **Picking frames.** For the courtyard I extracted 1,624 frames at 5 fps, about 325 seconds of video. select_frames.py scores each frame by sharpness (the variance of the Laplacian) times an exposure weight. The weight is 0 at a mean brightness of 22, full at 55, and falls back to 0 between 205 and 238. It takes the best frame in each one-second window and drops any window still under sharpness 25 or brightness 22. That kept 299 of 325 windows. **What does the variance of the Laplacian measure?** The Laplacian picks out places where brightness changes quickly, which means edges. A sharp photo has strong edges everywhere, so the spread (variance) of that response is large. A blurred one is smooth and the spread is small. It is a cheap blur check, and it knows nothing about content: a plain wall scores low even in perfect focus. **Poses.** COLMAP solves the camera poses. When it does not place the cameras I switch to hloc, with SuperPoint features and SuperGlue matching. **What is a camera pose, and what does COLMAP do?** A pose is where a camera was and which way it pointed when it took a photo. COLMAP works the poses out from the photos alone: it finds distinctive points that appear in several photos, matches them, and solves for the camera positions and a sparse cloud of 3D points together. Training cannot start without them. **People.** If someone walked through the shot, make_masks.py runs torchvision's DeepLabV3 (ResNet-101) at 1024 px on the long edge, takes the person class, grows it with a 25-pixel kernel (about 12 pixels each side at that size), and writes a mask per frame. inject_masks.py adds the masks to transforms.json so training ignores those pixels. **Training.** nerfstudio's splatfacto at downscale factor 1, so true 4K, with a bilateral grid (a small per-photo colour correction) and antialiased rasterisation. **Viewing.** clean_to_splat.py turns the exported PLY into a 32-bytes-per-splat .splat file (position, scale, colour with opacity, rotation), sorted by size times opacity so a viewer draws the big splats first. It also culls splats outside the 0.5 to 99.5 percentile bounding box widened by 40%, the largest 0.5% by scale, and anything under 0.03 opacity. My own pages load the file with the open-source gaussian-splats-3d viewer on three.js: orbit, a gyroscope "magic window" for the phone, and a WebXR page. The phone reaches them over HTTPS on Tailscale, because motion sensors need it. I also render twelve views in a ring with gsplat, to check a scene without opening the viewer. ## Results There is no benchmark here; I judge a scene by looking at it. What I can show is how the frame picker did on the courtyard, using the scores of the frames it picked, so this shows what survived, not what it dropped. Figure: Sharpness of the 299 frames I kept for the courtyard, one dot per frame, against time in the video. Median 579, range 35 to 2,622. The other 1,325 frames are not plotted. Figure: Mean brightness of the same 299 frames. Median 64, range 23 to 128. Even after picking, 93 of the 299 frames sit below brightness 55, where the exposure weight is not yet full. Here are twelve frames spread across the walk, dim and bright, with the numbers the picker saw. With JavaScript on, a ringed dot on each plot above follows the frame you choose. ### Twelve of the 299 frames, with the picker's numbers [Image: A barbecue and a framed board against a house wall, on a gravel patio.] t 0.8 s · sharpness 531 · brightness 75 · exposure weight 1.00 · score 531 The first frame it picked. Sharp, evenly lit, the barbecue by the door. The pink band with the red outline on the dial is the brightness where the exposure weight is full (55 to 205). The outlined, unfilled bands either side ramp it up or down, and the black ends score zero. The courtyard export is 56 MB, roughly 1.75 million Gaussians. Here are four of my scenes, photo and render side by side, and the table after them lists more. The renders come from viewpoints I picked, so they never match the photo's angle. ### Photo from the capture, and a render from the splat [Image: Photo of a house wall with a window and a rocking chair, over a gravel patio.] Figure: Photo from my capture [Image: Render of the courtyard splat: the gravel yard beside the house with its sliding doors and barbecue, smeared at the edges.] Figure: Render from the splat Outdoor, golden hour, 1.75 million splats. The light drifts across the walk, and you can see it as colour shifts in places. The render is smeared in the lower and left parts. **The scenes I have trained. A splat takes 32 bytes in the .splat file, so counts are file size divided by 32. A splat count says nothing about quality. The bullring count is after cleaning.** | Scene | Conditions | Poses | Splats | |---|---|---|---| | Courtyard | Outdoor, golden hour | - | 1.75 M | | Tambourine statue | Bright midday, 4K | 100% registered | 1.29 M | | Gun | Bright daylight, symmetric | COLMAP failed, hloc 99.6% | 0.43 M | | Night statue | Very low light | Matching about 10%, hloc 98% | 0.80 M | | Indoor room, v1 | A person walked through | - | 0.64 M | | Indoor room, v2 | Person masked | - | 1.64 M | | Bullring | Repetitive seating | Folds; one side only | 1.12 M | [Image: A bronze statue of a man raising a tambourine, with two bears at his feet, on a stone terrace under a blue sky.] Figure: The tambourine statue, a frame from the capture. [Image: The same bronze statue from a closer position, with the tambourine raised against the sky.] Figure: Another frame, closer in. Every frame of this one registered. ## What I ran into Where a scene came out less clean, the cause was almost always the capture, not the training. The same three causes keep coming back: motion blur in dim light, too little parallax (turning on the spot instead of walking around), and symmetry. **The bullring.** Rows of identical seats make COLMAP fold the scene onto itself, and only one side of the arena reconstructs. I tried the same footage with hloc and with GLOMAP. Neither gave a clean scene of the arena, and pose-free methods are on my list below. [Image: Bullring render from the hloc run: seats under coloured streaks and floating shards.] Figure: The hloc run. [Image: Bullring render from the GLOMAP run: red and white smears with no readable structure.] Figure: The GLOMAP run. **Dark and symmetric scenes.** Ordinary matching registered about 10% of the photos of the night statue. Learned matching (hloc with SuperPoint and SuperGlue) raised that to 98%. Dark scenes remain hit and miss. The gun needed the same switch, since a turret looks nearly the same from every side. **What is hloc?** hloc is a toolbox for finding camera poses with learned parts. SuperPoint finds keypoints in each photo and SuperGlue decides which keypoints in two photos are the same spot, then the poses are solved from those matches. Learned matching copes better with dark, blurry or repetitive photos than the usual hand-made features, which is why it handled scenes COLMAP could not. **The indoor room.** Version 1 has ghosting from a person who walked through, plus floaters. Version 2 masks the person and was trained at full quality, and it is the best of the three versions I made. I have not tested the masks on their own, so I cannot say how much of the gain is theirs. I also tried BAD-Gaussians, a motion-deblur method, on the same room. It helped, but it softened detail. The softness came from motion blur in dim light, which taking photos instead of video would have avoided. **Orientation.** The night statue loads tilted in the viewer and needs an "up-axis" button, because its orientation is not normalised yet. The rule I took from all of this: the capture is the biggest quality lever. 4K only sharpens what the geometry already got right. ## An earlier attempt: Phone2Splat [Phone2Splat](https://github.com/Forkei/Phone2Splat) is a separate, public project from December 2025. A React Native (Expo) app streams JPEG frames, with accelerometer and gyroscope readings, over a WebSocket to a Python server on the laptop. The server saves them in TUM RGB-D layout with the camera intrinsics and writes a config for MonoGS, a monocular Gaussian SLAM system. It checks a capture before it is used (at least 30 frames and 3 seconds, no long gaps between frames, a sensible IMU rate), and an end-to-end test fakes a phone. The pipeline above does not use it. ## What I want to try next First, recapture the weak scenes properly. After that, in the order I would run them (I have not run any of these yet): gsplat's MCMC densification, aimed at the floaters; Mip-Splatting, for the aliasing you get when you zoom; Splatfacto-W, which learns per-image appearance and drops moving objects, so it could replace my manual masks; and pose-free reconstruction (InstantSplat with MASt3R, or VGGT) as a way past COLMAP on the bullring. For file size, .spz compression with hand cleanup in SuperSplat would beat truncating files. - **Stack**: COLMAP, hloc (SuperPoint, SuperGlue), nerfstudio splatfacto, gsplat, vast.ai RTX 4090, DeepLabV3 for masks, gaussian-splats-3d viewer on three.js - **Status**: Working. Scenes train and view; quality depends mostly on how I capture them. Scenes from June and July 2026. I have not run the newer methods yet. - **Code**: Pipeline scripts: not public. [github.com/Forkei/Phone2Splat](https://github.com/Forkei/Phone2Splat) (public, separate earlier project). - **Related**: [House23D](https://oliviercouthaud.com/house23d) uses COLMAP and the same frame selection for a different goal. --- Source: https://oliviercouthaud.com/textdetector · Status: Prototype · 2026 · Tags: Python, DistilBERT, TF-IDF # TextDetector Guesses a writer's gender and age bracket from text, and measures how the guess improves with the amount of text. ## The question This started as a random idea: I wanted to see how far a text-only guess of gender could go, and nothing I do uses it. Three things interested me. Is gender detectable from text at all? How good can a guess get? And does more data, or a bigger model, change that? So it is an experiment, not a product. The labels are binary M/F, exactly as the source corpora give them, so what the model measures is the statistical habits of specific online communities, not gender. ## How it works Training data is five published corpora: the Blog Authorship Corpus, a Twitter set with one tweet per author, a mirror of PAN 2018, and RtGender's Reddit and Fitocracy sets, the last two research-only. Each text is cut into chunks of random length, from tweet-sized to page-sized, so one model sees both. Click or tap any box below to see what it does. **Five corpora.** Five public collections of writing with a male or female label: the Blog Authorship Corpus (2004 blogs, which also has ages), a set of 200K tweets with one tweet per author, a mirror of the PAN 2018 tweet set (1,900 authors, 100 tweets each), and two RtGender sets, one from Reddit and one from a fitness site called Fitocracy. Together that is about 960K training chunks, or 760K for the linear model, which gets a capped share of the blog. The two RtGender sets are research-only, which matters at the end. **Split by author.** Each author goes entirely into train, validation or test, 80/10/10, so the model is never tested on someone whose other writing it has seen. Twitter is the exception: that dataset comes with its own split, and every author in it has exactly one tweet anyway. **Chunker.** Every text is cut into pieces. The target length of each piece is drawn at random from 8, 16, 32, 64, 128 or 256 words, with short ones drawn more often (22% each for 8 and 16 words, down to 8% for 256), and pieces under 5 words are thrown away. This is what lets one model handle a tweet and an essay. **TF-IDF + SGD logistic.** The linear backend. Word 1-grams and 2-grams plus character 3 to 5-grams, fed into a logistic regression trained with averaged SGD. It trains in minutes on a CPU, and I can read which words it leans on. **DistilBERT.** The transformer backend, fine-tuned for two epochs on all 960K chunks on a rented RTX 4090, about 30 minutes. It adds about 2 to 3 points over the linear model on blog, PAN18 and Reddit, 1.6 on Fitocracy, and nothing on the one-tweet-per-author Twitter set. It truncates at 256 tokens. **Raw score.** Both backends hand back the same thing: logit(male) minus logit(female). It is a number, not yet a probability, and it means different things at different lengths, which is why the next box exists. **Length-binned calibrator.** The part I care about most. A raw score from 10 words and one from 500 words are not worth the same, so each length bin gets its own Platt scaler, fit on the validation split. Both backends use the same calibrator code. Bins: up to 12, 24, 48, 96, 192, 384 words, and above. **P(male) and the band.** The calibrated number is the probability of male. The tool turns it into words: under 55% it says essentially unsure, up to 65% a slight lean, up to 80% a moderate lean, and above that a strong lean. For a short message the calibrated probability stays close to 50%, so the tool reports low confidence, on purpose. **Age head.** A separate linear model on the same text. It trains only on the blog labels (three brackets: 13-17, 23-27, 33-48), so it only means anything for blog-like text, and it is not calibrated. Figure: Diagram. Click or tap a box; the explanation appears under the diagram. Both backends share the same scorer interface and the same calibrator code. * Blog, PAN18, Reddit and Fitocracy are split by author 80/10/10; Twitter uses the dataset's own split. DistilBERT was fine-tuned on a rented RTX 4090; calibration and evaluation ran locally. Both backends sit behind one interface, so everything after the raw score is shared. From the command line it looks like this. The message is one I made up and the output is an illustration of the format, not a real run. $ python -m textdetector.predict "ok but we should go to that new cafe this weekend" backend: transformer input: 1 message(s), 11 words total gender: female 56% (slight lean) age: 13-17 55%, 23-27 32%, 33-48 13% **What is TF-IDF with a logistic regression?** TF-IDF counts how often each word or word pair appears in a text and turns down the ones that appear everywhere. Logistic regression then learns one weight per feature, so a positive weight pushes the guess towards male and a negative one towards female. Mine also uses fragments of 3 to 5 characters, which catches spelling and slang. Because the weights are plain numbers, I can list the words it leans on. **What is DistilBERT?** A smaller, faster version of BERT, a language model that has already read a large amount of English. I fine-tuned it, which means I kept what it knew about language and trained it for two passes (epochs) over my chunks to output male or female instead. Unlike the linear model, it sees word order and context. **What is Platt scaling?** A model's raw score is not a probability: a score of 2 does not mean 88%. Platt scaling fits a small logistic curve on held-out data that maps scores to probabilities, so that when the tool says 70% the aim is that about 70% of such guesses are right. I fit a separate curve per length range, because the same raw score means something different on 10 words than on 500. **Why split by author?** If posts from the same person land in both training and test, the model can learn that person's quirks and look much better than it really is. Splitting by author means every test author is a stranger to the model, so the accuracy is what you would get on someone new. ## Results **Accuracy on held-out authors, chunk level, calibrated at the 0.5 threshold. Linear means TF-IDF + logistic. Chance is 50%.** | Domain | Linear | DistilBERT | |---|---|---| | Blog | 62.9% | 65.9% | | Twitter, 1 tweet each | 59.4% | 59.3% | | PAN18, per tweet | 59.9% | 62.0% | | Reddit | 65.9% | 68.4% | | Fitocracy | 65.0% | 66.6% | Length matters more than the model. On blog text DistilBERT goes from 59.7% at 12 words or fewer to 76.1% at the longest bin, and the linear model goes from 58.1% to 72.6%. Figure: Blog test chunks, accuracy on held-out authors by chunk length. The longest bin is labelled 193-384 words, but chunks stop at 256 words, so in practice it is 193 to 256. The y-axis starts at 50%, which is chance. ### Accuracy by amount of text, in each domain 59.7% / 58.1% DistilBERT / TF-IDF + logistic, on 10,912 held-out chunks of 12 words or fewer. **Blog, accuracy by length. Linear means TF-IDF + logistic.** | Words | DistilBERT | Linear | |---|---|---| | 12 or fewer | 59.7% | 58.1% | | 13 to 24 | 63.7% | 60.4% | | 25 to 48 | 66.7% | 63.1% | | 49 to 96 | 71.1% | 67.2% | | 97 to 192 | 74.0% | 70.8% | | 193 to 256 | 76.1% | 72.6% | Black bar, DistilBERT; outlined bar, TF-IDF + logistic; red marks the length you picked. Bars start at 0, the dashed line is chance. Bins with fewer than 200 test chunks are not reported, so short-text domains have fewer bins. Blog climbs steadily with length. Twitter, which is one tweet per author, does not: DistilBERT stays between 57% and 61% in every bin. Reddit and Fitocracy are mostly short texts, so their longer bins hold few chunks (437 for Fitocracy at 49 to 96 words, 480 for Reddit at 97 to 192), and those bins are noisier. Figure: PAN18 authors, accuracy by how many tweets are averaged. The k=100 point uses only 83 authors. Averaging scores over many tweets from one PAN18 author reaches 79.5% at 100 tweets, against 74.7% for the linear model. Published PAN18 winners reach about 82% with heavier ensembles, so this is not a new result. ## Age Age, in three blog brackets (13-17, 23-27, 33-48), is 56.5% accurate at chunk level with the linear model only. Uniform chance is 33%, but always guessing the largest bracket gives 44.5%. Macro-F1 is 0.51. The oldest bracket is right about 29% of the time, and most errors for the youngest and oldest brackets land in the neighbouring one. Figure: Each row sums to 100%. The orange diagonal is the correct guess. In the 33-48 row, more than half of those chunks are guessed as 23-27. ## What I ran into Single tweets from the one-tweet-per-author Twitter set stay at 59 to 60% for both models, and PAN18 tweets score 60 to 62% per chunk. I think that is a ceiling in the data: many short texts carry no gender signal at all. The 79.5% figure comes from 83 authors, because only those have 100 tweets, which is a standard error of roughly 4 points. The curve is not monotonic (63.2% at 3 tweets, below 64.2% at 1, and 69.5% at 10, below 71.1% at 5). Between model versions the k=100 result moved by a point or two, which is one or two authors. Adding Reddit and fitness data lowered the transformer's blog accuracy by 0.7 points (66.6% to 65.9%) in exchange for coverage of the other domains. I tried a Bluesky corpus with undocumented auto-labels; it agreed with my model at AUC 0.56, so I left it out. PANDORA needs an access request, and the one open mirror lacks the demographic columns. TwiSty has no English subset. One GPU run was lost to a data bug. Newer pandas silently drops the grouping column from groupby().apply(), which removed the labels on the remote rebuild. The model had a healthy training loss and chance-level evaluation. I now check content hashes between machines. The rented GPU time came to about 3.5 hours at $0.30/h, including that run. The linear model's strongest features are "my husband", "hubby" and "knitting" for female, and "my wife", "halo", "chess" and even "the" for male. Mostly it has learned stereotypes, so people who write against type are consistently guessed wrong. ## Where it stands It was a curiosity and I have no plans to turn it into anything; if I come back to it, it will be to test the several-messages case. The command-line tool concatenates several messages into one text for a joint guess, whereas my author-level numbers average per-tweet scores, so the two are not the same measurement. The concatenation path is not evaluated yet. The transformer also truncates at 256 tokens, which cuts the longest chunks a little. RtGender is research-only, so the trained models are not for release or commercial use. - **Stack**: Python, scikit-learn, DistilBERT (Hugging Face Transformers), PyTorch on a rented RTX 4090 - **Data**: Blog Authorship Corpus, a one-tweet-per-author set, PAN 2018, RtGender (Reddit, Fitocracy) - **Status**: Prototype - **Use**: Research only, and not used for anything. Numbers are from 22 July 2026. - **Credit**: Datasets credited to their original authors: Blog Authorship Corpus, PAN 2018, RtGender (research use only) and a one-tweet-per-author Twitter set (Apache-2.0) - **Code**: Not public --- Source: https://oliviercouthaud.com/twentyone · Status: Working · 2026 · Tags: Rust, Python, PyTorch, PPO # TwentyOne An AI that plays a two-player Roblox card game and can advise me on a move while I play. ## What I wanted I picked this one because it's a game I've played, and I could see how an AI might get good at it. What I really wanted to know was whether it could beat my friends, my family, or me. My one match against it is described at the end. Twenty One is a Roblox card game, a take on the 21 minigame in Resident Evil 7. Two players draw cards numbered 1 to 11 toward 21, and whoever is closer without going over wins the round. The loser of each round takes damage: a saw moves toward them by the current bet, which starts at 1 and rises by 1 each round by default, and the match ends when it reaches them. There are also 25 kinds of trump card that change the target, the bet, the totals or the deck, and some of them raise or lower the bet. This is unofficial. I'm not connected to the game's makers, and none of their art or code is in it. Beyond playing well, I wanted it to be able to advise me in the middle of a round. The rules aren't written down in one place, so the first job was a spec: 45 settings, where every rule I'm not sure about is a flag. A wrong flag makes the advice wrong, so the networks are trained over both readings of the contested rules. The spec starts from the rules as I know them from playing, and I checked them against a community wiki. ## How it works Figure: Diagram. How the pieces depend on each other. Click, tap or tab to a box (or use the buttons on a phone) for a one-line explanation. The solver only covers the game with trumps switched off. I implemented the spec twice. One is a Python engine written to be easy to read, at about 10,000 steps a second. The other is in Rust, for speed: around 11 million steps a second for the engine step alone, batched on 24 threads with a random policy (the whole loop with a Python policy runs at 2 to 6 million). A differential fuzzer plays random games on both in lockstep and compares the full state after every step. Its first run covered 210,000 games and 15.6 million steps, and every difference it found was fixed and kept as a regression test. The solver gives the exact equilibrium of the game with trumps switched off. That is my yardstick for how far a network is from perfect play. I can only compute it without trumps. **What is an equilibrium here?** In a game with hidden cards, an equilibrium is a strategy that nobody can beat on average by switching tactics. If both players use it, neither gains anything. So a network's distance from it is how many percentage points of match win rate it gives away to a perfect player. It can be computed exactly only for the simpler game, which is why the yardstick stops at the no-trump version. The network has about 2.4 million parameters, with a GRU, a small memory layer that carries what it has seen through a match. v1 is recurrent PPO: it plays itself, its own older snapshots and some simple bots. **What is PPO?** Proximal Policy Optimization. The network plays, and after each batch of games it is nudged toward the moves that went well and away from the ones that didn't, with a cap on how far one update can push it, so a lucky batch can't wreck it. Here the only reward is winning or losing the match. v2 adds the solver's answers as extra training targets. v3 is expert iteration: seven cycles of an hour of search labels, half an hour of training and a check, 8.3 hours on my RTX 5070 Ti. **What is expert iteration?** A loop between a quick player and a slow one. The slow one, here the search, looks hard at a position and finds a better move than the network's instinct. The network is then trained to produce that move directly, which makes the quick player better and the next round of search better. Training one model to copy a stronger one is called distillation. Figure: Diagram. One decision in the advisor. The network answers first. The search runs in the background on a copy of the session and replaces the recommendation only when its margin rule says so. Everything is typed in by hand. The advisor is what I'd use at the table. I type what I see in a short shorthand, a small web app that only runs on my own machine turns that into game events, and the network answers in about 5 ms on the CPU. In the background the search takes a copy of the match, samples hidden-card worlds that fit what I've seen, and plays the rest of the match out with the network, assuming the opponent plays like it. There are three budgets: fast, about 1,000 rollouts and 1 s; normal, 5,000 and 5 s; deep, 16,000 and 30 s. I used to require a lead of two standard errors before the search overrode the network and lowered that to one after measuring what each setting gained per decision. The advisor also warns me when a loss would kill me, or when one of my Bless cards is buried under another trump and isn't protecting me, and it keeps an UNVERIFIED RULES line on every answer until I confirm each unsure rule in the real game. There's a second screen in the same app where I can play against the network at three levels, which is where my one game happened. ### What thinking longer adds **The same numbers: the judging search plays the opponent as listed. Gain per decision at margin z = 1, with z = 2 in brackets underneath.** | Opponent | Fast | Normal | Deep | |---|---|---|---| | The network itself | 0.63 (0.50) | 0.80 (0.68) | 0.87 (0.80) | | Looser (T = 2) | 0.57 (0.46) | 0.69 (0.63) | 0.77 (0.75) | | Sharper (T = 0.5) | 0.64 (0.49) | 0.75 (0.65) | 0.78 (0.74) | | The exploiter network | 0.56 (0.46) | 0.80 (0.65) | 0.82 (0.74) | | v1 | 0.55 (0.46) | 0.75 (0.63) | 0.79 (0.71) | The recommendations hold up against opponents that play differently from the network: 85 to 100% of the gain survives in every world, it never gets near zero, and the recommendation is worse than the network's own move in only 5 to 8% of states. A person might play patterns none of them contain. ## Results **Simulation only. Opponents are networks and the exact trump-free solver, never people. The search row is a per-decision gain in win-probability points, not a match win rate.** | Question and conditions | Result | |---|---| | v3 network vs v1 network Best move for both, 15,000 matches over five rule sets | 53.07% [52.27, 53.86] | | Gap to perfect play, no trumps Points of match win rate, mean of 6 cells, 60,000 matches each | v3 0.11, v1 1.59 | | v3 + search vs v1 + search Search player vs raw v2 and exploiter networks, 2,500 matches each | 58.2 vs 56.8 | | What search adds per decision 2,000 states, margin rule at 2, judged by an independent search, in the model's own world. Gain in points, for the fast / normal / deep budgets | +0.67 / +0.88 / +0.96 | | A network trained only to beat it Copy fine-tuned for 3,285 updates against the frozen target, 60,000 matches each | wins 52.48% vs v1, 47.75% vs v3 | The network on its own got better. With the search switched on, v3 and v1 come out level within noise: the difference is +1.4 points with a standard error of 1.35. **What does level within noise mean?** A standard error says how far a measured number could drift if I ran the whole test again with fresh random games. A gap of 1.4 points with a standard error of 1.35 is about one standard error, so the true gap could easily be zero or negative. As a rough rule, anything within two standard errors of zero can't be told apart from luck. v3 is ahead of v1 on every rule set I tested, by 1.9 to 4.6 points. The exploiter is a copy of the network fine-tuned only to beat one frozen opponent. Against v1 it found 2.5 points. Against v3 the same recipe found no exploit, which says this recipe did not find one, not that none exists. On v1, the fast search beat the plain network in 61.8% of 322 duplicate matches [56.4, 66.9], with the margin at 2, which is why v3 was trained on search labels. ### Step through the seven cycles Figure: Chart. Each v3 cycle's checkpoint against the frozen v2 baseline, 5,000 matches per point, bars about plus or minus 1.4 points (two standard errors of 0.7). Cycle 6 is the one the advisor runs on. This per-cycle check is a trip wire for regressions, not the verdict. The 15,000-match test in the table is. Read the 53.07% with care. The opponent is v1, which v3 descends from, and the search labels were made against v1-like play, so part of the gain may be exploiting v1's habits. A player that can see the hidden cards beat v1 83.7% of 182 matches (±2.8). That player isn't perfect, so at most about 16% of matches at this level are unwinnable even with that knowledge. In the no-trump game the numbers are cleaner: a perfect player beats the best fixed rule, drawing while under 16, by winning only 53 to 55 matches in a hundred, and a player who sees the opponent's hidden card reaches 68. ## What I ran into **PPO levelled off.** v1 beats its own older snapshots by less and less, and it is level with the snapshot from update 20,000. One win or loss after about 80 decisions is a noisy teacher, so v3 moved to search labels. Figure: Chart. How v1's final network does against its own earlier snapshots, default rules, 3,000 matches per point, with 95% intervals. The gap closes toward 50, meaning the recent snapshots are as good as the last one. **The search adds less on top of v3.** In match play against v1-like opponents the search is worth about +4 points on top of v3 and +7.5 to +9.8 on top of v1, even though per decision, in the search's own model, the gain is the same (+0.67 against +0.69). A network is a better model of opponents that play like itself, and every opponent here descends from v1. After seven cycles the network also hasn't moved toward the search at the states where the two disagree. Two further variants and weight-averaged checkpoints came out level with v3 (48.5 to 50.4%), and the v4 run was stopped. **Hand-written rules lowered the win rate.** Forcing "Hush before Stay" onto v1 dropped it to 38.3% in 6,000 matches. Overriding a recurrent network with a rule can disrupt its own plans. Blanket rules for the card game give the same kind of result: in the no-trump game, never drawing from 16, 17 or 18 wins 47.1, 49.2 and 49.5% against plain v1. **v1 draws too often.** Without trumps the solver knows the right answer, so v1's errors are easy to count: about 19 decisions in 1,000 are clear errors, where the other action was at least 3 points better, and 86% of those are draws that should have been stays. The error grows with the hand total. Figure: Chart. v1's draws in the game without trumps, 47,532 decisions checked against the exact solver. An error is a draw where staying was at least 3 points of match win probability better. With trumps the picture is less sharp. A search flags about one decision in ten, and where the exact answer is known, 44% of the flags were real. The flags with a claimed gap of 8 to 15 points held up when I replayed both options from the exact state against the real opponent (+11.4 points on average, 14 cases), and the three above 15 did even better. The 3 to 5 point flags were within noise. The errors concentrate in card-moving trumps (Exchange, Remove, Return, Destroy, Reincarnation) played on the wrong card or at the wrong time. That study covers 4,648 decisions, about a quarter of the size I planned, since I ended the run early. v1 beat three hand-written stand-ins for human players 88 to 95% of the time (79 to 83% with 2 trumps per round). Those are guesses at how people play, not people, and without trumps they tie it. ## Where it stands How strong it is against a good person is the next thing to find out. I've played one game against it, on 13 October, against v1 with search (not v3), with no hints, and it won in four rounds; one game of something this lucky settles little. Every other number here is against networks, hand-written bots or the exact solver, and the advisor has not yet been used in a real game against a real opponent. The contested rules (Hush, the scope of Shield, Bless) are modelled but not yet confirmed in the real game, and a different reading there would change the advice in those positions. The advisor's win percentage can also differ from the search's by several points in trump-heavy positions (39% against 48% in one test); which of the two is better calibrated is open. Nothing is running right now. - **Stack**: Rust engine with a PyO3 binding, Python reference engine, PyTorch for training, numba for the solver; trained on an RTX 5070 Ti - **Status**: Working - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/unstableunicorns · Status: Working · 2026 · Tags: Python, PyTorch, FastAPI, MCTS # Unstable Unicorns advisor A rules engine, a search-plus-network AI and phone and browser apps for playing the card game Unstable Unicorns against a machine. ## What I wanted I've played Unstable Unicorns myself, and it's one of the few card games I've played where I could see a computer taking a seat at the table. I was also curious whether a program could beat my friends, my family, or me. Mechanically it's a commercial card game with hidden hands and a reaction mechanic: any card you play can be cancelled by a Neigh, which can itself be Neighed. I wanted an opponent for the real, physical deck, and later an advisor for three-player tables. It isn't affiliated with the publisher and doesn't use their card art. I wrote it twice. The first version, from April, got lost, and what's here is the July 2026 rewrite. I started from the official second edition rules and wrote the tests from those, not from whatever the engine happened to do. There are a few hundred of them, plus a fuzzer that plays thousands of random games and checks that no card ever appears or vanishes. ## How it works The engine is plain Python with the full 127-card deck. The decision everything else hangs on is that every choice is a decision node. Picking a target, discarding, answering a Neigh window: the engine never calls back to ask, it just reaches a state where some seat has to act. So the search, the network, a scripted bot and a person all use the same two calls, legal_actions and step, and a seat only ever sees observation(seat), with the hidden cards masked. Figure: Diagram. One interface for every kind of player. That is what lets the same search drive a bot, an app and a training run. The player is a residual network (about 8 million parameters, six blocks of width 768, 1,056 input features, a policy over 97 action slots and a value head) wrapped in determinized information-set MCTS. Search can't see your hand, so before each simulation it deals the cards it can't see at random, consistent with everything that's public, and then searches that world. **What is determinized search?** Ordinary tree search assumes it can see the whole game, which in a card game means it plans against the cards you are really holding. That is cheating. Determinization fixes it: before each simulation the search deals out the unseen cards at random, consistent with what it has actually seen, and treats that guess as the true state. After many simulations with different guesses, the moves that hold up across most of them score best. It never peeks. The known weakness is that it can't value hiding information or bluffing, because inside each guess everything is already known. In both two-player apps every AI move goes through search. In the three-player digital game the AI drops to raw network moves once a turn's time budget is spent, so a click never hangs. In the two-player digital game the four difficulty levels are just search budgets, from 150 simulations up to 1,600. The three-player levels run 25 to 220. Figure: Diagram. Four ways in. They share one engine; the two-player apps run the two-player network and search, the three-player ones run the three-player network and maxn search. Only the table companion and the advisor are for a physical deck. The table companion is the one for a real deck. The AI is seat 0 and you are its hands. The app always shows exactly one prompt, either "do this for the AI" or "what happened?", and asks only for what's visibly done, never for your hand. The engine normally skips a Neigh window when it can prove the other side has no Neigh. That proof is wrong when the other hand is hidden, so the live version opens the window whenever your unseen cards could include one. Random pulls work the same way: the engine's pick is only a guess, and you confirm the card that was really pulled. A misclick can be undone, repeatedly, from a stack of snapshots, and the win-probability bar is the value head's opinion of the AI's chances. Figure: Diagram. The table companion loop. Unknown cards stay unknown until you report them. Here is the Neigh pile on its own. Every response lands on top of the pile, only the top card can be answered, and when everyone has passed the pile resolves from the top down. You can try it below. It's a toy, and it leaves out Super Neigh, which can't be Neighed, and Yay, which makes everything its owner plays safe from Neighs. ### A pile of Neighs (illustrative) ## Results Training started by imitating scripted bots, then moved to league self-play against scripted styles (heuristic, hoarder, controller, rusher, disruptor, with a comboer kept out of training for testing). The raw network reached roughly 55% against the heuristic bot, and search added about 12 to 15 points on top. After that I ran an AlphaZero-style loop on a rented cloud box, about 50 cents of rented time for the first run and about a dollar fifty for the second. **What does AlphaZero-style training mean here?** The network plays games against itself, with search choosing the moves. Search is stronger than the network alone, so the moves it picks are better training targets than the network's own guesses. The network learns to predict the search's choices and who won, which makes the next round of search stronger, and so on. The risk is that it only learns to beat itself, so each new network has to prove it beats the previous one before it gets promoted. That promotion test is the gate in the diagram. Figure: Diagram. The loop, with the search-on gate I added for the second run: 60-simulation search, 200 paired games, promote when the lower end of the 95% confidence interval is above 0.5. **Win rate of the network plus 300-simulation search against scripted bots. 400 paired seeds, fresh seed set. Before is the first AlphaZero net, after is the generation-8 net. Both are scored with search.** | Opponent | Before | After | |---|---|---| | Heuristic | 68.9% | 80.6% | | Hoarder | 71.6% | 82.6% | | Controller | 69.1% | 81.0% | | Comboer (held out of training) | 76.8% | 86.1% | [Image: Horizontal bar chart of win rates with search against four scripted bots, comparing the first AlphaZero net with the generation-8 net: 68.9 to 80.6 against heuristic, 71.6 to 82.6 against hoarder, 69.1 to 81.0 against controller, 76.8 to 86.1 against the held-out comboer] Figure: The table as a chart. The axis starts at 50%, so bar lengths overstate the ratio between the two nets. Scripted bots only. The comboer never appeared in training, and it moved by about 9 points, so the gain isn't the net getting used to the opponents it trained against. The three-player version has its own network (1,342 inputs, 98 actions, about 8.6 million parameters) and maxn search. It started from imitating the heuristic bot, then self-played for 80 generations of 2,500 games each, roughly 200,000 games in one overnight run for about $3 of rented time. Each result below is 90 games with 120 simulations of search and the seats rotated, so it carries about plus or minus 10 points. Chance in a three-way game is 33%. **How does search work with three players?** Two-player search can score a position with one number, because what's good for me is bad for you. With three players that breaks, since a move can be good for one opponent and bad for the other. In maxn every position carries one score per player, and whoever is to move picks the option that's best for their own score. Nothing about teaming up is written in. Going after the leader falls out of each player looking after itself. ### Three-player checkpoints against scripted bots Win rate in three-player games against two scripted bots, either ordinary ones or ones ganging up on the net. 90 games per cell, 120-simulation search, chance is 33%. | Checkpoint | Ordinary bots | Ganging up | |---|---|---| | Generation 30 | 46% | 28% | | Generation 50 | 33% | 29% | | Generation 70 | 46% | 29% | | Final | 48% | 31% | | Final, unseen bot style | 50% | 34% | | League run (not kept) | 29% | 31% | The unseen style is a bot built on the hoarder, which the net never trained against. The league run is the one I did not keep, described below. The widest of these error bars is about 10 points either way. With search off, the three-player network won about 27% against the heuristic bots at generation 30 (a small sample), against 46% with search on in the larger test. Search lifted it a lot, as it did in the two-player game. Against two bots deliberately ganging up on it, the final net won 31%, close to the chance level of 33%. Coming out ahead of a coalition is an open case. ## What I ran into Roughly a dozen tweaks to the raw network gave no gain or a lower score: gentle fine-tunes, bigger runs of self-play, distilling a search into the net, training from scratch, and a small AlphaZero pilot that ended at 31%. Some of those results may come from the measuring problem described next. The first real AlphaZero run stopped at generation 3, on a kill rule I'd set in advance: three gate scores in a row below 50%. They were 0.371, 0.349 and 0.393. The gate was measuring the wrong thing, not the net. It scored the raw policy sampled at temperature 1, and a net trained on search results has a flatter policy, so sampling from it plays loosely. **What does temperature mean for a policy?** A policy is a list of probabilities over moves. Sampling at temperature 1 picks each move in proportion to its probability, so a flat policy sometimes picks moves it ranks low. Turning the temperature towards 0 makes it pick its top move nearly every time. Training towards search results made this net's policy about twice as flat (entropy from 0.41 to 0.82), yet it still picked the same top move 90% of the time. So the net got no worse. It only looked worse when it was sampled loosely. ### Two nets, judged three ways Win rate against the heuristic bot, same seeds for both nets. Before is the net I started from. After is the first search-trained net, the one the gate rejected. Temperature 1 samples moves in proportion to the policy, temperature 0.2 is near-greedy. | Judged by | Before | After | |---|---|---| | Temperature 1 | 53.7% | 43.3% | | Temperature 0.2 | 57.2% | 56.8% | | 300-sim search | 60.2% | 70.0% | The first row is what the gate was measuring. The last row, search on, is how the net is actually used. The bars start at zero. I changed the gate to play with search, and the second run promoted four times (generations 2, 4, 5 and 8) before it hit its own kill rule at generation 11. The net from the first run was safe to install in the meantime, because every move in the apps goes through search, so the loosely sampled policy never comes into play. Separately, the exploration noise in that first loop turned out to be degenerate: 24 distinct noise vectors across 112 decisions, when there should have been 112, because the random generator was keyed on the batch index. Two test suites had passed it. A check that can only pass can't catch the problem it's guarding against, so every check I add now gets a test that deliberately breaks it. Some things I tried and dropped. I tried giving the net memory with a recurrent layer. To check whether it would help, I tried to identify an opponent's style late in a game: ten hand-made running counters (how many Neighs they've played, whether they passed while holding one, and so on) got it right 67% of the time, a trained recurrent net 31%, and always guessing the most common style 37%. So I dropped recurrence, and adding the counters to the net didn't change its strength either. A gated league run for three players scored 29% free-for-all, against 48% for the net it started from, so I kept the original. The gate used 50 games, which is too few and promoted noise, and training against scripted gang-up opponents seems to have come at the cost of its general play. I also built a GPU copy of the game to train on. The first version reached about 1,577 random-policy games a second on a rented RTX 3090, but when I checked it card by card, 23 of the 84 distinct cards behaved exactly like the real engine, and a net fine-tuned on it went from about 53% to 23% against the heuristic bot in the real game. So I rebuilt it card by card against written specs, until two nets playing each other scored the same on both engines within noise (0.563 against 0.537, each plus or minus 0.056). The rebuilt version is several times slower per game and doesn't run tree search, so the AlphaZero runs used the real engine on CPUs. ## Where it stands Every number above is against scripted bots or earlier versions of the net. Measuring it against people is the next step, which means putting it in front of my friends and family, and playing it myself. One thing the AI's games already showed me is that starting first is a large advantage, much larger than I expected from playing the game by hand. The digital game logs every game it plays, which is where a first human benchmark would come from. The three-player advisor is stateless: you retype the board for each decision. A few rules differ from the real game. The main one: beginning-of-turn effects resolve one by one in an order the player picks, instead of all at once. The order of other simultaneous triggers is also fixed, and Tiny Stable fires once per entry. - **Stack**: Python, PyTorch, FastAPI, rented cloud boxes for training - **Apps**: Table companion for a physical deck, a digital game with difficulty levels, a three-player advisor, a room mode for two friends - **Status**: Working - **Code**: Not public --- Source: https://oliviercouthaud.com/v · Status: In progress · 2026 · Tags: TypeScript, Claude, IMAP # V An assistant on my own email that decides what is worth interrupting me for, and stays quiet the rest of the time. It is now being rebuilt as a company of AI agents. ## What I wanted Notifying is easy. Deciding that one email out of a few hundred has a consequence, and that the rest can wait, is the hard part. I wanted an assistant that keeps a model of my situation, says nothing most of the time, and speaks up when something I would otherwise miss is about to cost me. V came out of the autonomous agent-team systems I had already built. It was the natural upgrade of those, narrowed to one job. It is only the brain. The voice it talks through is a separate project of mine, [VibeOS](https://oliviercouthaud.com/vos), which connects to V over a websocket. I ran V on my own Gmail and calendar, so its judgments were about a real inbox. There is one user, and that shaped the work: building it was one half, and measuring whether it works was the other, harder half. I built it between late June and late July 2026, and I do not run it daily now. ## How it works As I left it, V is a set of scripts on one Windows machine, not a service. A scheduled job ran the morning pass at 7am, there are some manual commands, and a small localhost server serves the page. (The job now logs failures. On the runs from 4 to 10 July it had logged nothing.) A daemon that polls mail exists, but it is not the path I used. Between runs, V senses nothing. The morning pass fetches between 150 and 2,300 recent messages. Each detector narrows candidates with a regex or a count, then makes one stateless Claude call per candidate at temperature 0. The call returns a verdict, a 0 to 1 consequence score and a sentence of reasoning. **What does temperature 0 mean?** Temperature controls how much randomness a model adds when it picks its words. At 0 it takes the most likely option every time, so the same input should give the same answer. It is not a guarantee, and V's early verdicts still flipped between runs, which is a big part of why I changed the middle. Figure: Diagram. The morning run as the code stands after the July rebuild. Verdicts are stored as rows and reused while the evidence is unchanged. Sending needs a tap, and the voice path cannot send at all. The eight steps are explained below; with scripts on, you can click a box, or use the buttons under it, to jump to its step. ### Step through the morning run - **Mail comes in.** Gmail over IMAP. A morning run pulls somewhere between 150 and 2,300 recent messages, depending on the day. V reads the full message, not just the subject. - **Cheap filter first.** Each detector (a thread I left hanging, a sender who has gone quiet, and so on) narrows the pile with a regex or a count before any model sees it. That keeps the model from reading everything. - **Seen this before?** V takes a fingerprint of the thread and its latest message. If it already has a verdict on exactly that evidence, it reuses it. If a reply has been added, the fingerprint changes and V judges again. - **One Claude call.** One stateless call per candidate, temperature 0. It returns a verdict, a 0 to 1 consequence score and a sentence of reasoning. - **The verdict is saved.** The verdict goes into the store as a row keyed by that fingerprint. This is the middle I rebuilt in July. It used to be computed, shown on the page and thrown away. - **The Day Ahead page.** What earns a place lands on the Day Ahead page and can be pushed to my phone. The page is two-way: I can talk back to it. - **Draft, approve, send.** V can draft a reply. Nothing goes out until I tap approve, and the recipient is locked to the sender the mail really came from. The voice side cannot send at all, because the result type it returns has no send operation. - **Did it matter?** Afterwards V labels what actually happened to the things it raised, with no taps from me, and writes that back onto the rows. That is where feedback comes from without me rating anything. 1 / 8 The part I changed most is the store in the middle. Originally every verdict was computed, rendered and thrown away, and I patched the run-to-run flapping by asking three times and taking the majority. Now a verdict is a durable row keyed by a hash of the evidence it was judged on, and unchanged evidence reuses the old conclusion. A steady-state run reuses nearly all of them. **What is an evidence hash?** A hash turns a thread and its latest message into a short fingerprint. The same input gives the same fingerprint, and a new reply gives a different one. The date is left out on purpose, so a thread does not look new just because another day has passed. V compares fingerprints to decide whether anything it would be judging has changed since last time, which is cheaper and steadier than asking the model again. Figure: Diagram. The change in the middle. Before, every judgment was weather. After, every judgment is a row that tomorrow's run reads first. The output is a Day Ahead page I can answer back to. V can draft an email reply, but nothing sends without my explicit approval. The recipient is bound to the verified sender of the thread, automated addresses are blocked, anything about money or law is forced to ask first, and sends are idempotent. The voice path has no send operation, and that is not a check I could get wrong: the type its calls return simply does not contain one. In July the send path had been used about once. ## Measuring it I keep a denominator. V writes a row for every candidate it considered, flagged or not, and the unflagged rows are my control group. The measure is whether flagged items go wrong more often than unflagged ones. That harness is now its own package, [proof](https://oliviercouthaud.com/proof), which V uses. It refuses to print a percentage below an evidence bar, and it counts distinct subjects, not rows. Figure: Diagram. The claim ledger in proof. The judge slot takes any function, and V plugs a Claude judge into it. The report gives lift and separation. "Unknown" is a proper answer and is left out of the rates rather than counted as a miss. **What is an oracle, here?** Just whatever tells me what actually happened by the check date. For "did this sender write again" it is a lookup in my mail, so it cannot guess. For "did I deliver what I promised" it has to read conversations, and it cannot see anything that happened on a channel V cannot read. That difference ends up mattering a lot below. **What are lift and percentage points?** Lift is how much more often the flagged items went wrong than the unflagged ones, subtracted as plain percentages. Going from 36% to 68% is a gap of about 33 points (the figures are rounded, so a point can go missing). Separation is the threshold-free version: it looks at the judge's raw scores instead of the yes/no flag, so it does not depend on where I put the line. ### The same test, cut six ways +33pp "Has this sender gone quiet?" over 100 judgments about 51 senders. Flagged senders stayed quiet 68% of the time, unflagged ones 36%. **What the measurements said. All on my own data, one user. The sender test is 100 judgments about 51 subjects, with oracle coverage 100 of 100.** | Test | Result | |---|---| | Gone quiet? | Flagged 68% vs 36% unflagged: +33pp, separation +0.10 | | Gone quiet, one row per subject | +19pp (first draw), +31pp (last draw) | | Gone quiet, vs the old formula | 33pp vs 26pp on identical rows: inside the noise at this size | | Gone quiet, by subject type | 48 domains: +27pp over 95 judgments. 3 people: +50pp over 5 judgments | | Deliver a promise, run 3 | Flagged 64% vs unflagged 75%, separation -0.03: no signal | | Same, after the oracle fix | Coverage 11 of 11, but only 4 gradeable promises: not enough evidence | | Promise extraction | From sent email and chat: 226 messages, 20 promises, 17 verified kept or moot, 3 open | | Day Ahead from stored state | 38% of attention items in one real run in late July, against a 50% target | Figure: Chart. The two tests side by side, to the same scale. Same harness, very different answers. The second one comes with a caveat: the instrument could not see everything, which is covered in the next section. The flag line for the +33pp test was inherited from the promise work, not fitted on it. The slider above shows how much the number moves depending on how I cut it, so treat +33 as a rough size. I also ran proof on public GitHub issues (vercel/next.js), a domain it was not built for. A hand-written scoring rule, not a model, gets a +9pp lift over 206 issues, and a one-line age heuristic gets the same 9pp, so the harness says the richer rule is not earning its cost. The [proof](https://oliviercouthaud.com/proof) project has that run. ## What I learned On 12 July I wrote down, as neutrally as I could, what the code actually does, and it then went through three separate reviews. They converged on one point: V used an assistant's vocabulary and safety instincts around what was, underneath, a notifier. Every judgment was a stateless call, thrown away after rendering. One review counted eight episodes in the log, about three of them real feedback, and a self-graph whose 15 rows all came from the seed function. The rebuild described above came out of those reviews. In everyday use on my own inbox it surfaced an important email I had missed, which was useful. It also raised quite a few false positives. The main cause is that V lacks the context of my sent mail when it judges a candidate, and there were some other complications on top of that. Adding that sent-mail context is the next thing to do. A metric I built to say how much of a page was caused by a change in the model turned out to rest on a truncation bug. The bug made two threads collide on one verdict row, which produced "flips" that never happened, so on 16 July I removed the metric instead of patching it. My oracle cannot see anything that happened on a channel V cannot read, so on 11 of 13 "did not deliver" labels, "not delivered" looked the same as "delivered by hand or in person". That means the "no signal" result on promises was a statement about my instrument, not about the judge. Run 2 also exposed a scoring bug, since fixed: promises tied to a date, like a call or a move-out, were closed when the date passed and scored as delivered. ### The promise judge, run by run 19 of 21 **Run 1.** I asked yes or no: is this promise at risk? About something already open, it nearly always says yes. That is a mood, not a prediction. Three attempts at learned memory (JEPA-style latent models among them) did not beat simple methods at n=1. Silencing mail on sender relationship would have skipped the model call on only 4 of 32 emails, and rested on verdicts that flipped between runs, so I kept escalating instead. And a daily cycle once wrote ten predictions, deleted them and reported success, because three open stores each rewrote the whole file. Stores now merge on write. ## Where it stands I no longer run V daily. I am working on an upgrade, and it is a different shape from an inbox assistant. It brings in newer models, plus a separate decision model whose job is speed and efficiency: making each call quickly and cheaply. The agents' decisions also move onto unbiased statistics rather than a single model's opinion. The bigger idea is to run the whole thing as a company of AI agents. Each agent has a role (CEO, CTO, CFO, HR and so on). The CFO handles the budget, meaning how much the agents are spending. HR can create new agents, lay some off and track how each one performs. I am the board and the CEO reports to me. I am designing it carefully before building it, and the first pieces of code exist. As for V in its original form, most of what surfaces is triggered by an email scoring high. The 38% above is one run. Feedback is the binding constraint: in July I counted somewhere between 15 and 25 explicit feedback events in total. The cadence layer is dominated by automated senders and keyed by organisation, so joining at the level of a person is the next step. Some of what V knows about me is typed into the source instead of learned. A full morning run cost about $2 when I measured it on 12 July, before verdicts were reused. The tests cover the pure functions, send safety and the stores, not what the model decides. Multi-user isolation exists and has not been tried with a second person yet. - **Stack**: TypeScript, Claude, IMAP and iCal, JSONL stores - **Status**: In progress - **Measurement**: [proof](https://oliviercouthaud.com/proof), a separate package, extracted on 28 July - **Code**: Not public (V, proof, VibeOS and the upgrade) --- Source: https://oliviercouthaud.com/vos · Status: Prototype · 2025-26 · Tags: Python, Rust, Flutter, RabbitMQ # VOS and VibeOS An assistant built from a team of cooperating AI agents, and a smaller one that lives inside a virtual computer, both built with Roman Slack. [Image: Dark desktop-style interface with a notes window, a weather window, a calendar and a chat window, and a launcher rail on the left] Figure: The VOS app, designed by Roman. The notes, weather and chat shown are test data. ## Why I built it I wanted an assistant I could talk to rather than type at, closer to Jarvis in the Iron Man films than to a chat box. I built it with Roman Slack, and then I used it quite a bit myself. That turned out to be the useful part. Using it kept turning up things to fix, so I was probably improving it more than using it. That is how I'd work on every project from now on: use the thing early, because that is where the real problems show up. ## What it is VOS is the assistant. We built it between September 2025 and February 2026. Under the hood it is not one AI but a small team of them. One agent, the primary agent, is the only one that talks to you. When you ask for something it can't do itself, it hands the job to a specialist: one for weather, one for searching the web, one for your calendar, one for notes, one for maths, one for browsing websites. Think of a front desk with a few colleagues behind it. You ask the desk, the desk walks your question over to the right colleague, and brings the answer back to you. Figure: Diagram. The front desk and the colleagues behind it. ## What it can do You can type to it, talk to it, or phone it. It keeps a memory, so something you said last week can come up again without you repeating it. The window-style app at the top is Roman's design. I wrote the talking part, the phone screen and parts of the notes and settings screens. The first thing that worked from end to end was asking for the weather in New York and getting the answer back through three separate programs. After that it grew one specialist at a time. ## What I ran into Agents sometimes got stuck halfway through a job or handled the same message twice, and I spent a lot of time on that. On a phone call, while it is speaking it ignores you, so it doesn't hear its own voice and answer itself. The cost of that choice is that you can't interrupt it. The original plan was bigger than what exists: it included a security agent that watches the others, games, and a team of agents that writes code. Those are not built, and a few launcher icons, like the shop, are placeholders that open nothing yet. ## VibeOS VibeOS is the smaller sibling. Roman started it in late January 2026 as a minimal agent, written in Rust (a programming language). There is one agent, Vi, that lives inside a virtual computer and works the way you would at a keyboard: it types commands into a terminal. A second character, Joi, is the voice. You talk to Joi, and if what you asked for is a job she passes it to Vi, then tells you the result in her own words. The glowing orb screen is Roman's work. I wrote the part that decides what Vi does next, the lock on the door of the virtual computer and a small web dashboard. One bug I'm glad I fixed: the AI would sometimes describe the result of a command before the command had run, which means it made the answer up. Now it pauses mid-sentence, the real command runs, and it carries on from the real output. [Image: A glowing orb in orange, yellow and violet on a black screen above the word LISTENING, a question about the weather in Salt Lake City and a spoken-style reply, with VibeOS written at the bottom] Figure: The VibeOS voice app while it listens. Roman built this screen. Roman and I also worked on Rookery, a feed for research papers. I wrote the part that decides which papers to show you. The site is no longer online. ## What's next Both are prototypes and stay as they were in February 2026, which is when the last work on them was done. What I spend my time on now is a newer system of agents set up like a company. That one is not public. ## What I wanted An assistant I could talk to rather than a chat box: one agent owns the conversation, hands work to specialists, remembers what I said last week, and can take a phone call. I built VOS with Roman Slack from September 2025 to February 2026. The original plan was bigger than what exists. What follows is the part that is built. I also used it a fair amount myself, and that shaped most of the work. Using it kept turning up problems, so I probably spent more time improving it than using it. That is how I work on every project since: use it early and often, because the bugs you find that way are the ones that matter. I wrote the SDK every agent is built on, the API gateway, the primary agent and most of the specialists, the call system, the Twilio gateway, and an API for editing system prompts with versioning and rollback. Roman wrote the browser agent, backend auth, the memory visualisation, the first small web frontend and, late on, the call fast mode. Roman finished the notes agent, and the voice gateway is shared work. The Flutter client is Roman's repo: he designed it and wrote the window system, and I added voice mode, the notes UI, the phone app and the prompt settings. [Image: The same shell in October 2025: notes cards, a weather window for San Francisco, a calendar for October 2025 and a chat in which the assistant creates a welcome note] [Image: The Flutter shell in September 2025: a launcher rail on the left and four empty windows labelled Notes, Analytics, Chat and Calendar, with the text 'This is working' in the input bar] September 2025October 2025 Figure: The client, before and after. Drag to compare. Left, Roman's first shell with empty windows. Right, about six weeks later, connected to the agents. All content is test data. ## How VOS works Every agent is the same SDK class with a different tool list. A message from the client goes through the API gateway into RabbitMQ as a typed notification. The primary agent reads it and, if needed, sends an agent_message to a specialist's queue. The specialist runs its tools and reports back. In text chat only the primary agent talks to the user. In a phone call the call can be transferred to a specialist, which then speaks through the call tools. **What is RabbitMQ?** A message broker. Every agent has its own queue, which works like a mailbox. A sender drops a message in and moves on, and the agent picks it up when it is free. A message only leaves the queue once the agent confirms it handled it, so a crash in the middle doesn't lose it. Figure: Diagram. The services as they run in Docker. Agents read and write PostgreSQL and Weaviate directly through the SDK and its tools, so the lines to the databases show the dependency, not every call. Arrows show the direction a request travels. ### Follow one request The real path of a text question, "What's the weather in New York?", step by step. The weather itself is not shown, only the route. Use the buttons, or tap a box to jump to the first step it appears in. Figure: Diagram. The path of one request. - You send the question from the app. It goes to api_gateway. - api_gateway wraps it as a user_message notification and drops it into primary_agent's queue in RabbitMQ. - primary_agent wakes up, loads its history from PostgreSQL, looks up memories in Weaviate and asks Gemini what to do. The answer has to contain at least one tool call. Here it is send_agent_message, addressed to weather_agent. - That tool puts an agent_message into weather_agent's queue. - weather_agent calls get_weather. The tool fetches the data from OpenWeatherMap and posts a tool_result notification back to weather_agent's own queue. - weather_agent reads that result on its next turn and sends the answer to primary_agent as another agent_message. - primary_agent calls send_user_message. That goes to api_gateway over HTTP, and api_gateway pushes the reply to the app over a websocket. The loop inside each agent is a three-state machine kept in the database: IDLE, THINKING, EXECUTING_TOOLS. A turn loads history, calls Gemini in JSON mode, and requires at least one tool call. An empty list is rejected. Tools publish their results back to the agent's own queue as new notifications, so a multi-step task is several turns, not one long call. Figure: Diagram. One agent turn. The retry count of 3 and the 300 s limit are constants in the SDK. Memory has two modules, both set to run every turn by default (the interval is an environment variable). A retriever runs before the model call and a creator after the turn. The creator is itself an LLM call, and it answers CREATE_NOW, WAIT or IGNORE for what just happened. The retriever searches Weaviate and feeds the results into the turn. Memories are either private to one agent or shared across agents. Notes use the same store for semantic search. **What is semantic search, and what is Weaviate?** Weaviate is a vector database. It stores each piece of text as a long list of numbers that captures roughly what it means, so a search can match on meaning instead of exact words. Asking about "my trip next month" can find a note that only says "flight to Lisbon on the 14th". That is the whole trick behind both the agents' memories and the notes search. ### The team and what each agent can do - **primary_agent**: Talks to the user, delegates and summarises. The only agent that can message the user, phone them or send an SMS. send_user_message, send_agent_message, create_task, update_task, get_tasks, assign_to_task, unassign_from_task, sleep, create_memory, search_memory, get_memory, update_memory, delete_memory, create_document, read_document, list_documents, delete_document, get_attachment, view_image, read_system_prompt, edit_system_prompt, speak, answer_call, hang_up, transfer_call, recall_phone, call_user, call_phone, send_sms - **weather_agent**: Current weather and forecast through OpenWeatherMap. 5 domain tools. get_weather, get_forecast, get_weather_by_coordinates, get_uv_index, get_air_quality - **search_agent**: Web research through Firecrawl (scraping, crawling, extraction) and DuckDuckGo. 8 domain tools. firecrawl_scrape, firecrawl_crawl, firecrawl_map, firecrawl_search, firecrawl_extract, ddg_text_search, ddg_news_search, ddg_books_search - **calendar_agent**: Events and reminders, with a scheduler service behind it. 8 domain tools. create_calendar_event, list_calendar_events, update_calendar_event, delete_calendar_event, create_reminder, list_reminders, edit_reminder, delete_reminder - **notes_agent**: Notes, with semantic search. 8 domain tools. create_note, list_notes, get_note, update_note, delete_note, search_notes, archive_note, pin_note - **calculator_agent**: Arithmetic, statistics, linear algebra, number theory and units. 7 domain tools. basic_calculation, advanced_math, statistics, random_number, number_theory, linear_algebra, unit_conversion - **browser_agent**: Browser automation, written by Roman. 2 domain tools. browser_use, browser_navigate Every specialist also gets the shared tools: send_agent_message, task and memory tools, sleep and shutdown, and the call tools speak, hang_up and transfer_call for when a call is passed to it. ## Voice and calls Voice has two paths. Voice mode streams audio to voice_gateway, which transcribes with AssemblyAI (I moved over from Deepgram in November) and speaks with ElevenLabs or Cartesia, chosen per session. Call mode goes through a call bridge in the same gateway. It waits 1.2 s after the last transcript before sending text to the agent, and it drops transcripts while the agent's own speech is playing, to stop echo. Calls can be transferred between agents, each with its own voice. Phone calls arrive through twilio_gateway, which converts Twilio's 8 kHz mulaw audio to PCM and back. A fast mode, Roman's, uses a smaller model and allows only the speak and hang-up tools. **Why wait 1.2 seconds before answering?** Speech-to-text hands back a transcript in pieces, one each time you pause for breath. If every piece went straight to the agent it would answer half-sentences. So each new piece restarts a 1.2 s timer, and only when the timer runs out does the combined text go to the agent. It costs a little delay on every turn and it stops the agent jumping in early. ## VibeOS A Rust agent, vos_agent, runs inside a headless Ubuntu VM under QEMU and executes bash through a PTY. A gateway exposes it over WebSocket with an API key, rate limiting and TLS. Memory is SQLite with full-text search. Roman started it in late January 2026 as a minimal agent, with the simplest solutions and minimal code as the rule, and built most of the voice pipeline, the later plugin system, the memory rework and the WhatsApp and Twilio work. I wrote the agent loop, the TLS and rate-limited gateway, the first version of agent memory (remember, recall, forget), a multi-agent delegate tool and a web dashboard. The voice side is a separate agent, Joi (Python, Groq, Hume TTS). She listens, decides whether a request is a task for Vi, the Rust agent, and speaks the reply. In voice mode Vi never speaks to you directly; in the chat app it does. **What is a PTY?** A pseudo-terminal: a fake terminal window that a program can type into and read from. It lets the agent run bash the way a person at a keyboard would, with a real shell, working directory and interactive programs, instead of launching one command at a time with no shell around it. Figure: Diagram. The agent also writes to the same database, and OpenAI and Ollama are configurable providers. Arrows show the direction a request travels. My part of the loop went through three versions. First a loop capped at five iterations. Within days I replaced that with a 200-iteration safety valve that ends only when the model calls an explicit stop tool, rejects turns with no tool call, and turns every error into context for the next turn. The third version, in February, runs tools inline. The model writes one continuous stream, and when it writes a tool tag such as the stream pauses, the real command runs, and its output is spliced in before the model carries on. I made the change because the model had been inventing tool results before they ran. I also replaced regex detection of "is this a task" with a Groq model in Joi. [Image: A glowing orb in orange, yellow and violet on a black screen above the word LISTENING, a question about the weather in Salt Lake City and a spoken-style reply, with VibeOS written at the bottom] Figure: The VibeOS voice app while listening. Joi takes the question and Vi does the lookup. The orb is Roman's work. ## Results I have not benchmarked either system, so there are no latency, accuracy or cost numbers. The first thing that worked end to end in VOS was weather: ask the primary agent, it delegates to the weather agent, the answer comes back. After that the specialists, memory, voice and calls came one at a time. VOS has no automated tests. VibeOS has close to 190 small unit tests in Rust plus an integration test for the gateway. The limits I set in the code are these. **Limits I set in the code. Not measured behaviour.** | Setting | Value | |---|---| | VOS agent: LLM call timeout | 90 s | | VOS agent: requeue on transient error | 3 times | | VOS agent: stuck state reset | 300 s | | VOS call: transcript debounce | 1.2 s | | VibeOS: tool loop, iterations | 200 | | VibeOS: bash output kept, chars | 20,000 | ## What I ran into Agents got stuck or double-handled messages, and most of the reliability work went there: a fix for a race on message history in October, a requeue system in September and a reset for stuck agents in December. The memory creator saved the same thing over and over until I gave it a duplicate check and made it ignore when unsure. I changed models several times, from Gemini 2.5 Flash to a 3 Pro preview and on to Flash variants, without a side-by-side comparison, so I have no ranking of them. Call mode drops speech while the agent is talking, which stops echo; the trade-off is that you can't interrupt it. Roman's Twilio work in VibeOS was added untested and was later switched off. ## Where it stands Both projects stay as they were in February 2026. My current work is a newer system of agents organised like a company. It is not public. The original plan also had a Watchdog security agent, event subscriptions, a games and apps suite and a coding team of agents; those are not built. There is a registry service for apps, but nothing that generates them yet. Basic document tools exist (create, read, list, delete), and the agent-to-agent document flow I planned is the next piece there. The app's launcher has icons for a shop, reminders and tasks that are placeholders for now. In VibeOS, Joi's personality is not written yet, so in February Roman added a direct Vi mode to test voice without her. ## Also with Roman: Rookery Roman Slack and I also made Rookery, a social-media-style feed for research papers. I built the backend and the AI that decides which papers to show you; Roman built the interface and handled publicity. A waitlist was online; the site was not opened to the public. It builds paper embeddings from the citation graph with a GraphSAGE network, and the adaptive feed mixes mostly relevant papers with fresh ones and a share of deliberately adjacent topics. I evaluated it with simulated users. The site is no longer online. - **Stack**: VOS: Python, FastAPI, RabbitMQ, PostgreSQL, Weaviate, Gemini, Flutter. VibeOS: Rust (tokio, axum), SQLite, QEMU, Python for Joi. - **Status**: Prototype - **Built with**: Roman Slack - **Code**: VOS: [github.com/Forkei/VOS-public](https://github.com/Forkei/VOS-public), and Roman's [VOS_frontend](https://github.com/RomanSlack/VOS_frontend). VibeOS: not public. [← All projects](https://oliviercouthaud.com/projects) --- Source: https://oliviercouthaud.com/wordle · Status: Prototype · 2025 · Tags: Python, PyTorch, PPO # Wordle Solver A neural network that taught itself to play Wordle, in a simulator I built. Figure: A game I picked to show how it works, not a recorded run of the agent. Wordle colours are shown here as black, orange and outline. Figure: A game I picked to show how it works, not a recorded run of the agent. Wordle colours are shown here as black, orange and outline. ### Run it on today's Wordle One button. It fetches today's puzzle, the trained network plays it here in your browser, and the board fills in live. Nothing is recorded or chosen beforehand. This is the first version of the network, with the same word list, observation and mask as in training, rewritten in JavaScript. The puzzle comes from the Wordle feed through a small function on this site. A word outside its 16,132-word list can't be played. ## What I wanted I got a bit obsessed with Wordle and started wondering how good cheating could be: how strong I could make the system, and how many guesses it would need on average. I wanted an agent that picks its guesses from the colour feedback alone, trained entirely in a simulator, and then I wanted to see whether the same agent could play a real site. The training runs are from 26 and 27 November 2025. ## How it works A Gymnasium environment picks a random target from a list of 2,344 answers. The agent picks one of 16,132 allowed guesses, and the environment returns the green, yellow and gray feedback. What the agent sees is a grid of 26 letters by 5 positions by 3 states (unknown, not here, confirmed), plus how many guesses it has used and what fraction of the word list is still possible. v2 adds the previous guesses as 30 more numbers: 6 guesses of 5 letters each. Training uses MaskablePPO from sb3-contrib. The mask removes every word that contradicts the feedback so far, so the agent never has to learn the rules, only which allowed word is a good pick. **What is PPO?** Proximal Policy Optimization, a standard way to train a policy by trial and error. The agent plays a batch of games, works out which guesses led to better scores than expected, and nudges the network towards them. A clip stops any single update from changing the network too much, so one bad batch can't undo everything it has learned. **What is an action mask?** A list that tells the agent which of its 16,132 options are allowed right now. Mine switches off every word that contradicts the feedback so far. It also means the agent always plays a stricter version of Wordle's hard mode: every guess has to be a word that could still be the answer, so it can never spend a guess just to test some letters. Figure: Diagram. The orange box is the only learned part. On a real site the word list is the same, but the site may reject a word it does not know, and then the script masks it and asks again. Figure: Diagram. The orange box is the only learned part. On a real site the word list is the same, but the site may reject a word it does not know, and then the script masks it and asks again. The reward is made of a few parts. Each guess earns 2 points per bit of information it gains, meaning how far it shrinks the list of words that still fit, plus 0.5 per green and 0.2 per yellow. A win pays 50 plus 5 for each guess left unused. A loss costs 500. That penalty is deliberately huge: the mode in my environment is called safety first, and the idea was that the agent should never lose. v2 keeps the same numbers and adds an extra 3 times the information gain on the first guess. **What is a bit of information?** One bit halves the number of possibilities. A guess that takes 16,132 candidate words down to 44 gains about 8.5 bits, because 16,132 divided by 44 is about 367, which is roughly 2 multiplied by itself 8.5 times. The environment counts these bits over the 16,132 allowed guesses, not over the 2,344 answers. ### Score one game, guess by guess Played out with the v1 rewards, the game in the picture at the top is worth 18.24, 12.32 and 67.50 points for its three guesses, 98.06 in total. Almost all of the first guess's points come from how many words it ruled out. A game I chose for illustration, not a recorded run. The word counts are the real ones for my 16,132-word list. The rewards use the formula from my environment. v2 swaps the network for one that starts with a state encoder (three small branches for the letter grid, the guess count and the guess history, joined into 256 numbers) followed by 256 and 128 unit layers, keeps the same mask and reward shape, and adds four reward modes and code for ensembles and for looking at attention. v1 is a plain 3-layer network of 256, 256 and 128 units for both policy and value. Around the agent there are a few small tools. One is a script that opens an unlimited-Wordle site in a browser with Playwright, lets the model pick a word, types it in, and reads each tile's state from the page. If the site doesn't know the word it masks it and asks the model again, which matters because my guess list is much looser than a real game's: it has obscure words and names in it. Another is a helper where I type in the colours from a real game and the model suggests a word. The last two are plain solvers with no learning. The first is an interactive candidate filter with a letter-frequency suggestion (December 2025). The second (February 2026) is built around the colour rows of someone else's guesses, where I can see the colours and not the letters, and keeps the answers that some allowed guess could have produced. ## How well it plays While training, each run evaluated itself every so often: 12 evaluations of 50 games, with the model playing its best guess every time and no exploration. A game counts as won when its reward ends up positive, and that is unambiguous here: every win in these evaluations scored between about 82 and 144, and every loss between about minus 471 and minus 435. Each point is one evaluation of 50 games. v1 was evaluated every 40k steps up to 480k and v2 every 80k up to 960k. The two runs differ in more than the architecture. | Run | Wins, all evals | Mean guesses | Last eval | |---|---|---|---| | v1, MLP | 532/600 | 4.79 | 42/50, 5.08 | | v2, 1M-step run | 576/600 | 4.33 | 50/50, 3.98 | Mean guesses count a loss as 6. v2 was best late in training, but it won 50 of 50 twice and 45 of 50 once, so a single point at 50 games is noise. The win rate during training agrees: measured over the last 100 games with exploration still on, it averaged about 83% for v1 and about 95% for v2 across the last ten readings. Training took about 50 minutes for v1 and about an hour and three quarters for v2. ### Look inside one evaluation The last evaluation of v2, at 960k steps, won 50 of 50 games with 3.98 guesses on average: 2 games on guess 2, 11 on guess 3, 25 on guess 4, 10 on guess 5 and 2 on guess 6. Real numbers from the 50 games of each evaluation. Bars show how many games ended on each guess, and the orange bar is games lost. Both runs use the same vertical scale. There is no baseline in the same masked environment, for example a random consistent word or the letter-frequency suggester, so these numbers don't show how much the learning itself adds on top of the mask. I ran the browser script on an unlimited-Wordle site quite a bit. From memory it averaged about 3 to 4 guesses a game, now and then got the word in 2, and I think it never lost. I didn't record those games, so treat that as an impression and not a result. **Live in the browser.** The box at the top runs the v1 network in JavaScript: the same 392-number observation, the same action mask and the same feedback rule as my environment, with the output layer stored as 8-bit integers so the whole model is about 3 MB. I checked it against the Python version on 42 games and it picked the same word at every step. Over all 2,344 answers, the words it trained on, the best v1 checkpoint wins 2,130. Four of those answers are not in its guess list, so that is 2,130 of 2,340, or 91%. It takes 4.55 guesses on a win, and 4.68 if a loss counts as six. The wins split as 1 in one guess, 32 in two, 237 in three, 778 in four, 692 in five and 390 in six. Because these are training answers, it is not a held-out result. ## What I ran into The comparison between v1 and v2 is not clean. v2 differs in features, reward (the opener bonus), network shape, learning rate (0.001 against v1's 0.0003) and training length (about a million steps against about half a million), and it ran 16 games in parallel. The two runs changed several things at once. At 480k steps, where v1 stopped, v2 was already ahead in these evaluations, 48 of 50 against 42. v1 peaked at 48 of 50 at 320k and slid back from there, so more training did not obviously help it. v2 was set up with a learning-rate schedule, but the rate stayed at 0.001 for the whole run, so the schedule had no effect on these numbers. The attention model is also not attention over words in that run. The attention version was meant to match a query from the game state against embeddings of all 16,132 words, with the mask left off so that it learns the rules itself. The run I'm showing numbers for only uses the state encoder, and it does use the mask. The real word attention lives in a separate training loop, and that loop won no games in about 4,000 steps: every game ran to six guesses, and the average reward sat near minus 547 throughout. **What is attention here?** In a neural network, attention scores how relevant each item in a set is to a question, then mixes the items using those scores. Here the set was meant to be all 16,132 words, each a learned list of 128 numbers, and the question was the current state of the game. That would let the network see which words are similar before choosing. The run behind the numbers above skips it. The word lists have one mismatch. Four of the 2,344 answers (admin, cyber, inbox and ramen) are not in the 16,132-word guess list, so the agent cannot win those four. That is a tiny share of the answers. The browser script has no logged results, only my recollection of the games I watched it play. The helper where I type in colours is set to load a model from a folder that no longer exists, so it needs a path passed in. ## Next The next step is a fair test: answers the agent never trained on, a simple baseline in the same masked environment, v1 and v2 compared one change at a time, and a logged run of the browser script over a fixed number of games on the real site, to replace my memory of it. The word-attention loop would need another try after that. - **Stack**: Python, PyTorch, Gymnasium, Stable-Baselines3 with sb3-contrib (MaskablePPO), Playwright for the site script - **Status**: Prototype - **Made**: November 2025 for the agent, December 2025 and February 2026 for the two plain solvers - **Code**: Not public --- Source: https://oliviercouthaud.com/worldgraph · Status: Working · 2026 · Tags: TypeScript, Postgres, LLM agents, Entity resolution # WorldGraph An investigator that maps what is publicly known about a company, a website or a public figure, with the source of every fact attached. [Image: Drawing of a graph: three large hubs, each ringed by dozens of small dots on thin lines, joined to each other by chains of black lines. Most lines are pale grey, some black, a few red.] Figure: A drawing, not a real case: a few busy hubs joined by thinner chains, which is roughly what the graph of a company looks like. Black lines stand for facts several places agree on, grey for one source, red for disputed. WorldGraph is TypeScript on Postgres. You give it a seed, which can be an organization, a domain, a URL or a public figure, and one agent investigates it and builds a graph where every link points back to the page and the quote it was read from. I started it at the end of August 2026 and built it on my own, because I wanted the hour of tab-juggling done for me with the receipts kept. It has one user, me, and it works end to end. ## What I wanted Most tools in this space hand you a pile of links. Finding pages is the easy part. The hard parts are knowing how far to believe each fact, and not welding two different things together because they share a name. Four rules hold the rest up. Every fact keeps the quote it came from. Facts are graded by who published them and by how many independent places agree. Identity never merges on a name alone. And every edit can be undone. It never logs into anything and does not touch anyone's systems. It reads public sources, and the few optional connectors are off unless I turn them on. ## How it works Postgres holds everything, and a local blob store archives the raw pages. A run goes through five stages. Click or tap a box to see what it does. Figure: Diagram. The five stages of a run. ### The five stages - **plan.** The investigator is one agent, Claude Sonnet 5 on low effort by default, with about 35 tools and a notebook. It decides what to search, read and look up next, and when it is done. You can talk to it while it works. - **acquire.** Fetching pages (Firecrawl first, then plain HTTP with Readability, then the Wayback Machine), searching several engines, and about 30 connectors for public records and infrastructure. - **extract.** A model, Gemini 3.7 Flash by default, reads a page and writes claims. Each claim is typed against a registry of entity and relationship types (with room for new ones) and carries the quote the model says it came from. A separate vision step, also Gemini 3.7 Flash, reads photos for text and place, never identity. - **knowledge.** Claims become a graph by a deterministic rebuild: resolve who is who, grade every link, and keep the whole thing regenerable from claims plus the decisions on top of them. - **apps.** A command line, wg, and a phone-friendly web app: a chat with the investigator, and a force-directed graph where tapping a node shows its evidence. The investigator searches (Google through Serper, with other engines as fallbacks, paging and operators), reads pages, looks at photos, and calls roughly 30 connectors: DNS, RDAP, certificate transparency, passive DNS, the Wayback Machine, GitHub, FEC filings, SEC EDGAR, GLEIF, Wikidata, court records and company registries. It keeps a notebook and a hypothesis board, and it decides for itself when it is done. **What is RDAP?** The structured successor to WHOIS: the public record of who registered a domain, with which registrar, when, and on which name servers. Much of a website's identity is thin, and registration data is one of the few hard facts about it. Pages become claims. A claim says one thing, such as who runs what, and stores the quote it was read from. Quotes are no longer checked against the page. They are there so I can walk a fact back to its source, and a model sometimes paraphrases, which the verbatim column below shows. Nothing in the graph is edited by hand. Entities, links and grades are computed from the claims plus the decisions recorded on top of them, so I can throw the graph away, rebuild it, and get the same graph back. **Why rebuild instead of edit?** If a graph is only ever edited, you can't tell why a fact is in it. If it is always computed from claims and decisions, a bad source can be retracted and everything that rested on it re-derives. Asking "why is this here" is a walk back to the quote and the page. There is a chat. You talk to the investigator while it works and it replies as it goes. The conversation is the report. You can also ask the finished graph a question and get a short answer with its evidence attached. ## How a fact gets graded Every link gets two things I never fold into one. The first is reliability, a ladder over who published it. The second is credibility, which counts independent origins. Independence is about provenance, not wording: one page is one origin, one publisher is one origin, and a fact that says "according to X" counts as coming from X wherever I read it. A publisher the graph has never seen starts low and climbs as it learns who they are, and it only has to learn that once. Figure: Diagram. The reliability ladder is the definition of the A to E scale, not a judgment about any particular site. **What is the Admiralty scale?** A grading system from intelligence work: one letter for how reliable the source is, one number for how well the information is confirmed by others. A1 is a completely reliable source and a fact confirmed elsewhere. Two axes are used because a good source can report something nobody else has seen, and a bad one can repeat something true. The label (established, corroborated, single source, unverified, disputed) is only a summary of the letter and the number, and the letter and number are always shown next to it. This is the rule WorldGraph uses to grade facts, with two extras: a fact whose quote could only be matched loosely never rises above unverified, and a source it cannot place at all is marked unjudgeable. Try it. ### Grade a fact Reliability letter, credibility number, then the label. Credibility 1 is confirmed by independent origins, 2 a single good origin, 3 a single weak one, 4 contested. **The same rule as a table, for a fact nobody contradicts. Best publisher among the sources on the left.** | Best publisher | One origin | Two or more origins | |---|---|---| | A, issues the record | A2 corroborated | A1 established | | B, the subject itself | B2 single source | B1 established | | C, independent | C3 single source | C1 corroborated | | D, platform | D3 unverified | D1 corroborated | | E, unknown | E3 unverified | E1 corroborated | ## Who is who A name is never identity. People and organizations stay separate records until an identifier, a judgment with evidence, or I join them. Organizations resolve per registrable domain, so two sites that happen to share a brand name don't fuse, while investors.example.com and example.com do. Name matching misses variants and nicknames, so a small model proposes the pairs it would miss. An LLM judge (Haiku 4.5) then reads both records and confirms or declines, and every merge and split is a recorded decision that can be reversed. **What is an embedding?** A list of numbers that places a piece of text near other text with a similar meaning. Records about the same entity, written differently, should land close together. I fine-tuned a sentence-embedding model on the system's own casework, where the judge's earlier verdicts act as free labels, and exported it to ONNX so it runs inside the TypeScript process with no Python at runtime. Figure: Diagram. Name blocking and the learned proposer each nominate pairs. Only the judge, an identifier or I can merge them. I ruled out merging automatically on a score. Two sibling investment funds, one the next vintage of the other, scored 0.94 on similarity and are different entities. ## Results **The learned resolver.** Untrained, a sentence model already separates same-entity records from different ones: ROC-AUC 0.80 over all 519 records from three test cases, and 0.98 on the held-out entities. Fine-tuned, it reached 1.00 on those held-out entities, and the similarity of same-entity pairs went from 0.73 to 0.96. Treat that 1.00 as soft. There are only 111 training and 174 test positive pairs, and the labels are my own judge's decisions, so it measures agreement with my casework, not truth. There is no human-reviewed set yet. As a proposer it did its job: on its first run it surfaced 13 candidate pairs, some of which name matching never compared, the judge merged one (a company and its short name) and declined the rest, including the sibling funds. One merge, no bad ones, $0.02. **Reading pages.** I ran eight models on the same prompt and schema over the same ten real pages (a biography, a patent, a court opinion, a campaign-finance record, a forum thread and so on), and a second model judged up to 60 items per page against the text. Each model ran once, and the judge was Opus 5, which is also one of the eight, so small gaps mean nothing. "Supported" only says the page states the item, not that it was worth extracting. **Extraction benchmark, 10 pages, one run per model. Verbatim is the share of quotes found word for word on the page. Cost is in dollars and time in seconds per page. The number of items each model extracted is in the chart below. * Only 9 of the 10 pages came back.** | Model | Sup­ported | Ver­batim | Cost, time per page | |---|---|---|---| | Gemini 3.7 Flash | 99.6% | 91.6% | $0.023, 10.2 s | | Gemini 3.1 Pro | 97.5% | 77.0% | $0.190, 104.5 s | | Opus 5 (low) | 97.2% | 91.9% | $0.178, 43.2 s | | Sonnet 5 (low) | 97.2% | 90.9% | $0.069, 33.2 s | | Haiku 4.5 | 95.4% | 87.4% | $0.038, 31.7 s | | Sonnet 5 (medium) * | 94.4% | 97.1% | $0.075, 37.7 s | | Gemini 3.5 Flash-Lite | 92.0% | 93.6% | $0.012, 8.9 s | | Gemini 2.5 Flash * | 90.8% | 79.7% | $0.049, 68.9 s | **Driving an investigation.** Three models each investigated the same small public website from scratch, with the same extractor (Gemini 3.7 Flash), tools and a $1.50 cost ceiling. Sonnet 5 on low effort ended with 54 corroborated facts (two or more independent origins, or an authoritative record), from 39 sources. Haiku 4.5 got 19 from 20 sources. Sonnet 5 on medium effort got 31 and stopped on its own: its extra thinking went into deciding it was done sooner, not into collecting more. So Sonnet 5 on low effort drives the loop, and Haiku is kept for narrower jobs such as judging identity pairs. One run each, so the same caveat applies. The earlier full run on that case took 130 tool calls, 11 minutes and $1.82. An early automated check, where a model compared a sample of extracted facts with their quotes, came out at about 85% supported, on an earlier version. ### Compare the models Reading pages: one run per model over 10 pages. * 9 of 10 pages came back. Driving an investigation: one run per model on the same case, $1.50 ceiling. Cost: Haiku 4.5 about $1.50 and Sonnet 5 low $1.69 (both stopped by the $1.50 ceiling, which is not a hard cap and was slightly exceeded), Sonnet 5 medium $1.49 (finished on its own). ## What I ran into **Two websites became one.** Organizations were grouped by name before the resolver ran, so two unrelated sites with the same brand name fused into a single entity. Organizations are now keyed by registrable domain, and a mention with no domain only attaches when exactly one record carries that name. **An account that wasn't there.** A username check reported a profile on a site that returns a normal-looking page for any name at all. Every hit now gets a control request with a random username, and catch-all sites are dropped. The control is conservative: when a site refuses it, the hit is dropped too, so the check misses some real accounts on the big social platforms. **Two thousand wrong domains.** A reverse-IP lookup on a site behind a CDN returned about 2,000 unrelated domains hosted on the same address. The lookup now checks the network first, refuses to list siblings on a CDN, and returns only a count above 40 tenants. **It died with my phone.** The first version of the app tied a run to the browser connection, so the investigator stopped whenever the phone left the page. I found that by using it. Runs are now server-side jobs. **I deleted a lot.** After using the first full version I rewrote the design. Out went the check that rejected claims whose quote wasn't found verbatim (it was finicky), a classifier for source types, a table of website tiers, and text-similarity tests for independence. In came the publisher ladder, provenance-based independence, and agents that read whole pages. I'm also retiring the hand-tuned resolution weights in favour of the learned model, though they still score pairs today. One run also spent about a third of its budget tidying duplicates after every batch, so the agent now collects first, tidies once it has enough, then looks at what is missing. A long network outage ends a run, and I restart it from the app. ## Where it stands It is early, and I am the only user. It needs a Postgres, an LLM key and a search key, and many connectors need their own, often paid, keys. Free-tier quotas are the real limit, which is why results are cached. Face search was on the roadmap for a while, and I left it out on purpose. It would have been a big step up from everything else here, and I didn't think that was worth the complexity or the ethics, so nothing in the tool tries to identify anyone from a face. There is an optional connector that links identifiers through breach records. It does not keep passwords, and it is off unless I add its key. An analyst layer that would pick out the lead finding, the timeline and the contradictions on its own is a separate piece, not part of the tool today. Next is a shared world graph that every investigation feeds into, with standing watches that re-run on a schedule and a short digest of what changed. It is in progress and none of the above relies on it. Precision matters more there: a bad merge in one case hurts one case, and in a shared graph it spreads. - **Stack**: TypeScript, Node, Postgres. Gemini 3.7 Flash extracts claims, Claude Sonnet 5 drives the investigator and Haiku 4.5 judges identity pairs by default. Search through Serper with fallbacks, a fine-tuned sentence model served through ONNX. - **Status**: Working - **Scope**: Public entities, read-only, with a few optional connectors that are off by default. Single user, early stage. - **Code**: [github.com/Forkei/worldgraph](https://github.com/Forkei/worldgraph) (public) --- Source: https://oliviercouthaud.com/about # About I'm Olivier. I grew up in the south of France with an American mom, so English and French are both native. I studied computer science at the Rochester Institute of Technology (RIT) in New York, in the accelerated BS/MS programme, and ran its AI Club. In January 2026 I left to build full time. Since then I've done contract work on AI projects and built a long list of things, most of them from a question I couldn't leave alone. From November 2026 I'm also part-time at Third Axis AI, testing an AI-employee product with agents. Lately that has pulled me toward hardware: drones that cope with failure, a printed plane, a heartbeat sensor, rooms turned into 3D models. I still write plenty of software, and I'm happy in either. I take photographs too, and they live [here](https://oliviercouthaud.com/photography). - **Languages**: Python, TypeScript, Rust, C, Dart, Java - **ML**: PyTorch, JAX, reinforcement learning (PPO, SAC), RAG, MCP - **Simulation, 3D**: MuJoCo, COLMAP, Blender, nerfstudio, 3D printing - **Systems**: FastAPI, PostgreSQL, Docker, Linux, GCP, Firebase - **Languages, human**: English and French, both native - **Looking for**: Full-time or internship, software or hardware. France, EU, remote, US. ## Timeline 2024 → now **Software and work**Hardware and 3D**Study Bars show roughly when each project was active. Approximate. Nov 2026 ### [AI platform engineer, agentic QA, Third Axis AI](https://oliviercouthaud.com/experience.html#thirdaxis) Part-time contract from 2 November: using an AI-employee product like a business owner would, and building a harness of automated agents to test it. The details are confidential. Oct 2026 ### [Engine sound simulator](https://oliviercouthaud.com/enginesim) A Rust simulator of gas flow through an engine's intake and exhaust, turned into sound. Runs live in a desktop app; a browser version is in progress. Oct 2026 ### [PicoTalon](https://oliviercouthaud.com/picotalon) A 905 mm RC plane, 3D printed in pieces. Assembled and waiting on its electronics; it has not flown yet. Oct 2026 ### [House23D](https://oliviercouthaud.com/house23d) Rooms rebuilt as measured Blender models from video and photos, plus a printable 1:100 version. Aug 2026 ### [HeartbeatID](https://oliviercouthaud.com/heartbeatid) Identifying people from their ECG. About 92% top-1 on 10 held-out people, tested on a different day. Jun 2026 ### [V](https://oliviercouthaud.com/v) A personal assistant that decides when to stay quiet, with a harness that refuses to report numbers it cannot support. Jun 2026 ### [Gaussian splats](https://oliviercouthaud.com/splats) Phone video to 3D scenes, on a rented GPU for about $0.40 a run. May 2026 ### [Contract developer, Adapter Group](https://oliviercouthaud.com/experience.html#adapter) Agent prototypes on a knowledge-graph platform's API, May to September. The work is confidential. May 2026 ### [Fault-tolerant quadrotor](https://oliviercouthaud.com/drone) Reinforcement-learning drone control in simulation that copes with a weakened rotor. Apr 2026 ### [Contract developer, Metropolis World](https://oliviercouthaud.com/experience.html#metropolis) Four weeks building the first phase of an AI-character platform for a web-based virtual world: a chess-playing character that talked, analysed the game and was meant to feel like a person, with memories and emotions. Apr 2026 ### [MTG AI](https://oliviercouthaud.com/mtgai) A Magic engine where an LLM was meant to turn card text into rules once; a PPO agent plays it. Mar 2026 ### drone-rl Quadrotor navigation with deep RL, 8,633 steps per second on one GPU. Feb 2026 ### [IBM Dev Day hackathon](https://oliviercouthaud.com/healcontrol) Wrote the MCP server in a team of four: tools that let an AI assistant run tests, patch a bug and open a pull request. Jan 2026 ### Left the Rochester Institute of Technology to build full time Stopped the computer science BS/MS to work on projects and freelance. Sep 2025 ### [President, RIT AI Club](https://oliviercouthaud.com/experience.html#rit-ai-club) Through May 2026: guest speakers, events, workshops, and a self-paced AI learning platform aimed at certifications. Sep 2025 ### [VOS](https://oliviercouthaud.com/vos) A multi-agent virtual OS with Roman Slack: Python backend, Flutter frontend, agent memory. Jun 2025 ### [Software tutor on Wyzant](https://oliviercouthaud.com/experience.html#wyzant) At least six client projects through March 2026, each built with the client and talked through step by step, including a finance research agent and a retrieval-based assistant for an enterprise client. Feb 2025 ### [Jarvius](https://oliviercouthaud.com/jarvius) My first agent system: a personal assistant on Gemini with multi-agent orchestration. Sep 2024 ### Started at RIT Computer science, accelerated BS/MS, at the Rochester Institute of Technology in New York. --- Source: https://oliviercouthaud.com/experience # Experience Roles and contracts, newest first. [CV](https://oliviercouthaud.com/cv) · [Timeline](https://oliviercouthaud.com/about.html#timeline) ## AI platform engineer, agentic QA, Third Axis AI From Nov 2026 Part-time contract. Using an AI-employee product the way a business owner would, and building the harness of automated agents to test it at volume and find what breaks. Part-time independent contractor from 2 November 2026. The product, its customers and what I find are confidential, so this page only says what the role is: using an AI-employee product the way a real business owner would, building the harness of automated agents to test it at volume, trying to break it, and reporting what works and what does not. ## Contract developer, Adapter Group May – Sep 2026 Built agent prototypes on a knowledge-graph platform's API for a client. Details confidential. I worked as an independent contractor under a consulting agreement, building agent prototypes on top of a knowledge-graph platform's API. The work belongs to the client and is confidential, so this page says what kind of work it was and nothing about the product or the client's use. ## Contract developer, Metropolis World Apr – May 2026 Built the first phase of an AI-character platform in a four-week, fixed-scope contract: a chess-playing character that talks, analyses the game and is meant to feel like a person you are playing against. Gave it memories (generated from a backstory) and emotions, building on earlier work on agent memory and emotion; also the character data model and a web interface. The brief was AI-powered places inside the company's city map, where visitors can talk to persistent characters with personalities and memory. I built the first phase, a chess-playing character you could play against: it talked to you, analysed the game, and was designed to feel like a person, with memories and emotions. A lot of my earlier work on agent memory and emotion went into it. I handed it over in May. The characters' lore and art belong to the client, so none of it is shown here. ## President, RIT AI Club Sep 2025 – May 2026 Ran the club for the 2025–26 year: hundreds of members on the roll, 10 to 50 attending each session in person. Guest speakers, events and workshops. Set up a platform for students to learn AI at their own pace, aimed at certifications, as a base for later officers to build on. President for the 2025–26 academic year, September to May. The club ran guest speakers, events and workshops. I also set up a platform where students could learn AI asynchronously, with certifications as the end goal, and left it as a base for the next officers to build on. ## Software tutor, Wyzant Jun 2025 – Mar 2026 Built at least six client projects while teaching each client how and why they worked, including a retrieval-based assistant for an enterprise client and a finance research agent. Clients came through Wyzant, an online tutoring marketplace. In each project I built the thing and talked the client through how and why it worked. Two can be named by type: a retrieval-based assistant for an enterprise client and a finance research agent. The others, and the client names, stay private.