Posts in Go (20 found)

(Post-Quantum) Cryptographic Right Answers

Publishing “Cryptographic Right Answers” for developers is a tradition by now. Colin Percival started in 2009 , Thomas Ptacek followed up in 2015 , and then Latacora carried on in 2018 and 2024 . It might feel too soon for an update, but so much has changed . The timeline of the post-quantum transition dramatically shifted in early 2026 , and there is now consensus that it needs to complete by 2029. 2029! We have three years! The good news is that, despite what you might have heard, symmetric cryptography is going to be fine, because Grover does not matter . The bad news is that all asymmetric answers changed compared to 2018 and most even compared to 2024. The answers below assume you are not constrained by any specific compliance regime, although they are mostly compatible with FIPS 140. 1 If you need to follow something stricter like CNSA 2.0, you’ll know. Just substitute as appropriate. Finally, remember the other big change of 2026: LLMs are very good at finding bugs now. “Get a review from the best model you can afford” is now firmly a Cryptographic Right Answer, along with the long-time classic “use a memory-safe language.” If you’re in a rush, here’s the cheat sheet: Now let’s dive in. If you’re in the cloud, KMS . Otherwise, XAES-256-GCM or XChaCha20Poly1305 with random nonces. If you’re encrypting more than a few MB, use Cobblestone-128 . Percival, 2009 : AES-CTR with HMAC. Ptacek, 2015 : NaCl/libsodium default, or ChaCha20Poly1305, or AES-GCM. Latacora, 2018 : KMS, or XSalsa20+Poly1305. Latacora, 2024 : KMS, or NaCl’s secretbox, or XChaCha20Poly1305. Latacora’s reasoning for why and when you’d use your cloud’s KMS is still solid, so start there. If you’re encrypting locally, you want an AEAD where you can avoid worrying about tracking nonces or how many messages you encrypted, so you want random nonces of at least 128 bits. 2 NaCl/libsodium’s secretbox is just a paper-thin wrapper around a less common AE construction (XSalsa20+Poly1305), so instead I recommend XAES-256-GCM or XChaCha20Poly1305. The former is an efficient composition of a standard KDF and AES-GCM, packaged as an extended-nonce AEAD, so even if your library doesn’t provide it under that name you can assemble it from the KDF and AES-GCM APIs. 3 Regular AES-GCM and ChaCha20Poly1305 with random nonces are only ok if you know for certain you won’t encrypt more than a billion messages with one key. AES-GCM-SIV is also a good AEAD choice if you happen to have it everywhere you need it, but that’s kinda unlikely. Some even newer options have nice properties like key commitment, but are not commonly available yet. Since you have an AEAD, consider putting context in the additional data, like the user ID you’re encrypting for, or the version of the protocol. Anything not secret that you will definitely still know at decrypt time. Very important is how you handle large messages, which KMS will not solve for you. Do not pass one large message to an AEAD, or you might be forced to process or store the plaintext before having authenticated it at decrypt time. Absolutely do not use unauthenticated stream ciphers. Instead, you should use STREAM (which is not a stream cipher but a way to use AEADs on a stream… I know, I know, terrible name). There are a lot of options—too many, in fact—and shamefully none are the consensus answer. Tink has one , libsodium has one , the AWS Encryption SDK has one , FLOE is one , age has one, and the list goes on. I recommend Cobblestone-128 , which is a STREAM variant derived from TLS, and is available in pyca/cryptography and soon in the Go standard library (and in the meantime at filippo.io/cobblestone ). 4 Avoid : unauthenticated modes (CBC, CTR, raw stream ciphers), random 96-bit nonces unless you spent a lot of time thinking about how many messages you encrypt, rolling your own AEAD construction. Don’t stress it. 256-bit keys if you need to pick. Percival, 2009 : 256-bit AES keys. Ptacek, 2015 : 256-bit keys. Latacora, 2018 : 256-bit keys. Latacora, 2024 : 256-bit keys. Remember that Grover doesn’t actually matter and 128-bit brute-force attacks don’t exist. Still, 256-bit keys rule out multi-target attacks, which is nice. However, don’t make other decisions based on this. For example, the recommended Cobblestone suite uses 128-bit keys and that’s fine because it has a careful analysis of multi-target attacks. Likewise, don’t go changing the default cipher suite preferences of your TLS library to enforce 256-bit keys; there’s no need. AES-256 is slower than AES-128 because unfortunately Rijndael ties the number of rounds to the key size, but this probably won’t matter in practice. Avoid : anything other than 128 or 256 bits, worrying too much about size, keys that are not uniform (e.g. using a password or ASCII hex string directly as a key). HMAC-SHA-256 . Percival, 2009 : HMAC. Ptacek, 2015 : HMAC. Latacora, 2018 : HMAC. Latacora, 2024 : HMAC. Yay for HMAC. Just be careful to sidestep canonicalization and parser alignment bugs by always authenticating a verbatim string of bytes, not something you need to recompute. Check the HMAC and then process or store or forward just . You can use KMAC or keyed BLAKE3 if you already had a reason to use their underlying hashes. HMAC-SHA-512 is also fine. Remember to compare tags with dedicated constant-time functions. Avoid : rolling your own YoloMAC , encrypted hashes, standalone one-time MACs like Poly1305 or GMAC. SHA-2 is still fine, but use a real multihash if needed. Percival, 2009 : SHA-256 (SHA-2), plan on SHA-3. Ptacek, 2015 : SHA-2. Latacora, 2018 : SHA-2. Latacora, 2024 : SHA-2. The best option, if you have it available, is still SHA-512/256, which isn’t vulnerable to length-extension attacks. Otherwise, SHA-256 and SHA-512 are both fine. 5 SHA-3 is unnecessary (because SHA-2 is fine) and slow, 6 but not wrong . If you already have Keccak (SHA-3) around for ML-KEM and ML-DSA, and you want to use it for hashing, draw 32 bytes from SHAKE128. BLAKE2 is a weird choice these days. BLAKE3 is very fast, but complex and uncommon enough that you want to have a good reason to use it and a good reason to trust your implementation. Multihashing is when you need to hash multiple things at once. For example, multiple columns of a row, or a filename along with the file contents. It’s subtler than it sounds, so use SequenceHash with SHA-2, or TupleHash (SP 800-185) with SHA-3. Avoid : SHA-1, MD5, all other hash functions, rolling your own multihash , truncating to less than 256 bits unless a cryptographer told you collisions are not a concern. 32 random bytes , a.k.a. 256-bit random numbers. Percival, 2009 : 256-bit random numbers. Ptacek, 2015 : 256-bit random numbers. Latacora, 2018 : 256-bit random numbers. Latacora, 2024 : 256-bit random numbers. Note that UUIDv4 and APIs like rand.Text will give you ~128-bit values instead. That’s usually fine, unless you need more than 280 trillion (2⁴⁸) unique values. 256 bits is overkill, but why not. Use your operating system’s CSPRNG: on Linux, on Windows, on macOS and BSD, on the web. A good library will wrap these for you. Avoid : non-cryptographic PRNGs, userspace random number generators like OpenSSL’s, haveged, CPU jitter entropy. 7 Argon2id or scrypt . But have you considered passkeys instead? Percival, 2009 : scrypt or PBKDF2. Ptacek, 2015 : scrypt, then bcrypt, then PBKDF2 if you must. Latacora, 2018 : scrypt, then Argon2, then bcrypt, then PBKDF2 if you must. Latacora, 2024 : Argon2id, then scrypt, then bcrypt, then PBKDF2 if you must. Everything my predecessors said is still spot on: “you can throw a dart at a wall to pick one of these.” I dropped bcrypt because the 72-byte truncation bites . One tip: you could argue to your auditors that scrypt is FIPS 140 compliant because its actual KDF step is PBKDF2, which is what they want. I heard it works. For parameters, ignore RFC 9106 which apparently wants to murder your production system, and use for Argon2id (libsodium’s INTERACTIVE preset) or for scrypt. They use the same memory, but the scrypt ones will take ~1.5× the CPU time and ¼ the GPU time, which is why you should prefer Argon2id. If you’re doing non-interactive key derivation, feel free to crank up Argon2id’s m , and then t (or just N 8 for scrypt) as far as tolerable. Much more importantly, do you really need passwords? If you only need them for logins, could you use phishing-proof passkeys instead? Maybe with email magic links as a fallback ? Or just support SSO (as long as it’s OIDC and not SAML)? (Note that in 2026, passkeys are still not PQ. That’s mostly fine. They’re still phishing-proof and credential-stuffing-proof. All a quantum attacker can do is log in to a specific site if they obtain its database or intercept a login.) Avoid : regular hashes, spending time thinking about password hashes instead of focusing on passkeys, phishable variants of email-based logins. 9 X25519+ML-KEM-768 in a secure, standard protocol. Percival, 2009 : 2048-bit Group #14 with a generator of 2. Ptacek, 2015 : Probably still DH-2048, or NaCl. Latacora, 2018 : Probably nothing. Or Curve25519. Latacora, 2024 : X25519+ML-KEM-768. The 2018 version of the Latacora answers is correct in asking “why are you as a developer thinking about this, and what does it even mean?!” Don’t roll your own key exchange protocol. Just pick X25519MLKEM768 in TLS, mlkem768x25519-sha256 in SSH, and/or MLKEM768-X25519 in HPKE and age. If you really need to obtain some keys and you know what you’re doing, both TLS and HPKE have “exporters” to derive them. Non-PQ solutions are an active, urgent liability due to harvest-now-decrypt-later attacks, and PQ alternatives range from impractical (HQC-KEM, FrodoKEM) to impractical and broken ( Classic McEliece ). For key exchange, using an ECC hybrid is cheap enough to be worth it, and indeed ML-KEM is usually deployed alongside ECDH. However, if picking or implementing the hybrid is going to make you stay even a single month longer on a classical, non-PQ key exchange, go straight to pure ML-KEM-768 now. P-256+ML-KEM-768 is also fine, just less common. NIST P curves got a bad rep for many years, but I think it’s 90% because they came out before we knew how to do cryptography engineering particularly well (and because of… let’s say non- NPOV proselytizing). Almost none of the attacks against ECDH on NIST P curves even matter for ephemeral key exchanges. Avoid : anything and everything without ML-KEM or X-Wing (a specific X25519+ML-KEM-768 combiner) in its name. HPKE with (MLKEM768-X25519 a.k.a. X-Wing, HKDF-SHA256, AES-128-GCM) , or age with PQ keys . Percival, 2009 : RSAES-OAEP with SHA-256, MGF1+SHA-256, and exponent 65537. Ptacek, 2015 : NaCl. Latacora, 2018 : NaCl/libsodium (box / crypto_box). Latacora, 2024 : Tricky, wait for established PQ solutions. This is one of the most improved answers in recent years, as we now have a standard to do hybrid encryption: HPKE. (“Hybrid” as in asymmetric + symmetric, not as in classical + post-quantum, yay for overloaded names.) MLKEM768-P256 is also ok as a choice of KEM, but less available. Pure ML-KEM-768 is fine if it’s all you have. All KDF and AEAD choices are fine, as well. If your message is large, do the same chunking Cobblestone does, with multiple sequential HPKE Seal/Open calls in place of the AEAD. Note that the PQ HPKE KEMs are maddeningly still a draft. 10 Worse, they are unnecessarily spread over four drafts, such that I had to write filippo.io/hpke-pq . However, I made the authors solemnly swear they will not change the wire format, and then we all shipped them in our cryptographic libraries, so you can and should use them. If you need something higher-level, with multiple recipients or a CLI, use age 11 with its new -prefixed keys, based on MLKEM768-X25519. PQ support is also rolling out for plugins like the YubiKey and the Secure Enclave ones. Avoid : again anything not based on ML-KEM (RSA and NaCl’s box 12 in particular), OpenPGP, encrypting emails . ML-DSA-44 . Percival, 2009 : RSASSA-PSS with SHA-256, MGF1+SHA-256, and exponent 65537. Ptacek, 2015 : NaCl, Ed25519, or RFC 6979. Latacora, 2018 : NaCl or Ed25519. Latacora, 2024 : Ed25519 if short-lived, otherwise Ed25519+ML-DSA-65. In January 2026, I would have told you to wait and see, and then everything started moving a lot faster . You need to move to PQ for auth, too. Just use ML-DSA-44. If your message needs pre-hashing, use “external-μ (Mu)” signing, not HashML-DSA. Store keys as 32-byte seeds exclusively. The two PQ alternatives are FN-DSA, which is stuck somewhere in the US Department of Commerce, and SLH-DSA, which is based on hash functions. The latter can make sense for some specialist use cases, but is slow, large, and not widely available in implementations. Why not a hybrid or “composites”? Hybrid auth is more complex and less useful than hybrid key exchange. I need to write a long-form post about this, but in short either you already have a key rotation strategy that you can use to rotate back to RSA/ECDSA/Ed25519 in the unlikely case of a cryptanalytic break, or you have such a long time horizon that you should assume quantum computers will break the classical half anyway. Also, ML-DSA is just such a better signature algorithm than its predecessors. It has hedged randomness (which is better than both random and deterministic nonces), built-in context, extra properties like SUF-CMA, and is relatively simple to implement and test. Why the -44 parameter set, when I’m recommending ML-KEM-768? Unlike ML-KEM-512 (NIST category 1), ML-DSA-44 sits at NIST category 2, with some margin above the 128-bit security level already. The one exception to the no-composites rule is SSH keys. OpenSSH 10.6 picked . I think they made a mistake , but it would be a mistake for you to go off the beaten path there. Note that asymmetric signatures are overused, and especially now that they are not cheap, you should try to avoid them. Consider whether you could use a simple HMAC. Maybe you can replace signed JWTs with random session tokens. If instead you do want to commit to something publicly, consider whether you should use a transparency log , and get a more practically useful notion of non-repudiability (and potentially smaller sizes, if you amortize the signature over multiple proofs). Avoid : all non-PQ options like RSA/ECDSA/Ed25519, stateful signatures (LMS/XMSS), HashML-DSA, ML-DSA “ semi-expanded keys ,” composite signatures, OpenPGP. If you’re in the cloud, use its ALB/NLB . Otherwise, rustls , Go’s crypto/tls , BoringSSL , AWS-LC , or s2n-tls with a popular, updated ACME client . Percival, 2009 : OpenSSL for websites, custom RSA protocol for applications. Ptacek, 2015 : OpenSSL, or BoringSSL if you can. Or just AWS ELBs. Latacora, 2018 : AWS ALB/ELB or OpenSSL, with Let’s Encrypt. Latacora, 2024 : AWS ALB/ELB or OpenSSL, with Let’s Encrypt. Not recommending OpenSSL will probably be my least practical suggestion, but OpenSSL has been making consistently puzzling and problematic choices for years now. The pyca/cryptography authors, in a long post about OpenSSL’s issues , summarize it as: Our experience with OpenSSL has been on a negative trajectory for several years. They even presented at the 2025 OpenSSL Conference (!?), apparently fruitlessly. They’re not alone in criticizing OpenSSL: here are HAProxy from the server side, and curl from the client side. I recommend rustls or Go’s crypto/tls, 13 which are memory safe. If you need to stick to C, both BoringSSL and AWS-LC are very competently maintained OpenSSL forks, and the latter, unlike BoringSSL, has versioned releases. s2n-tls and rustls-ffi are interesting options if you don’t need libssl compatibility. Whatever you use, make sure it prioritizes the X25519MLKEM768 key exchange, which requires TLS 1.3. Your cloud load balancer might require you to select a PQ security policy. On RHEL 9 and derivatives, you need to opt in . Previous lists split websites and client-server applications. I merged them because these days you’ll just use the same TLS stack for both. What changes is the PKI. For applications where you control both ends, consider making your own ML-DSA-44 X.509 certificates, self-signed or from a private root. For websites, wire up a popular , well-supported ACME client and keep it up to date. Merkle Tree Certificates will roll out in 2027, and you want to be well positioned to be included in the transition. Avoid : rolling your own transport protocol, TLS libraries that failed at their core job before (GnuTLS 14 , Mbed TLS 15   16   17 , wolfSSL 18   19   20   21   22   23   24   25 , Botan 26   27 ), offbeat TLS libraries, OpenSSL before version 3.5 (the first with X25519MLKEM768), any OpenSSL if you can, TLS 1.0–1.1, TLS 1.2 if you can, rolling your own ACME client. restic with a long random password. Percival, 2009 : Tarsnap. Ptacek, 2015 : Tarsnap. Latacora, 2018 : Tarsnap. Latacora, 2024 : Tarsnap. I checked restic’s encryption a very long time ago and it was slightly weird but good. Fully symmetric, so it’s not scared of quantum computers. Doesn’t look like anything changed since. If you need cloud storage, rsync.net has a “secret” plan for restic users , and they support an append-only mode . We all love Tarsnap, and I feel like I am betraying my elders right now… but its asymmetric cryptography layer is all RSA. Technically , I think the only things that truly break are the lower-capability keys: a write-only key can decrypt archives and a read-only key can forge archives. Server authentication is HMAC, and the RSA public keys are never sent to the server. In that sense, Tarsnap is as secure as restic, which is an impressive feat for a 2008 design. However, this feels like a major footgun with impending quantum computers. Kinda incredible that you can just walk up to this thing. Follow me on Bluesky at @filippo.abyssdomain.expert or on Mastodon at @[email protected] . FIPS 140 still has some very ugly bits around module validation (runtime self-tests, the integrity check, and the worsening RNG mess) but the actual cryptography algorithms these days are “good, actually.”  ↩ You might read that GCM can take 128-bit nonces, but when you do that it doesn’t have a separate CTR counter anymore, and messages with different GCM nonces can end up sharing CTR keystream, which makes the analysis more complicated.  ↩ Full disclosure: XAES-256-GCM is my design, but there are now multiple third-party implementations, including as part of Google’s Tink libraries, there’s a security analysis by Amazon Web Services , and it’s used by Microsoft in an official SDK .  ↩ Again, full disclosure, this is my design, but it’s adopted externally enough that I feel comfortable recommending it.  ↩ On 64-bit machines, SHA-512/256 and SHA-512 should be faster than SHA-256 thanks to their larger block size, except right now hardware support for SHA-256 is more widespread, so SHA-256 is faster in practice. They are both fast, so you probably don’t care.  ↩ Partially because of choices NIST made to make SHA3-256 strictly as secure as SHA-256, even where SHA-256 was overkill. SHA-256 and SHA3-256 have 128 bits of collision resistance and 256 bits of second preimage resistance. It’d be enough to have 128 bits of both, which is what you get from 256 bits of SHAKE128 output. Unfortunately, it doesn’t have a SHA name. TurboSHAKE128 (RFC 9861) would be even better, but it is neither FIPS 140 nor widespread.  ↩ People outside the FIPS 140 circle of hell don’t seem to have noticed, but new NIST rules require FIPS 140 modules to have an “internal” entropy source now. If your FIPS 140 “module” is a piece of software… what do you do? Everyone’s answer is “CPU jitter”: run a tight loop that accesses uncached memory and take high-resolution timestamps, hoping the frequency mismatch between CPU and main memory will make the measurements unpredictable. IMHO, this is the worst part of FIPS 140 since the module integrity hash, and it will hurt people. The one sane way to do it is to go through the motions, and then at the last moment mix the result with kernel entropy, like Go does. However, that’s harder to justify to the validation labs than doing just CPU jitter, so I’m afraid we’ll see pure CPU jitter in production a lot.  ↩ Argon2id parameters mostly do what you’d imagine, scrypt parameters do not .  ↩ A link with an opaque random token that logs in the opening browser is very hard to phish. A link that logs in the browser that started the login is very easy to phish. Short numeric codes are moderately easy to phish. (You can get the best of both worlds by using the Broadcast Channel API to pass the token from the new tab to the original one.)  ↩ In a different venue I said a serious engineering community would have a post-mortem about having wasted two of the five years we had for the transition to post-quantum cryptography, only to produce specs that changed nothing compared to what we had in 2024. But that’s for another time.  ↩ Again again, full disclosure: my design, but pretty uncontroversial pick.  ↩ I truly don’t understand why my predecessors recommended NaCl’s box. It’s an incredibly sharp and low-level API , pretending to be a high-level safe API.  ↩ One last time, full disclosure: I am a crypto/tls maintainer.  ↩ CVE-2020-13777 , GnuTLS 3.6.4–3.6.13: the session ticket key is all zeroes, breaking confidentiality in TLS 1.2 and allowing authentication bypass in TLS 1.3.  ↩ CVE-2023-45199 , Mbed TLS 3.2–3.4: pre-auth remote code execution via an oversized key share in the TLS 1.3 handshake.  ↩ CVE-2026-34873 , Mbed TLS 3.5.0–4.0.0: TLS 1.3 session resumption allows client impersonation.  ↩ CVE-2026-25835 , Mbed TLS 2.18.0–3.6.5 and 4.0.0: the RNG state is cloned across fork, so parent and child produce the same random values.  ↩ CVE-2026-5194 , wolfSSL 3.12.0–5.9.0: signatures by ECDSA certificates can be forged by providing an ML-DSA or Ed25519 signature algorithm identifier and a trivial ECDSA signature over the zero-length digest.  ↩ CVE-2026-5501 , wolfSSL through 5.9.0: the OpenSSL compatibility layer skips the leaf’s signature check when any valid end-entity cert is supplied as an “intermediate.”  ↩ CVE-2026-11310 , wolfSSL 5.8.4–5.9.1: the OpenSSL compatibility layer lets caller-supplied untrusted intermediates act as trust anchors.  ↩ CVE-2026-11999 , wolfSSL 5.7.4–5.9.1: the OpenSSL compatibility layer accepts any chain longer than the maximum path depth of 100, without reaching a trust anchor.  ↩ CVE-2026-55960 , wolfSSL 5.6.4–5.9.1: a raw public key is always accepted in place of a certificate, bypassing authentication.  ↩ CVE-2026-93302 , wolfSSL 5.3.0–5.9.2: trusted-peer certificates are matched by name rather than public key.  ↩ CVE-2026-89136 , wolfSSL 5.6.0–5.9.2: clients still accept an unsolicited raw public key from the server, bypassing authentication.  ↩ CVE-2026-89135 , wolfSSL 5.8.4–5.9.2: a failed chain verification in the OpenSSL compatibility layer permanently adds the attacker’s CA to the trust store used by native TLS, OCSP, and CRL checks.  ↩ CVE-2026-34580 , Botan 3.11.0: an end-entity certificate whose DN matches a trusted root is accepted as if it were that root.  ↩ CVE-2026-34582 , Botan before 3.11.1: TLS 1.3 servers process application data before Finished, so a client can skip Certificate, CertificateVerify, and Finished entirely, a full client authentication bypass.  ↩ Encrypting data : If you’re in the cloud, KMS. Otherwise, XAES-256-GCM or XChaCha20Poly1305 with random nonces. If you’re encrypting more than a few MB, use Cobblestone-128. Symmetric key length : Don’t stress it. 256-bit keys if you need to pick. Symmetric “signatures” : HMAC-SHA-256. Hash algorithm : SHA-2 is still fine, but use a real multihash (SequenceHash) if needed. Random IDs : 32 random bytes, a.k.a. 256-bit random numbers. Password handling : Argon2id or scrypt. But have you considered passkeys instead? Key exchange : X25519+ML-KEM-768 in a secure, standard protocol. Asymmetric encryption : HPKE with (MLKEM768-X25519 a.k.a. X-Wing, HKDF-SHA256, AES-128-GCM), or age with PQ keys. Asymmetric signatures : ML-DSA-44. Website or client-server application security : If you’re in the cloud, use its ALB/NLB. Otherwise, rustls, Go’s crypto/tls, BoringSSL, AWS-LC, or s2n-tls with a popular, updated ACME client. Online backups : restic with a long random password. FIPS 140 still has some very ugly bits around module validation (runtime self-tests, the integrity check, and the worsening RNG mess) but the actual cryptography algorithms these days are “good, actually.”  ↩ You might read that GCM can take 128-bit nonces, but when you do that it doesn’t have a separate CTR counter anymore, and messages with different GCM nonces can end up sharing CTR keystream, which makes the analysis more complicated.  ↩ Full disclosure: XAES-256-GCM is my design, but there are now multiple third-party implementations, including as part of Google’s Tink libraries, there’s a security analysis by Amazon Web Services , and it’s used by Microsoft in an official SDK .  ↩ Again, full disclosure, this is my design, but it’s adopted externally enough that I feel comfortable recommending it.  ↩ On 64-bit machines, SHA-512/256 and SHA-512 should be faster than SHA-256 thanks to their larger block size, except right now hardware support for SHA-256 is more widespread, so SHA-256 is faster in practice. They are both fast, so you probably don’t care.  ↩ Partially because of choices NIST made to make SHA3-256 strictly as secure as SHA-256, even where SHA-256 was overkill. SHA-256 and SHA3-256 have 128 bits of collision resistance and 256 bits of second preimage resistance. It’d be enough to have 128 bits of both, which is what you get from 256 bits of SHAKE128 output. Unfortunately, it doesn’t have a SHA name. TurboSHAKE128 (RFC 9861) would be even better, but it is neither FIPS 140 nor widespread.  ↩ People outside the FIPS 140 circle of hell don’t seem to have noticed, but new NIST rules require FIPS 140 modules to have an “internal” entropy source now. If your FIPS 140 “module” is a piece of software… what do you do? Everyone’s answer is “CPU jitter”: run a tight loop that accesses uncached memory and take high-resolution timestamps, hoping the frequency mismatch between CPU and main memory will make the measurements unpredictable. IMHO, this is the worst part of FIPS 140 since the module integrity hash, and it will hurt people. The one sane way to do it is to go through the motions, and then at the last moment mix the result with kernel entropy, like Go does. However, that’s harder to justify to the validation labs than doing just CPU jitter, so I’m afraid we’ll see pure CPU jitter in production a lot.  ↩ Argon2id parameters mostly do what you’d imagine, scrypt parameters do not .  ↩ A link with an opaque random token that logs in the opening browser is very hard to phish. A link that logs in the browser that started the login is very easy to phish. Short numeric codes are moderately easy to phish. (You can get the best of both worlds by using the Broadcast Channel API to pass the token from the new tab to the original one.)  ↩ In a different venue I said a serious engineering community would have a post-mortem about having wasted two of the five years we had for the transition to post-quantum cryptography, only to produce specs that changed nothing compared to what we had in 2024. But that’s for another time.  ↩ Again again, full disclosure: my design, but pretty uncontroversial pick.  ↩ I truly don’t understand why my predecessors recommended NaCl’s box. It’s an incredibly sharp and low-level API , pretending to be a high-level safe API.  ↩ One last time, full disclosure: I am a crypto/tls maintainer.  ↩ CVE-2020-13777 , GnuTLS 3.6.4–3.6.13: the session ticket key is all zeroes, breaking confidentiality in TLS 1.2 and allowing authentication bypass in TLS 1.3.  ↩ CVE-2023-45199 , Mbed TLS 3.2–3.4: pre-auth remote code execution via an oversized key share in the TLS 1.3 handshake.  ↩ CVE-2026-34873 , Mbed TLS 3.5.0–4.0.0: TLS 1.3 session resumption allows client impersonation.  ↩ CVE-2026-25835 , Mbed TLS 2.18.0–3.6.5 and 4.0.0: the RNG state is cloned across fork, so parent and child produce the same random values.  ↩ CVE-2026-5194 , wolfSSL 3.12.0–5.9.0: signatures by ECDSA certificates can be forged by providing an ML-DSA or Ed25519 signature algorithm identifier and a trivial ECDSA signature over the zero-length digest.  ↩ CVE-2026-5501 , wolfSSL through 5.9.0: the OpenSSL compatibility layer skips the leaf’s signature check when any valid end-entity cert is supplied as an “intermediate.”  ↩ CVE-2026-11310 , wolfSSL 5.8.4–5.9.1: the OpenSSL compatibility layer lets caller-supplied untrusted intermediates act as trust anchors.  ↩ CVE-2026-11999 , wolfSSL 5.7.4–5.9.1: the OpenSSL compatibility layer accepts any chain longer than the maximum path depth of 100, without reaching a trust anchor.  ↩ CVE-2026-55960 , wolfSSL 5.6.4–5.9.1: a raw public key is always accepted in place of a certificate, bypassing authentication.  ↩ CVE-2026-93302 , wolfSSL 5.3.0–5.9.2: trusted-peer certificates are matched by name rather than public key.  ↩ CVE-2026-89136 , wolfSSL 5.6.0–5.9.2: clients still accept an unsolicited raw public key from the server, bypassing authentication.  ↩ CVE-2026-89135 , wolfSSL 5.8.4–5.9.2: a failed chain verification in the OpenSSL compatibility layer permanently adds the attacker’s CA to the trust store used by native TLS, OCSP, and CRL checks.  ↩ CVE-2026-34580 , Botan 3.11.0: an end-entity certificate whose DN matches a trusted root is accepted as if it were that root.  ↩ CVE-2026-34582 , Botan before 3.11.1: TLS 1.3 servers process application data before Finished, so a client can skip Certificate, CertificateVerify, and Finished entirely, a full client authentication bypass.  ↩

0 views
Brain Baking 6 days ago

Laptop Stickers: Yay Or Nay?

Here’s an interesting dilemma for you: do you place stickers onto the back of your laptop? Are you a laptop sticker kind of person or afraid of anything glossy that might leave glue marks? I used to belong to the latter group, but after more than six years of typing on this M1 Air , I was ready to give in. The reason for this is simple. Sebastián sent me a couple of Emacs-related stickers, knowing I couldn’t resist. It’s all his fault. Look at what he did! A not so pristine golden coloured MacBook Air shining in the sun, desecrated by four stickers. (And yes, I am well aware of the irony of pasting a Free Software Foundation sticker on a MacBook that runs on a proprietary OS. I promise my next hardware choice will be more ethically sound.) Of course I can’t stop there: what to do with all that open space in-between the stickers? I could have placed them all at one side to make it even more painfully obvious that the beautiful symmetry is completely gone now. As for the question of where to stick them: I already think far too much and was too happy with the stickers to give this much thought. They’re on there. The Apple logo is still visible: a Brain Baking logo sticker covering that up might be the logical next step to take. Every time I receive stickers at conventions, I hesitate: do I stick these onto the laptop? Wait, what do they represent? Do I really want to proudly display the Octat GitHub mascot to promote GitHub as “the only way to do Git”? Nope. Do I really want to paste a Java mug or Gopher on there, identifying myself with these programming languages that aren’t bad but don’t get my engine running? How about a colourful JetBrains IntelliJ one that can serve as a free ad? Nope, nope, nope. It was easy to be in the (tech) sticker hater group. And then I finally found something that was fun to mess around with. So yes, I am now a happy Lisp and Emacs advocate at work. The stickers already provoked many Emacs versus Vim discussions that end once you show them that Emacs can be a window manager running your other apps, or that EVIL mode exists. Still, 99% of the die-hard coder colleagues—which are less than 10%, the others of course rely on VS Code—are Vim users. That’s great! But one wasn’t and told me he yesterday closed his Emacs window for the first time in weeks. We both had to move classes but I’ve made a mental note to meet up and exchange Lispy configs. Thank you Seb & Seb’s stickers. Many of my students like stickers on their laptops. Most just stick on there whatever they can get their hands on. I’ve seen a lot of Gophers but nowhere in our curriculum we explore Go and after approaching them, I learn that they don’t even know how to write a line of Go code. A Gopher is cool, that’s what matters! I do think that you should at least know what you’re implicitly preaching. Which is why I’m not limiting myself by sticking only tech-related stuff on there. The question then becomes: what else? Something retro game-related, obviously. But still: what? A classic Castlevania logo? I’d rather go for a custom-printed sticker with some obscure sprite work, from for instance Wario Land 3 or Gobliins 2 . If someone is able to recognise all these, I’ll finally have found my missing soulmate. Maybe a pixelated version of bread or a fountain pen? Or am I giving away too much information that way? Also, since the laptop turns seven in January, I should keep copies as the stickers are not transferable. Another approach would be to cover the entire back, making use of every square millimetre. A few colleague design lecturers carry laptops like this. I don’t think I’m cool enough for that. Terence caught a nasty laptop sticker paralysis while trying to perfectly place his, so a bit of thoughtful preparation might be in order. I love Abigail’s Kirby sticker on her Thinkpad. She even scanned her laptop with a photocopier and converted the entire thing into a clickable polygon-based SVG. Much cooler than my lazy cellphone snapshot. I’m also jealous of her Cory Wong pizza slice sticker! I do wonder what she’ll do when her laptop breaks down or it’s time for a replacement. Nia’s laptop stickers (a November 2022 snapshot) also contains some cool ones, as does Manton Reece’s , such as bookstore stickers. Here’s a discussion on the Framework forum containing more inspiration. It is clear that I still have some work to do. The four stickers are only the beginning. Laptop Stickers: Yay Or Nay? Yay. But then: how many? Overlapping or not? Planned placement or not? Tech-related only or not? See, even the most banal decisions in life come with Deep Thoughts ! To be continued with a photo when it’s done. Related topics: / stickers / By Wouter Groeneveld on 9 October 2026.  Reply via email .

0 views
David Bushell 1 weeks ago

Friendship ended with Deno, now Node is my best friend

It’s finally time I go crawling back to Node! I’ve been using Node heavily this month on a SvelteKit client project. When did Node get so good‽ Deno has been my go-to runtime for so long I forgot how to Node. Now I’m back, I find all the ECMAScript † sugar is supported and the old annoying APIs have been replaced or modernised. Most importantly, I never have to see . † Doesn’t seem like that Oracle trademark dispute will see a positive end :( The official Node docs recommend piping an internet script straight to bash (we never learn) to install NVM to manage Node & NPM. My (ancient) experience with NVM and NPM hasn’t been stellar. I heard Fast Node Manager (FNM) was better to switch Node versions. Obviously I roll bleeding-edge but I have client projects that demand stability. I opted for PNPM too to avoid getting immediately pwned. (The “M” in NPM stands for “malware.”) Some scripts I use have hard-coded binary names, so I added two aliases: Maybe that’s a crime but so far it’s worked flawlessly. PNPM also blocks post-install scripts. Does NPM still yolo those? I added additional settings to to delay malware updates. At first I tried setting the minimum release age to “one month” because it takes Microsoft at least that long to remove reported malware. This caused dependency issues where PNPM struggled to match suitable versions. I settled for “one day”; long enough to allow some other sucker to beta test the next Shai‑Hulud. Node can now run TypeScript without throwing a tantrum like a baby if the stars don’t align. That said, one does not simply publish TypeScript packages to NPM. Why? Just strip the types bro, I know you can! Let me sign a deal with the devil! To discourage package authors from publishing packages written in TypeScript, Node.js refuses to handle TypeScript files inside folders under a path. Node.js v26.10.0 documentation This restriction is philosophical rather than technical. I get it though. TypeScript is a Microsoft product. Opening that floodgate would pollute the entire ecosystem. Nobody wants more Microsoft. I’d love to see light “native types” in ECMAScript. There are type annotation proposals . I suspect I’ll be retired before those bear fruit. No TypeScript packages mean I need to find the latest churnware slop to bundle my stuff. Tsdown did the trick, with only two additional dotfiles. Not thrilled about that (every dotfile represents a mistake). I suppose I break even after deleting etc. Speaking of Microsoft lock-in, because they wrecked GitHub I’m self-hosting my own Forgejo instance . NPM limitations mean my packages have lost “provenance”. I had to configure the PNPM trust policy to allow my own stuff. Fun times! My final test for Node was converting my static site generator from Deno. Not many Node versions ago this would have required a major refactor. Today with I found surprisingly little work to do. The only required changes were to replace Deno’s file system API with — which is vastly improved from what I remember (literally ~10 years ago). Aside from that, I had to replace with Hono’s node adapter (a wrapper around ). After this minimum-viable migration I was shocked to see 15% faster builds . My codebase still favours idiomatic Deno. I bet I’m leaving performance on the table by not using other built-in Node APIs. That’s something to explore later. The only further change I made was to replace Deno’s with which is a straight import swap. So if I were to TL;DR in the middle: Node got a glow-up, wow! You’ve probably known this for a while. I kept using Deno out of habit and familiarity. And I haven’t exactly been enthused to write server-side JavaScript recently. I’m burying this part because it’s flogging a dead horse. Ultimately, Deno failed when they allowed the Silicon Valley Circus to define “success”. Deno went from an innovative modern JavaScript runtime to a boring start-up with uncompelling products . Half the employees were laid off and what’s left are tweeting AI fantasies and vibe-coding Temu Cloudflare. There is no reason to use the Deno runtime today. Deno Land Inc. stopped innovating that years ago. Node has slowly but surely caught up, even surpassing Deno in places. What finally pushed me away was: Basically stuff that made it borderline unusable on top of my other criticism. JSR support were very quick to delete my account on request. I don’t like leaving dead profiles around the internet. None of my packages are visible but old versions remain installable. It was fun early on but now it’s time to say goodbye. Thanks for reading! Follow me on Mastodon and Bluesky . Subscribe to my Blog and Notes or Combined feeds. Broken ZSH integration for weeks JSR’s aggressive “429 (Too Many Requests)” Bug(s) that made Deno choke on concurrent HTTP requests

0 views
マリウス 2 weeks ago

Updates 2026/Q3

This post includes personal updates and some open source project updates. もしもし、マリウスです, faxing at 9600 baud directly from Tokyo, Japan . After a short hop over to Hong Kong and Shenzhen , I’m back in the beautiful neighborhood of Shimokitazawa , where I’m spending almost the entire rest of the year until right before Xmas, when I’ll be heading back to Shenzhen. First things first, a warning: This is a very long post as it includes a metric ton of project updates. If you’re interested in the stuff that I’m building then this update is definitely for you. However, let’s start off with some infrastructure-related things. A few major updates happened in the past quarter with regard to my infrastructure. Probably the most important one is the migration from individual VPS instances to my own bare metal running a Proxmox “datacenter” . While I had been relatively satisfied with Vultr in the past, with an ever-increasing number of virtual server instances, for projects like MSG.TAXI , Hyperuplink , tty.fail , and everything related to this very website, my cloud service bill became overly expensive, all while contributions sadly decreased over the past months, making it unsustainable to run cloud infrastructure, especially to that extent. It just so happened that Vultr had more maintenance windows than usual over the past months, which prompted me to finally deal with the task of moving my VPS instances onto my own hardware. By migrating to bare metal, I was able to slash costs by approximately 30%, by discarding the resource safety buffer I had accounted for within each individual VPS, for a more dynamically allocating approach across individual instances. With rising hardware costs, however, one trade-off that I had to make concerns high availability. A full hardware failure on the current setup will result in all uncached services becoming unavailable for as long as the repairs take. Unless prices for hardware go down again, which is unlikely to happen , or contributions pick up again, which in the current market climate is equally unlikely, I won’t be building out the setup any further. As for some general stats about this specific website, over the past six months it generated at its peak over 270GB of traffic per month, which is considerable, given its compact size and its GTmetrix rating of (100% performance, 98% structure, 450ms LCP, 0ms TBT, 0.02 CLS), with the first contentful paint after 344ms, the speed index at 366ms, the time to interactive at 436ms, and an average page size (including images) of a few megabytes at most. Because I stopped running any form of analytics software, due to privacy reasons at first, but ultimately because of the realization that it has become pointless with all the robots LARPing as legitimate human traffic, I cannot tell you the number of visitors this website or any of the related projects have, and frankly I don’t even care. Not because I don’t care about actual humans being interested in the things I’m publishing, but because over the past months I have been receiving an increasing amount of feedback via e-mail, as well as welcoming a surprising number of new members to the community channel , so that I don’t need analytics software to show me that visitors appreciate the work I’m putting in and decide to stick around. Speaking of sticking around: The SimpleX group that existed next to the primary XMPP channel is no more. Neither the platform, nor the group itself developed in a favorable way over the past years, despite its almost 500 members. SimpleX turned from an interesting privacy platform into yet-another-Telegram-type messenger and is on the way to enshittification with VC investments from e.g. ACP, who is an investor in K2 Space (ELINT), OnScreen.ai (tracking), Zorus (employee/network monitoring), as well as Jack Dorsey. To make matters worse, at the end of September Evgeny Poberezkin reached out to me via e-mail with the following request: Thank you very much for this article: https://xn--gckvb8fzb.com/an-overview-of-privacy-focused-decentralized-instant-messengers/ (we shared it here: https://simplex.chat/ <redacted>). We launched equity crowdfunding on Wefunder in August ( https://simplex.chat/ <redacted>), to give users the opportunity to get a stake in SimpleX Chat and benefit from its growth - 150 people have already invested. Please help us spread the word - maybe you could write about it and some other recent news: the foundation, channels, public names, and supporter badges, which we launched recently ( https://simplex.chat/ <redacted>). It would be great to connect - SimpleX link is below - please send any questions! Thank you again and all the best Evgeny P.S. If you write something, could you please share with us before publishing so it complies with Regulation Crowdfunding rules? We cannot promote (and won’t be able to re-share with our community) if it mentions valuation, security type, how much is or left to raise (saying how many people invested is allowed), and how the funds will be used. We can mention perks we provide though. Thank you! – Evgeny Poberezkin SimpleX Chat, Founder Despite my article clearly stating, quote: Red flag: VC funded, specifically by Jack Dorsey , since Aug 14, 2024, hence I do not recommend it any longer. … Evgeny (or, more likely, his team that didn’t properly vet every organic site linking/mentioning SimpleX) sent me this fairly generic looking e-mail, to which I replied by pointing at the red flag . Long story short, the fact that SimpleX has now seemingly become so much of a product in the literal sense that they are trying to get approved organic content out for their funding campaign shows once more that it isn’t a suitable option for the privacy/decentralization community anymore. I have therefore deleted the SimpleX group, as well as removed all mentions of the messenger on this website, treating it the same way as I treat e.g. Telegram, WhatsApp or any other commercial platform. Anyway, a few days ago I shut down the last Vultr instance, an OpenBSD machine that had been serving this website for years. It outlived the rest of my cloud infrastructure by a couple of days. The clearnet version of this website had moved from the instance to a Bunny storage zone with a pull zone in front of it earlier in September, which happened due to the increased traffic by bots and maybe humans , and which means that there is no origin server at all any longer. The Tor and I2P sites still needed a machine of their own, since Bunny doesn’t offer a way to serve either of them, hence both are now served from a virtual machine on my own hardware. That machine has no public IP address, which neither the Tor daemon nor i2pd needs in order to publish a site. Unfortunately it does appear to make the I2P site less reliable, presumably because an I2P router that nobody can connect to depends on other routers to introduce it, and in a measurement about four hours after the move, 4 out of 8 requests to the eepsite succeeded, while all 8 requests to the onion service did. If the eepsite doesn’t load for you, simply try again, since a second attempt usually works. The clearnet side has a trade-off of its own, namely that this website now depends on a provider being there, where the VPS used to be able to serve the entire thing by itself if I ever took the CDN away. What makes me comfortable with it is that the build is still a folder of plain files that any web server can serve, hence going back would require not much more than a server, a workflow step and a few changes in the DNS, but not a rebuild. There’s a dedicated write-up on the whole publishing pipeline in the works, so I’m keeping it at this for now. It’s been almost three months since I got the Lenovo X1 Carbon Gen 14 Aura and switched from the StarBook Mk VI to it , and I haven’t regretted it so far. The laptop performs as well as I had expected it to while offering very decent battery life. At times I’m getting dangerously close to its 32GB RAM limit, which is something I had expected before purchasing it, but most of my workloads still have plenty of breathing room. Shortly after upgrading to the X1 Carbon I also decided to get an external (portable) 19" screen, and so I snapped up the Uperfect GR19BU. So far my experience with the display has been very decent, but I won’t get ahead of myself as I’m going to release a dedicated write-up/review on it soon. However, with several other new additions to my setup, like this display, the Mudi 7 , and the things below , it’ll probably make sense to also start preparing a follow-up on the travel desk setup from earlier this year. As usual with many of the posts on this site, however, it takes between one and three months from the first line to the finished and published post, so don’t count on it until the end of the year or maybe even early next year. After having had a few minor issues with the uni USB-C hub (8-in-1 with USB-C power), and slowly running out of USB ports, I decided that it was time to get a proper Thunderbolt 4 dock for my X1 Carbon , and what option would be better than the official Lenovo ThinkPad Thunderbolt 4 Smart Dock Gen 2 7500, for which I happened to find a pretty sweet deal. As a matter of fact the deal was so good that I didn’t realize at the time of buying that the dock comes with A) its own external 135 Watt power adapter and cannot be powered through a regular USB-C PD charger, which is sort of messing with my minimal yet productive travel desk setup , because I’m already carrying a PSU that weighs over 800g, and B) with cLoUd CoNnEcTiViTy . The dock itself is 590g, its PSU (plus power cord) another 350g, which amounts to 940g total added weight for only the dock. Clearly the dock isn’t meant to be a travel-friendly option, but it seems like no Thunderbolt dock really is considering the (even heavier) alternatives, like the CalDigit TS-4. However, with regular USB-C hubs not providing enough connectivity, performance and, most importantly, stability, it was time to try a different class of devices. Sadly the dock only lasted a single day before it stopped working and had a slight smell of burnt electronics to it. Hence I had almost three weeks of back and forth with Lenovo’s customer service in order to get it RMA’d/replaced, which was a frustrating experience to put it mildly. Then again, I don’t think the lackluster performance of Lenovo in the specific geographic region that I had to deal with them in is a fair representation of Lenovo’s global customer service, but very likely typical customer service across most companies in that region. Anyhow, I eventually received the replacement and I’m finally able to use it. The hardware is fairly decent, I haven’t encountered any odd messages in my log yet, and I finally have enough USB-A/-C ports to connect all sorts of peripherals, like my keyboard, my mouse, the portable monitor , and more. However, I’d wish that these docks would replace their HDMI and DisplayPort connectors with more USB-A/-C ports and maybe even a halfway decent sound card with various audio connectors (3.5mm amongst others). I never needed that many display outputs on a computer, let alone on a portable device. But I guess I’m the exception, as pretty much every dock manufacturer seems to follow this quadruple/sextuple/octuple display-setup trend. The only meh part of the dock seems to be the integrated Ethernet card/port, that appears to be a Realtek RTL8156B ( ), which is a bit of a potato. There are plenty of user reports online that describe all sorts of issues with this specific NIC under Linux. While it does offer 2.5G, matching my new switch perfectly, it does seem to come with a few issues with regard to reliability and actual performance. However, with the amount of USB ports on the dock I can connect one of the many 1G Ethernet adapters that I have and be done with it, in case the integrated NIC should ever give me a hard time. So far, however, it had been working without issues. In an ongoing effort to USB-C-ify all of my hardware I also pulled the trigger on a new portable switch, namely the Ubiquiti Flex Mini 2.5G. I’ve been using the blocky Netgear 1GbE switches forever, but I’ve always struggled to power them, as they come with a barrel connector and a dedicated PSU. While I did at some point get a USB-A-to-barrel-connector cable, judging from the power output of the PSU those switches are not intended to be run off of a USB-A port. It works, but it’s probably not the best idea. Short story long, I went for the Flex Mini primarily because it is powered via USB-C, so that I don’t have to carry another PSU or worry about damaging the device. As an added benefit, the switch now offers 2.5G links, which the Lenovo dock , as well as the Mudi 7 and the Slate 7 can benefit from. Ideally my portable NAS should also benefit from the higher speeds, but sadly that probably won’t happen with the current hardware. Speaking of which … The Ultra-Portable Data Center (v2) has had its second birthday and has held up extremely well, considering all the travel and, at times, incredibly dusty, humid, and/or hot environments that it went through. More importantly, though, even two years later there is still not a single comparable, commercial product on the market that would make me want to replace the existing system with it. Every piece of hardware that I’ve stumbled upon to date appears to come with major hard- or software headaches, be it the UnifyDrive UT2, the Beelink ME Mini, the CWWK x86 P6, or even the significantly larger/heavier AOOSTAR R7. The closest in terms of software freedom, hardware reliability and physical footprint to date appears to be the QNAP TBS-464, which is an ultra-thin and lightweight, portable 4-bay M.2 NVMe NAS introduced back in 2021. Because I had to further reduce the weight and size of the items that I carry with me on my travels, however, at the beginning of September I began working on the next iteration of my ultra-portable NAS, the UPDC v3. I haven’t yet found the time to pour the endeavor into a dedicated write-up, but I am happy to say that I’ve managed to reduce the build’s footprint from initially around 1.48 liters down to as little as 0.68 liters in volume. I won’t spoil too much, yet, but it’s fair to say that I’m relatively happy with the end result and that it allowed me to make the UPDC a fixed part of my carry-on luggage . Not much happened on the keyboard front this quarter, apart from a set of PBS Black on Black that I was gifted, and that has since been on my Kunai . It has become one of my favorite keycap sets and it is pretty much what I was hoping for . The PBS keycaps are however not as rough/textured as I was hoping them to be, which is a bit of a bummer. Anyway, I might post a dedicated long-form review in a few months when I’ll be able to better judge long-term use. I invested some time in pursuing my open source projects in the past quarter, hence there are a few updates to share. Several of them shared the same two things, which were a move to the SEGV-1.1 license , and a vanity import path under my own domain. As some of you might remember, I had an unpleasant encounter last year with one of the Alacritty developers, which prompted me to do something that was long overdue, namely switch to Ghostty. Along the way I implemented the exact feature I initially wanted for Alacritty and made my code public, on a dedicated branch in my fork of Ghostty. Over a year later, I’m still maintaining that patch. If you’re using my patch, make sure to update your local version. Neon Modem Overdrive , my BBS-style command line client, received a whole new backend this quarter. It can now connect to Hyperuplink, the bulletin board of the future, to which I’ll get in just a second . Neon Modem is the first dedicated client implementation for the relatively young and not-yet-well-thought-out REST API. Additionally, a shared prompt package now handles the server URL and credential input for that system as well as for Discourse and Lemmy, instead of each one keeping its own copy. I also tracked down a longstanding source of UI lag. The post-create and post-show windows were doing more work on every keystroke than they needed to, and reworking their handlers and rendering made the interface responsive again. A new release with all of this is available on tty.fail , with binaries on GitHub . Kopi , the command line coffee journal, had its two heaviest dependencies replaced. The SQLite driver moved from the -based to the pure-Go , so Kopi now builds without a C toolchain and ships as a fully static binary on every platform. The OCR helper, which reads the details off a photo of a coffee bag, moved from an Ollama-specific client to the official OpenAI Go SDK , so it can point at any OpenAI-compatible endpoint rather than only an Ollama instance. The Go module was renamed to a vanity import path under my own domain ( ), and the usual dependency, workflow and GoReleaser housekeeping came with it. On top of this, Kopi received the first contribution , which was applied to it via at the end of July. reader , the command line web page reader, had a productive quarter. The biggest change is the removal of the journalist dependency, whose crawling logic is now part of reader’s own package rather than pulled in from a separate module. As I don’t maintain journalist any longer it made sense to pull out the bits that I need for reader. Additionally, reader gained proxy support, so it honors the standard proxy environment variables (#23). It now also correctly handles the URL scheme when deciding whether a source is an HTTP resource (#35), and it got a fix for an -related issue (#37), which I worked out despite the reporter never supplying the example files I asked for. Note: Don’t be that guy . If you open an issue in any piece of software that you don’t pay for and that someone else dedicates their time to free of charge, consider upfront if it’s really something that you’re going to use, and if it’s an issue you’re willing to dive into and help fix. If either of these are a clear nope , then simply don’t report it at all. usbec , the USB Equipment Commander daemon that runs commands when USB devices are plugged in or removed, had its one core dependency reimplemented in-tree and enhanced. The module was dropped and its Linux netlink listener rebuilt inside usbec’s own package, so the daemon no longer depends on an outside module for that. The license moved from GPLv3 to the SEGV-1.1 license , the module was renamed to the vanity import path, and the release workflow and GoReleaser config were fixed along with it. In addition, the USB Equipment Commander is now a USB & Network Equipment Commander, with the introduction of NetworkManager (via D-Bus). This allows you to have usbec run commands whenever the network changes in any of the various ways it can change. My motivation was to run a script that queries my public IP and the inferred location via and shows it as a desktop notification whenever NetworkManager (re-)connects. zpoweralertd , my Zig rewrite and drop-in replacement of poweralertd, which is a UPower-powered power (.. power, POWER, POWERRRRR) alerter, has received a deep and thorough refresh. The biggest change is the C bindings, which I have replaced with handwritten s, just the way I had used them for the first time in ssh-askpass-zigtk , but more on that new tool below. The second big change is the Zig compatibility, which now requires at least 0.16, and which I have tested against the current master/future 0.17 release as well, to make sure that everything will continue working. With this, I also largely refactored the code, for which I initially took inspiration from poweralertd. The restructuring allowed me to clean up the codebase and find a handful of memory-related issues along the way. A new version of zpoweralertd has been released that contains these enhancements and, for the first time, it is available as pre-built binaries directly from the releases page. cexec , my small cached-exec wrapper that runs a command and caches its output for a given amount of time so that re-running it returns the stored output instead of executing it again, was rewritten from Go to Zig this quarter. The new version is a drop-in replacement for the previous one, but it changed a few things in the background, one of which is the storage. Previously cexec used BuntDB, which turned out to be a bad choice for a process that can theoretically run multiple times in parallel and access/write to the same database. The new version, instead, uses plain files that are scoped to the actual commands that are being executed. In addition, the new Zig version supports encryption for the cached runs, with an optional key from or the environment variable, which makes using cexec suitable even for output that contains sensitive data. On top of that, everything cexec does is now also available as a Zig module, so that the same caching can be used from another program without having to shell out to the cexec binary. New this quarter is , a GTK4 helper written in Zig . OpenSSH runs an askpass program to prompt for a passphrase or a yes/no confirmation when it has no controlling terminal. The reason it exists is my own issue with X11, which I explained here . The reference askpass, and most others, link GTK against X11, so they fail to build on my Gentoo system with the global USE flag. does not require X11 dependencies, so it builds and runs on an X11-less GTK4. It works on Linux and (hopefully) on the BSDs alike. Also new this quarter is Switchyard , a lightweight bridge that accepts e-mail over SMTP and forwards each message to XMPP. You might have read about it before in the dedicated post that I had published at the beginning of August, but if you haven’t I recommend going through it if a bridge for SMTP to XMPP sounds like something you could have a use for. For years I had a small shell script in my dotfiles that popped up a bemenu list whenever I clicked a link, so that I could decide which browser it opened in. This quarter I turned that idea into a proper GTK4 application written in Zig, namely Browser Select . It registers itself as a web browser, hence every link another application opens goes to it first, and a small popup then lists the browsers installed on the system for you to pick one with the mouse or the keyboard. Just like the script before, that used the Go version of cexec as a binary to cache the choice for a few seconds, Browser Select also uses the new Zig cexec library for remembering a choice for a while, so that clicking a burst of links doesn’t lead to repeated asking for a browser. The Flipper BUSY Bar review that I posted this quarter came with a little goodie: busybar.zig . It is a Zig 0.16 client library and command line tool for the Flipper BUSY Bar, implementing the device’s current OpenAPI specification as closely as possible, so that you can drive it over its HTTP API from Zig code or straight from the shell. It uses nothing but Zig’s library, which means it builds for every target Zig supports, including macOS and Windows. Darkwing Ducky is a BadUSB (“Rubber Ducky”) firmware for the PicoUSB, an RP2040-based board, built in Zig 0.16 with MicroZig . I’ve struggled to finish this for about ten months or so, primarily because implementing the required USB HID code that acts like an external keyboard wasn’t all that straightforward with MicroZig. Anyway, Darkwing Ducky announces itself to the host as an ordinary keyboard and then types out a predefined sequence, which can be useful for automating the setup of a machine that you have physical access to, or, well, for other things. While the PicoUSB comes with its own CircuitPython-based software, I wanted to try Zig on hardware, especially with MicroZig for a while and so this device was the perfect excuse to do so. If you’re curious, the repository has the details on the payload format and how to flash it. The internet bulletin board software that I had been writing about as ▓▓▓▓▓▓▓▓▓▓▓ in the previous updates has a name, and it’s out. Hyperuplink went public at the end of August, together with a dedicated post that covers what it is, why it exists and how to run it. If a JavaScript-free forum as a single binary sounds like your kind of thing, read that one first and come back here afterwards. Otherwise you might as well skip this part. Most of the quarter went into the bits and pieces that the board needed before anyone other than me could run it. Attachments and profile pictures with local or S3-compatible storage, board-wide settings and an administration UI for all of them, a search, soft-deletion for categories, forums and topics, and a manual that is embedded in the binary and available under Help -> Manual . On top of that came a whole set of themes and color schemes, as well as the packaging, which now covers Docker and Podman images, Quadlets, a Helm chart, a nixpkg with a NixOS module, an ebuild for Gentoo, and service files for systemd, OpenRC and rc.d. And if that wasn’t enough already there is the REST API, which is what Neon Modem connects to. If you’d like to have a look at Hyperuplink yourself without any commitment, there’s a demo instance that you can check out. You can find its login details on the official website . Account creation is disabled on the demo instance (for now). Go compiles down to a single static binary, which is why it is a great choice for Hyperuplink. However it has nothing along the lines of Django, Rails or Phoenix that takes care of the web parts, hence a good share of Hyperuplink was never about the bulletin board at all, but about routing, controllers, models, migrations, background jobs, translations and configuration. At the end of July I moved that share out of the repository and into Go on Glides , a web application framework for Go that includes everything needed to build database-backed web applications, on top of Fiber v3 and pgx , with embedded migrations, an asynchronous job queue, cron jobs, i18n, local and S3-compatible storage, and helpers that each project would otherwise have to reimplement. The reason for the split is that I’m building a second service on the same foundation, namely Maya , which meant that Hyperuplink’s internals had to become something that more than one program can use. Inca is the spiritual successor of addrb and caldr , my two earlier attempts at the same problem, this time as a single command line client for CalDAV and CardDAV that synchronizes calendars, tasks and contacts into a local database for offline use. It is built on top of the “framework” that I built and make use of in zeit , and therefore it shares a lot of zeit’s UI aesthetic. On the other end is Maya , a CalDAV and CardDAV server as a single Go binary with PostgreSQL underneath it, which (sadly) uses a patched hard-fork of go-webdav , and which exists because neither Radicale nor Baïkal ever made me happy. It’s the second program built on Glides . I’ve been dogfooding Maya myself since early September, and as of now it still isn’t publicly available because it’s not yet at a point that I’m comfortable releasing it, in particular due to its hard-fork of go-webdav, which was a requirement because the upstream project is sadly pretty much dead at this point. However, given the absolute mess that DAV protocols are, it’s not even surprising that the maintainer seemingly lost interest in this project, and that the ecosystem is so bad. Anyway, I have both, Inca and Maya, at a point that I can daily-drive them and, more importantly, that other clients (Android, iOS, desktop) can participate without too many hiccups along the way, but I’m not at all happy with the results for reasons that I will get into if I ever choose to release Maya. Also new this quarter, and admittedly not something one might call a modest undertaking , is Netrunner , a web browser built on WebKitGTK and GTK4, written in Zig . I’m calling it the hacker’s browser as it’s meant for people who prefer a lightweight browser over Chromium and Firefox and who care more about configurability and about built-in integrations than about endless features and add-ons. Netrunner deliberately leaves out a number of things that other browsers have, bookmarks being probably the most obvious one, because the people it is built for tend to have a system for those features in place already. Netrunner is configured through a single TOML file that holds the static settings as well as the runtime state, e.g. which sites are allowed to run JavaScript, play audio or send notifications. The session (i.e. open windows and their tabs) is also persisted in that one TOML file when the browser quits, and restored on the next start. Two other features that Netrunner comes with built-in are Onion addresses and Eepsites, which are native protocols for the browser. By default, whenever you open an URL or an link Netrunner opens a connection via Tor or i2pd and keeps it alive for as long as you browse sites within the Onion/I2P network. As soon as your browsing activity goes back to the clearnet, Netrunner automatically disconnects from Tor/I2P again. To the user these connects/disconnects happen completely transparently and every network gets a of its own, so a tab that follows a link onto another network moves over and keeps its back and forward history. In plain English this means that and URLs are indistinguishable from any other URL and you don’t need to do anything special to be able to open them. Obviously Netrunner is not a replacement for the official Tor Browser that the Tor Project releases and that is somewhat hardened to make sure you stay as anonymous and safe as possible. Instead, Netrunner’s Tor and I2P integrations are intended for the casual user who enjoys the freedom of browsing those networks as if they were part of the “normal internet”. Speaking about the normal internet, one thing that can’t be missed, especially these days, is content filtering. While WebKit sadly doesn’t support running a full-blown uBlock Origin, its own content filter engine is nevertheless a relatively decent option for basic ad blocking and annoyance filtering. As a matter of fact, Netrunner uses the same AdBlock and uBlock Origin lists that popular browser extensions use, but it does so by downloading and converting them into the WebKit content blocker format via adblock-webkit-convert.zig , one of two small Zig libraries that came out of this browser experiment. Sadly, the WebKit content blocker format does not support all the trickery that e.g. uBlock is able to do within its browser extension, which means that no, you won’t be able to avoid YouTube ads within Netrunner, at least for now. You will however still score above 62% on AdBlocker test sites , which is fairly okay. The other library that came out of this experiment is osdetect , which does what its name suggests: It detects with some amount of confidence what operating system a program is running on, and it gives you specifics like whether it’s e.g. a Debian or a Gentoo machine, or not even a Linux at all. I won’t tell what my main motivation for building this library was, but if I should ever get to the point that I have a 0.1 version of Netrunner ready for release you will be able to the source code for to find out. Which brings me to the release. Not only to that of Netrunner or Maya, but to the more general question of open source and how I intend to contribute to the ecosystem moving forward. Despite having a landing page online, Netrunner is a fun project that I don’t take too seriously and that I’m currently dogfooding, just like I do with Maya , to find out whether this is a project that I can imagine maintaining long-term, or whether it is one that will eventually outgrow my availability. After two decades of putting the software that I build for myself online, I have come to realize one or two things. The first thing is that “just putting it out there” won’t benefit the software and it certainly won’t benefit me. My main motivation for building all these things is my own curiosity about how stuff works and, ultimately, my own needs. I need a proper CalDAV/CardDAV server that is pleasant to administer/maintain and that can talk to all of the software that I’m using, hence I’m building Maya (and Inca) . I need a proper TUI client for Keebtalk and a few other forums, hence I’m building Neon Modem Overdrive . Speaking of which, I needed a forum and I was sick of the cruft that is phpBB or Discourse, so I built Hyperuplink . And it’s also me who is annoyed by the utterly insane bloat of modern web browsers, which is why I started building Netrunner. Publishing any of them inevitably changes the software from “what I need” to “what I need and what others seem to need” , and there is no guarantee, even if you go the extra mile to implement what others ask for, that those people will stick around or ever start contributing themselves. Zeit is one example where I experienced exactly that, multiple times, and every time it left a bitter taste in my mouth and, what’s worse, hundreds of lines of code that other people had requested but that nobody ended up using long term. At some point Zeit v0.x had become so bloated with other people’s needs that I rewrote it from scratch as the v1 release that it is today, with only the features that I believe make sense to support long term. The second thing that I realized is that “putting it out there” in hopes of finding like-minded people to collaborate with is a pipe dream. The open source “community” has been broken for a long time and, in most cases, was never much of a community to begin with, but rather the developer-equivalent of a one-man band . Not that there are no exceptions, however, my projects usually aren’t those exceptions. Kopi received its first contribution only this quarter, two years after I initially released it, and it was a one-time submission rather than something that materialized into a long-term collaboration. reader , in the very same quarter, collected a handful of issues, one of which I fixed without ever receiving any feedback on it whatsoever. And if you look through the few “Issues” sections on GitHub that I haven’t disabled yet, you’ll see that it’s the same story across the other projects as well, and you might understand why most of the repos lack the “Issues” tab these days. A single patch against two or three dozen issue reports is roughly the ratio that every single one of my projects has had over the past two decades, and I don’t believe that this is because my software is particularly unattractive to contributors, despite it being intentionally niche, but simply because writing a patch for someone else’s program is work, while filing a request is at most a two-minute annoyance. This is also why I, contrary to many other voices in the industry, do not regard opening an issue report as an actual contribution to a project. With all due respect, I don’t want your issue reports, keep them, especially the drive-by ones for something that you’re unlikely to end up using anyway. What a public repository attracts, hence, is an audience rather than a community , and many times that audience has a very short attention span. All of this eventually turns a maintainer into more of an unpaid support desk, which is not what I would like for myself. On top of that, LLMs have taken away the one argument that publishing open source software still had going for it. That argument wasn’t that a stranger would show up and maintain your software for you , but that someone out there might one day need the exact same thing badly enough to extend what you had already built, instead of writing it from scratch all by themselves. In 2026, however, that someone doesn’t need your repository any longer, because they’ll describe what they want to a model and get back something that compiles. Whether what comes out the other end is any good depends on the person directing the LLM and on the effort, or, dare I say, the money they’re willing to throw at it, rather than on the LLM itself. Code itself has become relatively cheap . As a matter of fact, if you want a specific feature implemented in any of my years-old tools, you might as well ask the machine to do it for you, so you can have what you need for the time being. And frankly, I’m quite happy about that! I’m happy that a random passer-by can get the feature that they thought they needed during their euphoric discovery phase of a tool they just found out about, mainly because I also know that the moment the honeymoon phase ends and the person loses interest in the tool, their interest in that feature will vanish along with it, and it would be left up to the maintainer to continue supporting it. Don’t get me wrong, I’m not arguing that collaboration never works, because examples like Linux, curl and PostgreSQL prove the opposite, but I am arguing that for small programs that a single person can hold in their head there are very few reasons for others to actively contribute, and even fewer in a world of automated code generation. Going back to where I started, I’m not sure yet whether I’ll ever publish any of the newer tools and programs that I’m building, like Maya or Netrunner, simply because I don’t see much of a reason to do so. Especially when, for projects like Netrunner, I know of far more popular examples that still died the moment their maintainer stopped investing time into them. And maybe that’s a good thing. Maybe these kinds of projects, just like the ones that I’m publishing, are simply not relevant enough in the grand scheme of things. Maybe it’s just Darwinism at work, and maybe that’s just how things should be after all. There he goes. One of God’s own prototypes. Some kind of high-powered mutant never even considered for mass production. Too weird to live, and too rare to die. – Raoul Duke

0 views
Ahead of AI 2 weeks ago

Language Models for Text Classification: From Bag-of-Words to Jev

The recently released Jev AI model has been quite a cultural phenomenon in technical communities in the past 2 weeks. While Jev aims to classify things, it’s easy to dismiss Jev as “just a classifier,” and my own view of Jev has evolved quite a bit over the past few days. In particular, my thoughts went from “classifiers used to be my bread & butter; I can easily build this myself” (more on this later) to “wow, this actually works better than I thought.” Figure 1: Quick overview of the Jev API; more details on that later. Sure, the latest state-of-the-art GPT and open-weight LLMs can do the same kinds of classification tasks as Jev, while also being capable of much more general decision-making. But Jev’s advantage is that it can handle those classification tasks much faster and more cheaply. At the other end of the spectrum, for a narrow, well-defined problem, Jev probably won’t classify anything better, faster, or cheaper than a special-purpose classifier. But its selling point is that it is far more general than those task-specific models. So, what is the methodology behind Jev (based on an educated guess), what can it do, and why is it so popular? I aim to answer all of these later in this article. However, I thought starting with a brief history of language models for decision-making would be a great way to begin. And it hopefully helps demystify some of the hype and show what Jev does very well (”Jev is essentially a text classifier,” but “Jev is also not ‘just’ a text classifier.”) PS: I am not affiliated with Jev in any way. Also, I am not offered free access to Jev, and this is also not a product endorsement, just a technical article to offer some insights into the history of text classification to help you make sense of the recent hype. Since this is a long article, I recommend reading it in your browser , where you can access the table of contents menu on the left side. For completeness, before we put Jev in context (no pun intended), I thought it made the most sense to start chronologically. In this section, I want to take a brief tour of applied text classification via naive Bayes, logistic regression, and the more classic (deep) neural networks before transformer-based models came along. Back in the day, when I was a grad student 15 years ago, even though recurrent neural networks already existed (more on that later), text classification was usually done with a bag-of-words representation because it was straightforward and could get good results on moderately sized datasets. In short, we can think of the bag-of-words representation as a method that makes free-form text input of different lengths compatible with classic classifiers (naive Bayes, Logistic Regression, SVMs, Random Forest, XGBoost, to name a few), which expect a fixed-size input vector. Popular real-world applications include anything from news article classification to email spam filtering. And yes, allegedly even Gmail’s original spam filter used a Naive Bayes model with a bag-of-words representation. As a side note, I wrote about this approach exactly 12 years ago. It was one of the first things I shared on arXiv. Figure 2: An old tutorial of mine from 2014 that explains naive Bayes classifiers using a bag-of-words model. So, what exactly is this bag-of-words representation? It’s a way to convert free-form texts with different lengths, e.g., Training example 1: “Zentropa is the most original movie I’ve seen in years. If you like unique thrillers that are influenced by film noir, then this is just the right cure for all of those Hollywood summer blockbusters clogging the theaters these days. Von Trier’s follow-ups like Breaking the Waves have gotten more acclaim, but this is really his best work.” Training example 2: “This film is just plain horrible. John Ritter doing pratt falls, 75% of the actors delivering their lines as if they were reading them from cue cards, poor editing, horrible sound mixing” Training example 3: “Zentropa has much in common with The Third Man, another noir-like film set among the rubble of postwar Europe.” into a fixed-size representation for the aforementioned “classic” classifiers. (The example above is an excerpt from the popular IMDb movie review classification dataset .) Figure 3: An illustration of a bag-of-words representation. A bag-of-words model starts by building the vocabulary, which consists of all unique words in the training set (optionally, one can get rid of so-called stopwords like “a” and “the”, which are words that carry little to no semantic meaning in most contexts). A bag-of-words representation results in these fixed-size inputs by assigning each word in a vocabulary its own position in a vector. We then count how often each word occurs in a document. For example, if we have a vocabulary of 50,000 unique words, it produces a fixed-size vector with 50,000 entries, regardless of whether the input consists of only ten words or 300k words. Note that most entries are zero because each document contains only a small subset of the vocabulary. (Instead of representing the raw counts, there are also normalization schemes like TF-IDF.) Then, once we have these word frequency vectors, we can train a classifier on a labeled training set, such as emails labeled as spam or non-spam. For example, a logistic regression model would then learn feature weights that correlate certain words (and word counts) with particular labels. For instance, certain words might increase the predicted spam probability, and others may decrease it. This approach is computationally cheap and can work well when particular words provide strong clues about the label. In a simple classification task such as spam classification, this is often enough to get quick, reasonably accurate results. But one of the biggest downsides of this approach is that, because of the nature of the bag-of-words representation, it loses word order. So, for example, “the dog bites the man” and “the man bites the dog” produce identical vectors despite describing different events. (There are some workarounds to preserve some local order by adding word pairs or longer sequences, called n-grams, as features, although this increases the vocabulary size.) Despite the shortcomings, I still think that a bag-of-words has its place in certain low-stakes applications because it’s so cheap, and a bag-of-words representation + logistic regression remains my go-to baseline for every text classification problem, since it’s so easy to implement. Figure 4: For those interested in building a simple logistic regression classifier, I have a tutorial up here . This model achieves 89.9% accuracy (on a balanced dataset). The aforementioned bag-of-words model would also work with (simple) deep neural networks, like multilayer perceptrons. But the downside still is that we would lose the sentence structure and word order. However, more sophisticated neural network architectures avoid the bag-of-words workaround: convolutional neural networks (CNNs) and recurrent neural networks (RNNs), which can take word embeddings as input. First, before feeding the input texts into a model, we have to convert them into a suitable representation. One such representation is bag-of-words. Another is word embedding vectors. The difference is that a bag-of-words vector represents the entire text by counting how often each vocabulary word occurs, while a word embedding represents an individual word as a dense vector of learned numbers. Figure 5: Illustration of creating word embeddings. Word embeddings work similarly to embedding layers in LLMs, i.e., they convert input tokens into dense vectors. Embeddings can happen outside the model (e.g., two classic, popular methods for learning them are Word2Vec and GloVe ), or the embedding layer can be part of the neural network architecture itself and be learned and tuned during model training. These classic embeddings are context-independent at lookup time. The word “bank”, for example, gets the same vector in “river bank” and “bank account” (as you may know, context can be handled via concepts like attention). For additional resources on embeddings, you may find the following ones helpful: Chapter 2: Working with Text Data (this is an LLM chapter but should give you the gist of embedding words or tokens; in LLM tokenizers, we split words into subword tokens; in Word2Vec, 1 word is usually 1 token.) Understanding the Difference Between Embedding Layers and Linear Layers (this is an illustration that shows when embedding vectors are mathematically equivalent to Linear layers and matrix multiplications.) Since many of you are probably familiar with recurrent neural networks (RNNs), I will keep this section short. RNNs are a classic go-to neural network architecture for natural language processing, and popular variants go back to the 1980s and early 1990s. Transformers, which were introduced in 2017 (and using the attention mechanisms that were first introduced in RNNs; see my Understanding Large Language Models for a brief timeline), then gradually replaced them in many NLP applications. RNNs read a sequence (like text) one word at a time. At each step, they combine the current word embedding (discussed in the previous section) with a hidden state from the previous step. We can think of the hidden state as a fixed-size vector that summarizes the text processed so far, so this makes word order matter, because rearranging the words changes the sequence of state updates. Figure 6: Illustration of an RNN classifier. Note that the figure above shows the RNN in the unrolled representation. I.e., the RNN reuses the same layer stack for each input, hence the term “recurrent”. And since it’s “recurrent”, the input text can have an arbitrary length. The figure below illustrates the “recurrence” with the unrolled representation side by side. Note that both show the identical architecture, it’s just a different visualization. Figure 7: Rolled and unrolled illustration of the same RNN. RNNs were notoriously hard to train, and there are important improvements to RNNs, like Long short-term memory (LSTM) networks, introduced in 1997, and gated recurrent units (GRUs), introduced in 2014, which use learned gates to control how information is retained and updated. (There is also the more recent xLSTM: Extended long short-term memory , introduced in 2024). Also, state-space models are inspired by this idea of a fixed-size hidden state updated sequentially, which is cheaper than transformer attention. However, the bottleneck is still how much information the hidden state can retain, and it still has to be processed sequentially. (Fun fact: attention was first developed for RNNs before the transformer architecture came along, but it’s a story for another time; I’ve written about it in my Understanding Large Language Models article.) The bottom line is that RNNs can be used to train text classifiers. Coming back to the IMDb movie review dataset, the bag-of-words classifier with logistic regression achieved about 89.9% accuracy (on a balanced dataset), while an LSTM RNN achieved only 85.66% accuracy. Yes, RNNs can be harder to train (stay tuned for the ULMFiT method below, which trains an RNN with much higher accuracy). Figure 8: A simple RNN with LSTM tutorial . This model achieves 85.66% accuracy (on a balanced dataset); the higher training accuracy indicates substantial overfitting. Note that this RNN was trained from scratch. A better approach is to pre-train the model on a larger dataset first, then fine-tune it on this target dataset (classically, we call this approach “transfer learning”). In the natural language processing domain, one of the most influential papers proposing this approach is ULMFiT (2018), which achieved an impressive 95.4% test accuracy on IMDb. Figure 9: Annotated figure from the ULMFiT paper. You probably know convolutional neural networks (CNNs) from their use in computer vision. However, it is also possible, although historically less common, to use them for text. Figure 10: Illustration of a convolutional neural network (CNN) to classify images. As shown in the image classification example in the figure above, CNNs apply learned filters to image patches (windows). Similarly, in the natural language domain, we can apply learned filters to windows of adjacent word embeddings. Figure 11: A CNN for text classification, step by step. Only 1 filter (channel) for simplicity. As illustrated in the figure above, a convolutional filter with a window size of three uses the same weights for each adjacent group of three words. Then, as it slides over the inputs, it moves the filter one word at a time across “the movie had surprisingly good acting”. So, this gives four windows (ignoring padding for simplicity): 1: “the movie had” 2: “movie had surprisingly” 3: “had surprisingly good” 4: “surprisingly good acting” So, in the last layer, before the classification head, we can either flatten or global max pool the results before connecting it to the classification head. While flattening preserves all information, it would produce differently sized vectors depending on the text input length. (E.g., if “the movie had surprisingly good acting” were longer, we would have longer feature maps.) So, to make it input-length agnostic, global max-pooling would be a better option here. In short, we can visualize the text CNN as shown below. Figure 12: CNN for text classification with multiple filters (channels). Also, what’s nice is that the filter computations across positions can run in parallel, which avoids the step-by-step dependency of an RNN. For a quick comparison, on the aforementioned IMDb dataset, my experiments show that such a CNN gets about 90.07% accuracy (but note that this is highly architecture-dependent; for example, you may know from computer vision contexts that accuracies can vary widely). (E.g., the good old AlexNet had a ~62.5% top-1 accuracy on ImageNet, and a ConvNeXt V2-H gets 88.9%.) Figure 13: A CNN trained to classify IMDb movie reviews; it gets 90.07% accuracy on this balanced dataset. You can find the source code here . In 2017, the original transformer architecture was introduced in the Attention Is All You Need paper. Since this topic has been covered so extensively (by me and others), I will focus on the classification-relevant aspects. But for those interested in the attention mechanism and other architecture details, please see my related articles: The key point is that the original transformer architecture was an encoder-decoder setup used for language translation, but it can be easily adapted for text classification tasks, as I’ll illustrate in the following sections. Figure 14: The original transformer architecture from Attention Is All You Need . I remember all too well the first years after the original transformer architecture release. They were pretty much defined by the rivalry between two different approaches: Encoder-style models like BERT, primarily developed by Google; Decoder-style models like GPT, primarily developed by OpenAI. Encoder-style models were natural text classifiers, whereas GPT models could do zero- and few-shot classification as an emergent property, but their strength was more in generative tasks. But let’s start with encoder-style models and how to fine-tune and use them to classify text. One universal aspect of using transformers, whether encoder- or decoder-style, is that we work with models pre-trained on large text corpora, and, in addition to using them as zero- or few-shot classifiers, we can fine-tune them on the target dataset (similar to ULMFiT, as mentioned in the RNN section earlier). As shown in the figure excerpt from the BERT paper (2018) below, BERT-/encoder-style models have a classification token at the first position that we can conveniently fine-tune for this. Figure 15: Annotated figure from the original BERT paper . While BERT models are not nearly as popular as autoregressive GPT-style transformers, thankfully some people still update and modernize them occasionally. One recent example that is often my go-to for classification tasks is the 2024 ModernBERT model. As shown below, on the IMDb movie reviews, ModernBERT gets approximately 95% accuracy with very little fine-tuning effort. Figure 16: Different LLMs fine-tuned to classify IMDb movie reviews; you can find the source code here . (I did very little hyperparameter tuning, and this can potentially be improved by 1-2% further; however, one of the points here is also that one can get really good results with pretty minimal effort.) LLMs like GPT are decoder-style, autoregressive transformers that are the center of all attention (no pun intended) for generating text and code. However, as I explained in chapter 6 of my Build A Large Language Model (From Scratch) book, as a gentle introduction to fine-tuning (before covering instruction fine-tuning), we can also repurpose these for text classification. Sure, we can also prompt an LLM directly, as shown in the screenshot below. Figure 17: Prompting an LLM to classify a movie review. However, if we want structured outputs and we have a specific target domain in mind, this is unnecessarily brittle and inefficient. Instead, we can replace the output layer with a leaner classification head, as illustrated below: Figure 18: Swapping the output layer of a GPT-style model with a leaner classification head. Now, fine-tuning has a few caveats. For instance, because of the autoregressive attention mask, we have to be careful about how we design fine-tuning so that the classification token has information about all other tokens in the sequence. Figure 19: Attention masks in BERT and GPT. If you are interested in further technical details, please see my recent end-to-end Building an AI Text Detector From Scratch article: Overall, though, the advantage of using GPT-style LLMs over e.g., BERT variants is that there are so many modern open-weight architectures out there to adopt. Anything between the small Qwen 3 0.6B models to the latest Kimi, GLM, or DeepSeek models. (Of course, using such >1B parameter models for classification could be a bit overkill from an efficiency perspective, but hey, it’s possible.) Figure 20: On the aforementioned IMDb movie review dataset, a relatively small GPT-2 124M model gets approximately 92% accuracy; a larger and newer LLM (like a recent Qwen3 variant) would likely perform better. While the original transformer architecture (an encoder-decoder) was split into two paradigms, encoder-style models like BERT and decoder-style LLMs like GPT, there were also efforts to use encoder-decoder-style variants, with the most recent variant (somewhat unexpectedly) being DeepSeek V4.1 Flash . (However, in this case, it’s a causal encoder not a bidirectional one as in T5.) Keeping the focus on encoder-decoder architectures for classification, probably the most prominent candidate is Google’s 2019 T5 (Text-to-Text Transfer Transformer) . Architecturally, the differences between T5 and the original transformer include updates to the architecture (as summarized below), as well as changes to training. The original transformer was trained to do language translation (in a supervised fashion); T5 uses pre-training on unlabeled text with span corruption, where the encoder receives text with missing spans and the decoder generates those missing spans. Figure 21: T5 architecture changes to the original transformer architecture. (The architecture drawing is taken from Attention Is All You Need .) Now, we can use T5 similar to the other RNN, CNN, and transformer approaches above, by adding a classification head. Additionally, we can also (train to) have the decoder output the class label prediction (like “positive” or “negative” in the IMDb movie review dataset case), similar to regular LLMs. Let’s call this approach text-to-text classification. While GPT-style LLMs are trained on massive amounts of text, they usually perform text-to-text classification well out of the box, as shown earlier (the figure below is inserted here again for convenience). Figure 22: Text-to-text classification with a GPT model. However, in the case of T5, it is common to further fine-tune the decoder to do well on these types of tasks in a given target domain. For T5, both approaches work. Here’s a summary: Figure 23: Classification head vs text-to-text approaches. So far, we have seen that there are plenty of approaches to text classification, from traditional methods like logistic regression and naive Bayes with bag-of-words representations to prompting the latest frontier LLMs like GPT-6 or fine-tuning any open-weight LLM with a classification head (or text-to-text classification). At first, I (almost) dismissed it as “just a classifier,” something that I build routinely for classification tasks with natural language inputs (e.g., see my AI Detector article as a recent public example). Before continuing this discussion, though, let’s start with a quick Jev overview. Jev is a new model released by TypeSafe AI , which just came out of stealth a few weeks ago and got a lot of attention (at first, it seemed a bit bizarre because it looks just like a classifier). Jev is a proprietary model (although the release was followed by a huge number of quick open-source clones, but we will get to this later) that is relatively cheap to use and claims to be on par with GPT-5.6 Luna (for decision-making) while being orders of magnitude faster and cheaper: Figure 24: Benchmark from TypeSafe AI blog . Here, regarding GPT-Luna, think of this as the text-to-text classification approach discussed earlier. So why all this hype? I think it’s partly because of the nice API and that it performs so well on all kinds of tasks, so it doesn’t require custom fine-tuning. For example, I can use it to categorize support tickets/emails (I made a video version to show the response time): Or, I can have the same model play Tetris in real-time (here, I am using the Choice API): Maybe the best way to succinctly explain it (before getting into technical details) is as follows: Like ChatGPT in 2022 was exciting because it was a general-purpose chat model that could generate all kinds of texts, one of the reasons the tech community is excited about Jev is that it is the ChatGPT moment for classification, where it can cheaply classify all kinds of text inputs without having to fine-tune a custom classifier for each task. (As of this writing, there’s not much known about the architecture and exact training algorithm, except that one of the founders said it was trained via “Reinforcement Learning for Calibrated Decisions”, but more on that later.) Over the past few years, we’ve gotten used to throwing bigger, better, and more expensive GPT-style LLMs at all kinds of problems, and for targeted decision-making or classification tasks, a cheap & fast approach like Jev may feel refreshing to most. Especially for one-off tasks where collecting training data and fine-tuning a custom ModernBERT sounds too tedious, we might just throw a Luna-like model at it. But on top of being popular for its versatility (that is, performing well on different target domains out of the box), Jev also has a relatively nice API which we can use via curl in the terminal or via its Python API. In short, there are three main API types illustrated below. Let’s start with the Choice API, which is convenient for multi-class classification. Figure 25: Jev’s Choice API. Use this for multi-class classification. Next is the Noul API, which is simpler and assigns a “yes” probability to a question. Figure 26: Jev’s Noul API. Use this for binary classification or multi-label classification (with multiple Noul questions). Lastly, the Score API assigns a score based on a rubric level (in the example below, 0, 1, 2). Figure 27: Jev’s Score API. Use this for ordinal classification. To sum it up, the different APIs and use cases are as follows. Figure 28: Jev API cheat sheet. To make these APIs more concrete, and to answer the question of how well Jev might do on the aforementioned IMDb dataset, we can run it with either the Choice or Noul API. Let’s start with the Choice API first. Each review would be formatted as follows: This Choice API is convenient if we want to use explicit labels for binary (here) or multi-class problems. An actual response might look as follows: Along with the label, we also get the confidence for this prediction, which can be really useful in real-world applications, as well as the probabilities for each class. (The confidence field summarizes how concentrated the probability distribution is. It is different from the probability assigned to the winning class.) One of the selling points of Jev is that, according to the documentation, these are well-calibrated (but more on calibration later). Alternatively, we can also use the Noul API here to classify the reviews. Using the same movie review classification context, the format would be as follows: And the answer via the Noul API is: (Interestingly, it gives 0.98 instead of 1.0 for the positive class, even though it’s the same text.) We can also apply the Noul API to multiple classes by asking one question per class, for example: “Is this article about finance?” “Is this article about politics?” “Is this article about technology?” Here, each question (independently from each other) returns a probability, and these probabilities don’t have to sum to 1. So, for a multi-class classification where each text can have multiple labels, we may prefer Noul, and for classification problems with only one final answer, Choice adds more convenience. Now, running Jev with either Choice or Noul on the 25,000 movie reviews on the test set gave the following results: Accuracy 96.47% (24,117 correct) Total runtime: 22 minutes 24 seconds 15,456,663 input tokens, $0.6492 total cost Accuracy 96.20% (24,050 correct) Total runtime: 23 minutes 3 seconds 15,106,663 input tokens, $0.6345 total cost Note that the small difference in performance and runtime between Choice and Noul could be due to random fluctuation since, similar to other LLMs, the runs are not fully deterministic. For instance, I ran the Choice API a second time over the same test set and got slightly different results, as shown below. Figure 29: Repeated Jev runs. (The non-determinism when serving LLMs and other models at scale is likely due to batch-dependent GPU kernel execution changing the order of floating-point operations, as laid out in a nice blog post by Horace He last year.) Anyways, the results look quite good overall (caveat: we don’t know if the IMDb test set was part of the training set). For reference, a ModernBERT model took 23 min to fine-tune on the training set; 7 min to evaluate on the test set. For comparison, the best ModernBERT model has similar accuracy, as shown below (I expect you can still get 1-2% higher accuracy with additional hyperparameter tuning). Note that this was run on a DGX Spark, and it’s possible to get faster inference performance with quantization and faster hardware. Figure 30: ModernBERT versus Jev. But our ModernBERT model can’t play Tetris, for example, or do anything else besides movie classification without us fine-tuning it for the new task. But we could then throw a GPT-5.6 Luna or GPT-6 Luna model at it (which is a bit slower and also more expensive). A classic rule of thumb was: Use a cheap LLM (like GPT-6 Luna) for one-off decision-making tasks; Fine-tune a custom classifier if we want to do this task repeatedly. Now, something like Jev could take the place of the above to a) reduce latency and save money over Luna and b) save us the work of fine-tuning a custom model. (Although if we have a very high-volume task and we want to maximize speed and accuracy on a very specific task, it, of course, still makes sense to fine-tune.) Of course, we could also be adding a Jev-like API on top of a (Modern)BERT or any GPT-style model. Adding a Jev-like API is pretty straightforward. In fact, as soon as Jev was released, I built a Jev-like ModernBERT model to show how simple it is to build your own Jev model. I decided not to release my Jev clone because I changed my mind in the meantime. I.e., it’s trivial to put a Jev-like API on top of ModernBERT, and it’s trivial to fine-tune it on a bunch of classification tasks. But it’s not trivial to make this model work well on all different kinds of tasks (like Tetris) without extensive training and testing. (Also, there are already enough quick Jev clones riding on the hype train by now; the world doesn’t need another quick clone, but a strong open-weight version would be nice, of course.) However, if you are interested in how that retrofitting would work, here is a quick overview. For example, we can implement a Jev-like Choice API by adding a small classification head to any BERT-, GPT-, or T5-style model as illustrated earlier. But instead of having the output nodes in this head match the number of classes, we have it with only 1 output node, as illustrated for the modified GPT model below. Figure 31: GPT model where we replace the output layer (which maps to the whole vocabulary) with an output head with only one node. Earlier, we discussed fine-tuning these models for a specific task like movie review classification with a pre-defined number of class labels (here, “positive” and “negative”). With this “1 output node” setup, we can actually extend this to a flexible and arbitrary number of classes. To extend this to an arbitrary number of classes (let’s consider the 3-class case of categorizing a customer ticket into the three categories “billing”, “technical”, “account”), we can do so as shown in the figure below. Figure 32: Using a BERT- or GPT-style model with a Jev-like choice API. As shown in the figure above, for each candidate option, we feed the model the input text, the task instructions, and the candidate’s description. The classification (scoring) head maps the output representation to a single scalar score. We then apply softmax across the candidate scores to obtain a probability distribution and return the highest-probability option. The main point here is that the proposed head has one output per class. Each class description produces its own representation, which the same head scores via the output layer (which is essentially a logistic regression model): s i is the scalar score for class label i ; w is a learnable weight; b is a learnable bias unit; h i is the output of the transformer before the classification head. For N candidates, this produces N scores. Softmax then returns N probabilities. The learned parameters and stay the same when N changes. Note that for h i : BERT-style encoder models use the final hidden state of the token (optionally passed through a pooling layer); GPT-style autoregressive models typically use the final non-padding token’s hidden state, which we need because of the autoregressive nature as discussed earlier (the last non-padding token can attend to the preceding text, instructions, and candidate description). Then, we fine-tune the model and the scoring head jointly using cross-entropy loss against the correct option. Since all candidates share the same scoring head, we can change the number and descriptions of the options without changing the architecture. But again, how well this works on unfamiliar tasks depends on the training data. By the way, why BERT- or GPT-style transformer-based models over simpler RNN and CNN architectures mentioned earlier? From a technical perspective, the approach outlined above works with either. But transformer-based models scale really well, meaning they can be pre-trained on larger datasets and benefit more than other models. Also, they can make good use of the information provided in the context (thanks to attention). So, if we want our model to generalize well to different target tasks without explicit fine-tuning on each one, pre-training a transformer-based model on a high-quality dataset likely gets us closer than RNN- or CNN-based models. So, how come Jev does so well on so many different tasks (from movie reviews to something arbitrary like playing Tetris)? Unfortunately, the architecture, algorithm, and training data details are not public. Also, keep in mind there’s a whole team and multi-million-dollar company behind it that specialized in and worked really hard on this model; we can’t expect to match that level of performance by training a ModernBERT-like model for a week on some open datasets. That being said, if I had to make an educated guess, architecture-wise, I’d guess that they are using something small similar to ModernBERT, hence, the low latency. For the training data, as mentioned before, the TypeSafe AI CEO said the following: 100% of our data is synthetic (but not the type of crap that is just spit out from an LLM obviously) So, yeah, I think most of the effort went into curating this dataset. It’s something I’ve also been preaching to students and collaborators for many years. As a short anecdote, about 8 years ago, I was collaborating with another professor in the social sciences department and helped to design the experimental setup for a text classification problem. Her student spent many days hyperparameter tuning both a bag-of-words baseline and a BERT model to eke out ~2-5% accuracy. Then I suggested we each sit down for a few days to hand-label more data (I think our original dataset was around 300 samples, and we doubled the size), which resulted in a >10-20% accuracy boost. Yes, it’s important to plot learning curves for that purpose :). Figure 33: Rule of thumb showing that often more data helps more than additional hyperparameter tuning. Finally, the training algorithm. Again, the details are not known, but TypeSafe AI’s blog post states that they were using a new algorithm called Reinforcement Learning for Calibrated Decisions (RLCD): “We built a new stack entirely focused on automation: with a new model architecture, parallel sampler for maximum efficiency, and training method we call Reinforcement Learning for Calibrated Decisions (RLCD).” This method is not public. A published method with a similar calibration objective is RLCR (Reinforcement Learning with Calibration Rewards), from the 2025 paper Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty . Before providing more details, the next section gives a brief overview of calibration in general. Calibration is not a new topic and a step I recommend for any production model where you want to use and evaluate the class-membership probabilities. In short, calibration adjusts the model’s probability estimates so they better match observed class frequencies. For example, consider once more our IMDb movie classification problem. A model might assign a review 74% positive and 26% negative. These are probability estimates, but the model may be overconfident or underconfident. We cannot assume that the numerical values are reliable without evaluating calibration. I.e., predictions of 74% positive and 54% positive both yield the class label “positive” at a 50% threshold, although the model expresses more confidence in the 74% case. Figure 34: Illustration of how calibration works. Calibration techniques use a separate labeled dataset, e.g., a held-out validation set, to adjust these probability estimates. After successful calibration, among many reviews assigned a positive probability of approximately 74%, roughly 74% should actually be positive. For more information, I recommend the good old scikit-learn documentation . There are many approaches for this. For example, one that I used in my AI detector project is temperature scaling. Temperature scaling divides the model’s logits by a temperature ( T ) value before applying softmax. We learn T (a positive number) by minimizing the cross-entropy loss on the calibration dataset while keeping the model weights fixed. A temperature above 1 reduces confidence in the highest-probability class, while a temperature between 0 and 1 increases it. We then use this same temperature for new predictions. Because this scaling preserves the ordering of the logits, the predicted class remains unchanged when we select the highest-probability class. Jev’s RLCD training method remains proprietary, but a related idea we can discuss is Reinforcement Learning with Calibration Rewards (RLCR), which was introduced in the 2025 Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty paper. (Note that there is no officially established connection between the two methods, but I am assuming that they could be related.) Reinforcement learning with verifiable rewards (RLVR) typically rewards a correct answer with 1 and an incorrect answer with 0. (For a comprehensive explanation and implementation, I recommend checking out my Build A Reasoning Model (From Scratch) book :)). In short, RLCR adds an additional penalty for inaccurate confidence estimates. So, in the conventional RLVR method, the reward R is either 0 or 1 based on the answer correctness (we are ignoring an optional formatting reward and length penalty here, for simplicity). In RLCR, the model generates reasoning and an answer, which is followed by an uncertainty analysis and a numerical confidence ( q ). This modified reward is where is 1 for a correct answer and 0 otherwise. For example, an incorrect answer with 90% (0.9) confidence receives a reward of -0.81, since At 20% confidence, the reward is -0.04. And a correct answer at 90% confidence earns 0.99. (Readers familiar with evaluating calibrated models may notice that the squared-error term is the Brier penalty for the stated probability that the answer is correct.) Figure 35: RLCR overview. Note that the same (here, Qwen2.5-7B) model generates the uncertainty analysis and q as part of its response. The uncertainty analysis is a written assessment of where its answer could be wrong. After answering, the model examines missing evidence, ambiguous wording, questionable assumptions, or possible reasoning errors. The paper’s prompt asks it to identify specific uncertainties rather than propose corrections. Also, the system prompt explicitly asks for q , a number between 0 and 1 inside tags. It represents the model’s estimated probability that its answer is correct. The system generates all these parts sequentially in the same response. For illustration purposes, the model answer could be as follows: Below is an annotated figure from the paper that illustrates how it improves the expected calibration error over regular RLVR with temperature scaling. On HotpotQA, RLCR reduces expected calibration error (ECE) from 0.37 to 0.03 compared with RLVR, with similar accuracy (62.1% versus 63.0%). Across six other datasets, average ECE falls from 0.46 to 0.21, while accuracy rises from 53.9% to 56.2%. These are the results in the paper’s Table 1(a). Figure 36: RLCR results from the Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty paper. Conceptually, the “Classifier” in “RLVR + Classifier” is a supervised binary classifier that predicts whether the generated answer is correct. For Jev, I could apply calibration rewards directly to typed decisions, without generating reasoning text. For example, we could equip a GPT-style model with the classification head illustrated in Section 2.2 and adapt the RL training objective to reward accurate decisions and calibrated probabilities. We would train the backbone and classification head together. This would be an adaptation inspired by RLCR, and it remains unclear whether Jev’s RLCD works this way. Alternatively, since Direct Policy Optimization (DPO) is a cross-entropy (CE) alternative to Reinforcement Learning with Human Feedback (RLHF), we could also directly minimize CE + Brier loss on the classifier’s probabilities. I did this with the ModernBERT model, and there was a modest improvement over regular temperature scaling. Figure 37: ModernBERT improvements via RLCR-inspired Brier-loss optimization. The ↑ symbol means higher is better. The ↓ symbol means lower is better. 5.3 Side note: Is calibration necessary? Cross-entropy already encourages accurate probability estimates. Under ideal conditions, that is enough to obtain calibrated predictions without an additional penalty. I.e., using a simple coin flip example analogous to the movie review classification, the two classes are “heads” and “tails.” If it’s a biased coin that lands heads 80% of the time (in expectation), the true probabilities are 80% heads and 20% tails. On a representative training set, when we optimize cross-entropy, the model will already learn these as a side-effect of minimizing cross-entropy (negative-log-likelihood). So, in short, if the model learns the true class probabilities, its predictions are calibrated and an additional penalty is unnecessary. (And Brier loss has the same theoretical optimum as cross-entropy, as described in Strictly Proper Scoring Rules, Prediction, and Estimation by Gneiting and Raftery, 2007.) In practice, however, we never train to the real optimum (in expectation) but train on a finite dataset. So, once a neural network correctly classifies most training examples, it can further reduce cross-entropy on the training set by becoming more confident. These confidence estimates may then generalize poorly to the test set and new data. For example, in On Calibration of Modern Neural Networks , Guo et al. (2017) observed “neural networks can overfit to NLL without overfitting to the 0/1 loss.” That means test accuracy can still improve while test cross-entropy worsens. So overfitting can show up in the probability estimates even when classification accuracy continues to improve. Adding Brier loss changes how prediction errors are weighted during training. But whether this improves calibration needs to be checked on held-out data, of course. (The modest improvement in my experiment is an empirical observation for this setup, and we should not assume that adding Brier will always help.) In my experiments (see previous figure), adding the Brier loss to cross-entropy provided only a very small additional calibration benefit. But the motivation is more direct in RL contexts, such as described in RLCR, where we measure answer correctness and don’t already minimize cross-entropy. So, in RL contexts, the Brier term adds an incentive to report accurate confidence alongside the reward for answer correctness. Now that we covered Jev in reasonable detail, who is Jev for? Based on my assessment, it’s a pretty big target audience that intersects between people who a) want to save time by not having to fine-tune a custom classifier for every decision task and b) want to save money over using “the big guns” like GPT-6 and similar LLMs. There are also lots of interesting use cases to think of. In the section below is a short list of ideas. The list of use cases is seemingly endless. Sure, there are flashy examples like having it play video games like Tetris (as I showed in my earlier video above to demonstrate the low latency of the API). However, sorting emails is a more practical choice for most. For example, one could use Jev as an additional spam filter, prioritization, and so on. But beyond things like that, it could also serve as a tool to augment LLM-agent harnesses. Here it could be used as a pre-screener for regular LLM-agent harnesses to scan our contexts for prompt injection; select the reasoning effort level for a model; select a skill.md from your registered skill library; use it as a judge for evaluation or self-refinement; find relevant files for context building. The list is practically endless. Someone even posted a survey on arXiv analyzing 2,170 Jev-related projects that have sprung up recently in just a few days. Since Jev’s launch, 100s, if not 1000s, of quick Jev clones have sprung up. Most of the ones I checked out were just quick examples fine-tuning a ModernBERT or Qwen model with SFT and adding a Jev-like API. Based on what I can tell, none of them achieves the same level of performance as Jev on such a breadth of tasks. Comparing these projects to Jev is like comparing Alpaca (the early instruction-finetuned LLM based off of the original LLaMA open-weight model) to GPT-6. Sure, it may work similarly in spirit, but your mileage will vary on real-world tasks. No offense, but they seem like quick projects to jump on the hype train, and I don’t want to plug a specific one here. One exception here might be the GLiNER project, which has been around for ~3 years ago, and while it’s not fundamentally the same, it can be used for similar things. However, based on a benchmark , Jev is definitely stronger. Figure 38: GLiNER vs Jev comparison via https://github.com/AbdelStark/jev-benchmarks/blob/main/results/reports/btzsc-pilot-v1.md Anyways, there is definitely a genuine need and desire for a good open-weight alternative to Jev. The reason is not necessarily cost (because Jev is so cheap) but privacy. (Also, if Jev is already that fast over the internet, imagine how fast it could be on beefy local hardware.) So, of course I’d like a DeepSeek (or Kimi, or GLM, or MiMo) moment for Jev. I am sure people are working on this, but it will take some time. TypeSafe AI worked on this for many months with a dedicated team of experts. It’s unrealistic to expect to replicate that development and evaluation work in a week. (However, since this is a smaller and more efficient model than a 1-trillion-parameter model, by nature, it hopefully won’t take that long.) Update 1 (29 September, 10:20 am PT): OpenAI just announced the Decision API at their DevDay 2026 conference (29 September). It appears to be a Jev-like model directly integrated into their platform. Decisions API enables real-time decision-making by focusing Luna’s intelligence on a specific set of user-defined questions with finite pre-defined answers. Developers supply context using text or images, and get back answers they can use to classify content, route requests, or choose an agent’s next action. Available in limited preview today with a broad release planned in the coming days. Figure 39: OpenAI’s Jev-like Decision API announced at the OpenAI Dev Day. Update 2: A reader shared the Contrastive Language Models project with me in the comments below. It’s one of the nicer-looking Jev-likes. When I tried it, its IMDb test accuracy is just 82.90% (over 96.47% in Jev), and it fails the Tetris test . Another reader wrote in about Laya , another strong contender. It did better at IMDb (92.33% test accuracy) but then failed the Tetris test even worse. So, my point still stands. There are many solid alternatives. But what makes Jev so popular is not a unique idea (as mentioned before, classifiers existed for a long time) but the fact that it performs so well at so many diverse tasks. The only task where it allegedly doesn’t do well (as a reader told me) is legal reviews in non-English languages. (But the weak multilingual performance can be circumvented by connecting it to a cheap translation model, e.g., GPT -6 Luna with a small latency overhead.) All in all, at first glance, Jev doesn’t seem to offer anything fundamentally new. After all, one might say that “it’s just a classifier” (with a nice API on top of it). However, it works surprisingly well across a huge range of tasks. In that sense, it’s the plug-and-play version of dedicated classifiers. Sure, experts will still fine-tune specialist models, but the bar to justify fine-tuning a specialized model is now much higher, since it’s easy to just throw a Jev or Jev-like at it and get good-enough results. Also, I don’t expect Jev and Jev-likes to unlock new capabilities or solve previously unsolvable tasks. However, if we make such models part of our agent harnesses to aid the expensive GPT-6 or Opus 5.5 models in decision-making within that harness, using agent harnesses could become much faster and cheaper in the future. If you found this article useful, consider becoming a paid subscriber to Ahead of AI. Your support helps me spend more time on the experiments and detailed illustrations behind articles like this. You can also support my work through my books, Build a Large Language Model (From Scratch) and Build a Reasoning Model (From Scratch) , if you’d like to implement these concepts yourself. Thanks for reading and supporting my independent research! Figure 1: Quick overview of the Jev API; more details on that later. Sure, the latest state-of-the-art GPT and open-weight LLMs can do the same kinds of classification tasks as Jev, while also being capable of much more general decision-making. But Jev’s advantage is that it can handle those classification tasks much faster and more cheaply. At the other end of the spectrum, for a narrow, well-defined problem, Jev probably won’t classify anything better, faster, or cheaper than a special-purpose classifier. But its selling point is that it is far more general than those task-specific models. So, what is the methodology behind Jev (based on an educated guess), what can it do, and why is it so popular? I aim to answer all of these later in this article. However, I thought starting with a brief history of language models for decision-making would be a great way to begin. And it hopefully helps demystify some of the hype and show what Jev does very well (”Jev is essentially a text classifier,” but “Jev is also not ‘just’ a text classifier.”) PS: I am not affiliated with Jev in any way. Also, I am not offered free access to Jev, and this is also not a product endorsement, just a technical article to offer some insights into the history of text classification to help you make sense of the recent hype. Since this is a long article, I recommend reading it in your browser , where you can access the table of contents menu on the left side. 1. Language modeling and classification in the pre-transformer era For completeness, before we put Jev in context (no pun intended), I thought it made the most sense to start chronologically. In this section, I want to take a brief tour of applied text classification via naive Bayes, logistic regression, and the more classic (deep) neural networks before transformer-based models came along. 1.1 Bag-of-words: naive Bayes, logistic regression, and XGBoost Back in the day, when I was a grad student 15 years ago, even though recurrent neural networks already existed (more on that later), text classification was usually done with a bag-of-words representation because it was straightforward and could get good results on moderately sized datasets. In short, we can think of the bag-of-words representation as a method that makes free-form text input of different lengths compatible with classic classifiers (naive Bayes, Logistic Regression, SVMs, Random Forest, XGBoost, to name a few), which expect a fixed-size input vector. Popular real-world applications include anything from news article classification to email spam filtering. And yes, allegedly even Gmail’s original spam filter used a Naive Bayes model with a bag-of-words representation. As a side note, I wrote about this approach exactly 12 years ago. It was one of the first things I shared on arXiv. Figure 2: An old tutorial of mine from 2014 that explains naive Bayes classifiers using a bag-of-words model. So, what exactly is this bag-of-words representation? It’s a way to convert free-form texts with different lengths, e.g., Training example 1: “Zentropa is the most original movie I’ve seen in years. If you like unique thrillers that are influenced by film noir, then this is just the right cure for all of those Hollywood summer blockbusters clogging the theaters these days. Von Trier’s follow-ups like Breaking the Waves have gotten more acclaim, but this is really his best work.” Training example 2: “This film is just plain horrible. John Ritter doing pratt falls, 75% of the actors delivering their lines as if they were reading them from cue cards, poor editing, horrible sound mixing” Training example 3: “Zentropa has much in common with The Third Man, another noir-like film set among the rubble of postwar Europe.” Figure 3: An illustration of a bag-of-words representation. A bag-of-words model starts by building the vocabulary, which consists of all unique words in the training set (optionally, one can get rid of so-called stopwords like “a” and “the”, which are words that carry little to no semantic meaning in most contexts). A bag-of-words representation results in these fixed-size inputs by assigning each word in a vocabulary its own position in a vector. We then count how often each word occurs in a document. For example, if we have a vocabulary of 50,000 unique words, it produces a fixed-size vector with 50,000 entries, regardless of whether the input consists of only ten words or 300k words. Note that most entries are zero because each document contains only a small subset of the vocabulary. (Instead of representing the raw counts, there are also normalization schemes like TF-IDF.) Then, once we have these word frequency vectors, we can train a classifier on a labeled training set, such as emails labeled as spam or non-spam. For example, a logistic regression model would then learn feature weights that correlate certain words (and word counts) with particular labels. For instance, certain words might increase the predicted spam probability, and others may decrease it. This approach is computationally cheap and can work well when particular words provide strong clues about the label. In a simple classification task such as spam classification, this is often enough to get quick, reasonably accurate results. But one of the biggest downsides of this approach is that, because of the nature of the bag-of-words representation, it loses word order. So, for example, “the dog bites the man” and “the man bites the dog” produce identical vectors despite describing different events. (There are some workarounds to preserve some local order by adding word pairs or longer sequences, called n-grams, as features, although this increases the vocabulary size.) Despite the shortcomings, I still think that a bag-of-words has its place in certain low-stakes applications because it’s so cheap, and a bag-of-words representation + logistic regression remains my go-to baseline for every text classification problem, since it’s so easy to implement. Figure 4: For those interested in building a simple logistic regression classifier, I have a tutorial up here . This model achieves 89.9% accuracy (on a balanced dataset). 1.2 Deep neural networks for text classification The aforementioned bag-of-words model would also work with (simple) deep neural networks, like multilayer perceptrons. But the downside still is that we would lose the sentence structure and word order. However, more sophisticated neural network architectures avoid the bag-of-words workaround: convolutional neural networks (CNNs) and recurrent neural networks (RNNs), which can take word embeddings as input. 1.2.1 Word embeddings First, before feeding the input texts into a model, we have to convert them into a suitable representation. One such representation is bag-of-words. Another is word embedding vectors. The difference is that a bag-of-words vector represents the entire text by counting how often each vocabulary word occurs, while a word embedding represents an individual word as a dense vector of learned numbers. Figure 5: Illustration of creating word embeddings. Word embeddings work similarly to embedding layers in LLMs, i.e., they convert input tokens into dense vectors. Embeddings can happen outside the model (e.g., two classic, popular methods for learning them are Word2Vec and GloVe ), or the embedding layer can be part of the neural network architecture itself and be learned and tuned during model training. These classic embeddings are context-independent at lookup time. The word “bank”, for example, gets the same vector in “river bank” and “bank account” (as you may know, context can be handled via concepts like attention). For additional resources on embeddings, you may find the following ones helpful: Chapter 2: Working with Text Data (this is an LLM chapter but should give you the gist of embedding words or tokens; in LLM tokenizers, we split words into subword tokens; in Word2Vec, 1 word is usually 1 token.) Understanding the Difference Between Embedding Layers and Linear Layers (this is an illustration that shows when embedding vectors are mathematically equivalent to Linear layers and matrix multiplications.) Figure 6: Illustration of an RNN classifier. Note that the figure above shows the RNN in the unrolled representation. I.e., the RNN reuses the same layer stack for each input, hence the term “recurrent”. And since it’s “recurrent”, the input text can have an arbitrary length. The figure below illustrates the “recurrence” with the unrolled representation side by side. Note that both show the identical architecture, it’s just a different visualization. Figure 7: Rolled and unrolled illustration of the same RNN. RNNs were notoriously hard to train, and there are important improvements to RNNs, like Long short-term memory (LSTM) networks, introduced in 1997, and gated recurrent units (GRUs), introduced in 2014, which use learned gates to control how information is retained and updated. (There is also the more recent xLSTM: Extended long short-term memory , introduced in 2024). Also, state-space models are inspired by this idea of a fixed-size hidden state updated sequentially, which is cheaper than transformer attention. However, the bottleneck is still how much information the hidden state can retain, and it still has to be processed sequentially. (Fun fact: attention was first developed for RNNs before the transformer architecture came along, but it’s a story for another time; I’ve written about it in my Understanding Large Language Models article.) The bottom line is that RNNs can be used to train text classifiers. Coming back to the IMDb movie review dataset, the bag-of-words classifier with logistic regression achieved about 89.9% accuracy (on a balanced dataset), while an LSTM RNN achieved only 85.66% accuracy. Yes, RNNs can be harder to train (stay tuned for the ULMFiT method below, which trains an RNN with much higher accuracy). Figure 8: A simple RNN with LSTM tutorial . This model achieves 85.66% accuracy (on a balanced dataset); the higher training accuracy indicates substantial overfitting. Note that this RNN was trained from scratch. A better approach is to pre-train the model on a larger dataset first, then fine-tune it on this target dataset (classically, we call this approach “transfer learning”). In the natural language processing domain, one of the most influential papers proposing this approach is ULMFiT (2018), which achieved an impressive 95.4% test accuracy on IMDb. Figure 9: Annotated figure from the ULMFiT paper. 1.2.3 Convolutional neural networks (CNNs) You probably know convolutional neural networks (CNNs) from their use in computer vision. However, it is also possible, although historically less common, to use them for text. Figure 10: Illustration of a convolutional neural network (CNN) to classify images. As shown in the image classification example in the figure above, CNNs apply learned filters to image patches (windows). Similarly, in the natural language domain, we can apply learned filters to windows of adjacent word embeddings. Figure 11: A CNN for text classification, step by step. Only 1 filter (channel) for simplicity. As illustrated in the figure above, a convolutional filter with a window size of three uses the same weights for each adjacent group of three words. Then, as it slides over the inputs, it moves the filter one word at a time across “the movie had surprisingly good acting”. So, this gives four windows (ignoring padding for simplicity): 1: “the movie had” 2: “movie had surprisingly” 3: “had surprisingly good” 4: “surprisingly good acting” Figure 12: CNN for text classification with multiple filters (channels). Also, what’s nice is that the filter computations across positions can run in parallel, which avoids the step-by-step dependency of an RNN. For a quick comparison, on the aforementioned IMDb dataset, my experiments show that such a CNN gets about 90.07% accuracy (but note that this is highly architecture-dependent; for example, you may know from computer vision contexts that accuracies can vary widely). (E.g., the good old AlexNet had a ~62.5% top-1 accuracy on ImageNet, and a ConvNeXt V2-H gets 88.9%.) Figure 13: A CNN trained to classify IMDb movie reviews; it gets 90.07% accuracy on this balanced dataset. You can find the source code here . 2. Transformers In 2017, the original transformer architecture was introduced in the Attention Is All You Need paper. Since this topic has been covered so extensively (by me and others), I will focus on the classification-relevant aspects. But for those interested in the attention mechanism and other architecture details, please see my related articles: The key point is that the original transformer architecture was an encoder-decoder setup used for language translation, but it can be easily adapted for text classification tasks, as I’ll illustrate in the following sections. Figure 14: The original transformer architecture from Attention Is All You Need . 2.1 Encoder-style language models I remember all too well the first years after the original transformer architecture release. They were pretty much defined by the rivalry between two different approaches: Encoder-style models like BERT, primarily developed by Google; Decoder-style models like GPT, primarily developed by OpenAI. Figure 15: Annotated figure from the original BERT paper . While BERT models are not nearly as popular as autoregressive GPT-style transformers, thankfully some people still update and modernize them occasionally. One recent example that is often my go-to for classification tasks is the 2024 ModernBERT model. As shown below, on the IMDb movie reviews, ModernBERT gets approximately 95% accuracy with very little fine-tuning effort. Figure 16: Different LLMs fine-tuned to classify IMDb movie reviews; you can find the source code here . (I did very little hyperparameter tuning, and this can potentially be improved by 1-2% further; however, one of the points here is also that one can get really good results with pretty minimal effort.) 2.2 Decoder-style LLMs LLMs like GPT are decoder-style, autoregressive transformers that are the center of all attention (no pun intended) for generating text and code. However, as I explained in chapter 6 of my Build A Large Language Model (From Scratch) book, as a gentle introduction to fine-tuning (before covering instruction fine-tuning), we can also repurpose these for text classification. Sure, we can also prompt an LLM directly, as shown in the screenshot below. Figure 17: Prompting an LLM to classify a movie review. However, if we want structured outputs and we have a specific target domain in mind, this is unnecessarily brittle and inefficient. Instead, we can replace the output layer with a leaner classification head, as illustrated below: Figure 18: Swapping the output layer of a GPT-style model with a leaner classification head. Now, fine-tuning has a few caveats. For instance, because of the autoregressive attention mask, we have to be careful about how we design fine-tuning so that the classification token has information about all other tokens in the sequence. Figure 19: Attention masks in BERT and GPT. If you are interested in further technical details, please see my recent end-to-end Building an AI Text Detector From Scratch article: Overall, though, the advantage of using GPT-style LLMs over e.g., BERT variants is that there are so many modern open-weight architectures out there to adopt. Anything between the small Qwen 3 0.6B models to the latest Kimi, GLM, or DeepSeek models. (Of course, using such >1B parameter models for classification could be a bit overkill from an efficiency perspective, but hey, it’s possible.) Figure 20: On the aforementioned IMDb movie review dataset, a relatively small GPT-2 124M model gets approximately 92% accuracy; a larger and newer LLM (like a recent Qwen3 variant) would likely perform better. 2.3 Encoder-decoder style architectures While the original transformer architecture (an encoder-decoder) was split into two paradigms, encoder-style models like BERT and decoder-style LLMs like GPT, there were also efforts to use encoder-decoder-style variants, with the most recent variant (somewhat unexpectedly) being DeepSeek V4.1 Flash . (However, in this case, it’s a causal encoder not a bidirectional one as in T5.) Keeping the focus on encoder-decoder architectures for classification, probably the most prominent candidate is Google’s 2019 T5 (Text-to-Text Transfer Transformer) . Architecturally, the differences between T5 and the original transformer include updates to the architecture (as summarized below), as well as changes to training. The original transformer was trained to do language translation (in a supervised fashion); T5 uses pre-training on unlabeled text with span corruption, where the encoder receives text with missing spans and the decoder generates those missing spans. Figure 21: T5 architecture changes to the original transformer architecture. (The architecture drawing is taken from Attention Is All You Need .) Now, we can use T5 similar to the other RNN, CNN, and transformer approaches above, by adding a classification head. Additionally, we can also (train to) have the decoder output the class label prediction (like “positive” or “negative” in the IMDb movie review dataset case), similar to regular LLMs. Let’s call this approach text-to-text classification. While GPT-style LLMs are trained on massive amounts of text, they usually perform text-to-text classification well out of the box, as shown earlier (the figure below is inserted here again for convenience). Figure 22: Text-to-text classification with a GPT model. However, in the case of T5, it is common to further fine-tune the decoder to do well on these types of tasks in a given target domain. For T5, both approaches work. Here’s a summary: Figure 23: Classification head vs text-to-text approaches. 3. Jev overview So far, we have seen that there are plenty of approaches to text classification, from traditional methods like logistic regression and naive Bayes with bag-of-words representations to prompting the latest frontier LLMs like GPT-6 or fine-tuning any open-weight LLM with a classification head (or text-to-text classification). At first, I (almost) dismissed it as “just a classifier,” something that I build routinely for classification tasks with natural language inputs (e.g., see my AI Detector article as a recent public example). 3.1 Jev vs existing text-to-text classification Before continuing this discussion, though, let’s start with a quick Jev overview. Jev is a new model released by TypeSafe AI , which just came out of stealth a few weeks ago and got a lot of attention (at first, it seemed a bit bizarre because it looks just like a classifier). Jev is a proprietary model (although the release was followed by a huge number of quick open-source clones, but we will get to this later) that is relatively cheap to use and claims to be on par with GPT-5.6 Luna (for decision-making) while being orders of magnitude faster and cheaper: Figure 24: Benchmark from TypeSafe AI blog . Here, regarding GPT-Luna, think of this as the text-to-text classification approach discussed earlier. So why all this hype? I think it’s partly because of the nice API and that it performs so well on all kinds of tasks, so it doesn’t require custom fine-tuning. For example, I can use it to categorize support tickets/emails (I made a video version to show the response time): Or, I can have the same model play Tetris in real-time (here, I am using the Choice API): Maybe the best way to succinctly explain it (before getting into technical details) is as follows: Like ChatGPT in 2022 was exciting because it was a general-purpose chat model that could generate all kinds of texts, one of the reasons the tech community is excited about Jev is that it is the ChatGPT moment for classification, where it can cheaply classify all kinds of text inputs without having to fine-tune a custom classifier for each task. (As of this writing, there’s not much known about the architecture and exact training algorithm, except that one of the founders said it was trained via “Reinforcement Learning for Calibrated Decisions”, but more on that later.) 3.2 The Jev API Over the past few years, we’ve gotten used to throwing bigger, better, and more expensive GPT-style LLMs at all kinds of problems, and for targeted decision-making or classification tasks, a cheap & fast approach like Jev may feel refreshing to most. Especially for one-off tasks where collecting training data and fine-tuning a custom ModernBERT sounds too tedious, we might just throw a Luna-like model at it. But on top of being popular for its versatility (that is, performing well on different target domains out of the box), Jev also has a relatively nice API which we can use via curl in the terminal or via its Python API. In short, there are three main API types illustrated below. Let’s start with the Choice API, which is convenient for multi-class classification. Figure 25: Jev’s Choice API. Use this for multi-class classification. Next is the Noul API, which is simpler and assigns a “yes” probability to a question. Figure 26: Jev’s Noul API. Use this for binary classification or multi-label classification (with multiple Noul questions). Lastly, the Score API assigns a score based on a rubric level (in the example below, 0, 1, 2). Figure 27: Jev’s Score API. Use this for ordinal classification. To sum it up, the different APIs and use cases are as follows. Figure 28: Jev API cheat sheet. 3.3 Jev classifying IMDb To make these APIs more concrete, and to answer the question of how well Jev might do on the aforementioned IMDb dataset, we can run it with either the Choice or Noul API. Let’s start with the Choice API first. Each review would be formatted as follows: This Choice API is convenient if we want to use explicit labels for binary (here) or multi-class problems. An actual response might look as follows: Along with the label, we also get the confidence for this prediction, which can be really useful in real-world applications, as well as the probabilities for each class. (The confidence field summarizes how concentrated the probability distribution is. It is different from the probability assigned to the winning class.) One of the selling points of Jev is that, according to the documentation, these are well-calibrated (but more on calibration later). Alternatively, we can also use the Noul API here to classify the reviews. Using the same movie review classification context, the format would be as follows: And the answer via the Noul API is: (Interestingly, it gives 0.98 instead of 1.0 for the positive class, even though it’s the same text.) We can also apply the Noul API to multiple classes by asking one question per class, for example: “Is this article about finance?” “Is this article about politics?” “Is this article about technology?” Choice: Accuracy 96.47% (24,117 correct) Total runtime: 22 minutes 24 seconds 15,456,663 input tokens, $0.6492 total cost Noul: Accuracy 96.20% (24,050 correct) Total runtime: 23 minutes 3 seconds 15,106,663 input tokens, $0.6345 total cost 23 min to fine-tune on the training set; 7 min to evaluate on the test set. Figure 30: ModernBERT versus Jev. But our ModernBERT model can’t play Tetris, for example, or do anything else besides movie classification without us fine-tuning it for the new task. But we could then throw a GPT-5.6 Luna or GPT-6 Luna model at it (which is a bit slower and also more expensive). A classic rule of thumb was: Use a cheap LLM (like GPT-6 Luna) for one-off decision-making tasks; Fine-tune a custom classifier if we want to do this task repeatedly. Figure 31: GPT model where we replace the output layer (which maps to the whole vocabulary) with an output head with only one node. Earlier, we discussed fine-tuning these models for a specific task like movie review classification with a pre-defined number of class labels (here, “positive” and “negative”). With this “1 output node” setup, we can actually extend this to a flexible and arbitrary number of classes. To extend this to an arbitrary number of classes (let’s consider the 3-class case of categorizing a customer ticket into the three categories “billing”, “technical”, “account”), we can do so as shown in the figure below. Figure 32: Using a BERT- or GPT-style model with a Jev-like choice API. As shown in the figure above, for each candidate option, we feed the model the input text, the task instructions, and the candidate’s description. The classification (scoring) head maps the output representation to a single scalar score. We then apply softmax across the candidate scores to obtain a probability distribution and return the highest-probability option. The main point here is that the proposed head has one output per class. Each class description produces its own representation, which the same head scores via the output layer (which is essentially a logistic regression model): where s i is the scalar score for class label i ; w is a learnable weight; b is a learnable bias unit; h i is the output of the transformer before the classification head. BERT-style encoder models use the final hidden state of the token (optionally passed through a pooling layer); GPT-style autoregressive models typically use the final non-padding token’s hidden state, which we need because of the autoregressive nature as discussed earlier (the last non-padding token can attend to the preceding text, instructions, and candidate description). Figure 33: Rule of thumb showing that often more data helps more than additional hyperparameter tuning. Finally, the training algorithm. Again, the details are not known, but TypeSafe AI’s blog post states that they were using a new algorithm called Reinforcement Learning for Calibrated Decisions (RLCD): “We built a new stack entirely focused on automation: with a new model architecture, parallel sampler for maximum efficiency, and training method we call Reinforcement Learning for Calibrated Decisions (RLCD).” This method is not public. A published method with a similar calibration objective is RLCR (Reinforcement Learning with Calibration Rewards), from the 2025 paper Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty . Before providing more details, the next section gives a brief overview of calibration in general. 5.1 On calibration Calibration is not a new topic and a step I recommend for any production model where you want to use and evaluate the class-membership probabilities. In short, calibration adjusts the model’s probability estimates so they better match observed class frequencies. For example, consider once more our IMDb movie classification problem. A model might assign a review 74% positive and 26% negative. These are probability estimates, but the model may be overconfident or underconfident. We cannot assume that the numerical values are reliable without evaluating calibration. I.e., predictions of 74% positive and 54% positive both yield the class label “positive” at a 50% threshold, although the model expresses more confidence in the 74% case. Figure 34: Illustration of how calibration works. Calibration techniques use a separate labeled dataset, e.g., a held-out validation set, to adjust these probability estimates. After successful calibration, among many reviews assigned a positive probability of approximately 74%, roughly 74% should actually be positive. For more information, I recommend the good old scikit-learn documentation . There are many approaches for this. For example, one that I used in my AI detector project is temperature scaling. Temperature scaling divides the model’s logits by a temperature ( T ) value before applying softmax. We learn T (a positive number) by minimizing the cross-entropy loss on the calibration dataset while keeping the model weights fixed. A temperature above 1 reduces confidence in the highest-probability class, while a temperature between 0 and 1 increases it. We then use this same temperature for new predictions. Because this scaling preserves the ordering of the logits, the predicted class remains unchanged when we select the highest-probability class. 5.2 Reinforcement learning with calibration rewards (RLCR) Jev’s RLCD training method remains proprietary, but a related idea we can discuss is Reinforcement Learning with Calibration Rewards (RLCR), which was introduced in the 2025 Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty paper. (Note that there is no officially established connection between the two methods, but I am assuming that they could be related.) Reinforcement learning with verifiable rewards (RLVR) typically rewards a correct answer with 1 and an incorrect answer with 0. (For a comprehensive explanation and implementation, I recommend checking out my Build A Reasoning Model (From Scratch) book :)). In short, RLCR adds an additional penalty for inaccurate confidence estimates. So, in the conventional RLVR method, the reward R is either 0 or 1 based on the answer correctness (we are ignoring an optional formatting reward and length penalty here, for simplicity). In RLCR, the model generates reasoning and an answer, which is followed by an uncertainty analysis and a numerical confidence ( q ). This modified reward is where is 1 for a correct answer and 0 otherwise. For example, an incorrect answer with 90% (0.9) confidence receives a reward of -0.81, since At 20% confidence, the reward is -0.04. And a correct answer at 90% confidence earns 0.99. (Readers familiar with evaluating calibrated models may notice that the squared-error term is the Brier penalty for the stated probability that the answer is correct.) Figure 35: RLCR overview. Note that the same (here, Qwen2.5-7B) model generates the uncertainty analysis and q as part of its response. The uncertainty analysis is a written assessment of where its answer could be wrong. After answering, the model examines missing evidence, ambiguous wording, questionable assumptions, or possible reasoning errors. The paper’s prompt asks it to identify specific uncertainties rather than propose corrections. Also, the system prompt explicitly asks for q , a number between 0 and 1 inside tags. It represents the model’s estimated probability that its answer is correct. The system generates all these parts sequentially in the same response. For illustration purposes, the model answer could be as follows: Below is an annotated figure from the paper that illustrates how it improves the expected calibration error over regular RLVR with temperature scaling. On HotpotQA, RLCR reduces expected calibration error (ECE) from 0.37 to 0.03 compared with RLVR, with similar accuracy (62.1% versus 63.0%). Across six other datasets, average ECE falls from 0.46 to 0.21, while accuracy rises from 53.9% to 56.2%. These are the results in the paper’s Table 1(a). Figure 36: RLCR results from the Beyond Binary Rewards: Training LMs to Reason About Their Uncertainty paper. Conceptually, the “Classifier” in “RLVR + Classifier” is a supervised binary classifier that predicts whether the generated answer is correct. For Jev, I could apply calibration rewards directly to typed decisions, without generating reasoning text. For example, we could equip a GPT-style model with the classification head illustrated in Section 2.2 and adapt the RL training objective to reward accurate decisions and calibrated probabilities. We would train the backbone and classification head together. This would be an adaptation inspired by RLCR, and it remains unclear whether Jev’s RLCD works this way. Alternatively, since Direct Policy Optimization (DPO) is a cross-entropy (CE) alternative to Reinforcement Learning with Human Feedback (RLHF), we could also directly minimize CE + Brier loss on the classifier’s probabilities. I did this with the ModernBERT model, and there was a modest improvement over regular temperature scaling. Figure 37: ModernBERT improvements via RLCR-inspired Brier-loss optimization. The ↑ symbol means higher is better. The ↓ symbol means lower is better. 5.3 Side note: Is calibration necessary? Cross-entropy already encourages accurate probability estimates. Under ideal conditions, that is enough to obtain calibrated predictions without an additional penalty. I.e., using a simple coin flip example analogous to the movie review classification, the two classes are “heads” and “tails.” If it’s a biased coin that lands heads 80% of the time (in expectation), the true probabilities are 80% heads and 20% tails. On a representative training set, when we optimize cross-entropy, the model will already learn these as a side-effect of minimizing cross-entropy (negative-log-likelihood). So, in short, if the model learns the true class probabilities, its predictions are calibrated and an additional penalty is unnecessary. (And Brier loss has the same theoretical optimum as cross-entropy, as described in Strictly Proper Scoring Rules, Prediction, and Estimation by Gneiting and Raftery, 2007.) In practice, however, we never train to the real optimum (in expectation) but train on a finite dataset. So, once a neural network correctly classifies most training examples, it can further reduce cross-entropy on the training set by becoming more confident. These confidence estimates may then generalize poorly to the test set and new data. For example, in On Calibration of Modern Neural Networks , Guo et al. (2017) observed “neural networks can overfit to NLL without overfitting to the 0/1 loss.” That means test accuracy can still improve while test cross-entropy worsens. So overfitting can show up in the probability estimates even when classification accuracy continues to improve. Adding Brier loss changes how prediction errors are weighted during training. But whether this improves calibration needs to be checked on held-out data, of course. (The modest improvement in my experiment is an empirical observation for this setup, and we should not assume that adding Brier will always help.) In my experiments (see previous figure), adding the Brier loss to cross-entropy provided only a very small additional calibration benefit. But the motivation is more direct in RL contexts, such as described in RLCR, where we measure answer correctness and don’t already minimize cross-entropy. So, in RL contexts, the Brier term adds an incentive to report accurate confidence alongside the reward for answer correctness. 6. Who is Jev for? Now that we covered Jev in reasonable detail, who is Jev for? Based on my assessment, it’s a pretty big target audience that intersects between people who a) want to save time by not having to fine-tune a custom classifier for every decision task and b) want to save money over using “the big guns” like GPT-6 and similar LLMs. There are also lots of interesting use cases to think of. In the section below is a short list of ideas. 6.1 Jev use cases The list of use cases is seemingly endless. Sure, there are flashy examples like having it play video games like Tetris (as I showed in my earlier video above to demonstrate the low latency of the API). However, sorting emails is a more practical choice for most. For example, one could use Jev as an additional spam filter, prioritization, and so on. But beyond things like that, it could also serve as a tool to augment LLM-agent harnesses. Here it could be used as a pre-screener for regular LLM-agent harnesses to scan our contexts for prompt injection; select the reasoning effort level for a model; select a skill.md from your registered skill library; use it as a judge for evaluation or self-refinement; find relevant files for context building.

0 views
Unsung 2 weeks ago

“First, don’t interfere with user input.”

In response to a previous post , one of the readers wrote this: I would phrase this as the fault of the animation having violated the Hippocratic oath for animations, which is “first, do no harm” a.k.a. “first, don’t interfere with user input”. Yes, one has to be afraid of hyperbole – was there ever an onscreen transition that saved a life? – but there is something I really liked about this phrasing. After all, most transitions and animations are decorators, and every transition and animation is, by definition, also a delay . Here’s Shazam as I open it, and overlaid are my frantic taps as I’m trying to make it start the song recognition process: It’s a cute cold start animation, but: What’s particularly frustrating here is how Shazam is being used. On the other end of this whole flow is a song that might already be fading out – timing myself, a user, can’t control – so time wasted on the uninterruptible animation might be seconds separating failure from success. I know it does feel like a blink of an eye when watched out of context, and it is literally just a bit over a second of a delay in the best case scenario, but I wanted to share it as a general example. I believe these seconds add up, especially in a stressful moment, on an older device, repeated many times a day, across different apps… or all of the above. (My go-to example: Imagine your keyboard keys reacting with a second of delay!) At least, I should compliment Shazam for not making another mistake: after the recognition starts, tapping the same button doesn’t cancel it – instead, you have to tap a close box in the corner. Here, the designers realized I might actually be slamming the big button many times over, and I shouldn’t be punished for it even more. It’s not interruptible, like animations should be. It doesn’t even buffer the taps, so – in case the animation covers for something truly uninterruptible, like loading from the cloud – I can’t simply tap and forget, but instead have to wait and tap after it’s done animating.

0 views
Anton Zhiyanov 2 weeks ago

Go concurrency distilled

This mini-book provides a brief overview of many concurrency topics in Go. Each topic comes with interactive examples — feel free to experiment with them by changing the code and clicking Run . There's also a PDF version with static examples. This is a quick refresher on Go concurrency, not a beginner's guide. If you want to learn concurrency from the ground up with practical exercises, check out my other book — Gist of Go: Concurrency . The book is AI-free. Goroutines  • Channels  • Select  • Pipelines  • Time  • Context  • Wait groups  • Data races  • Race conditions  • Mutexes  • Semaphores  • Signaling  • Run once  • Object pool  • Atomics  • Testing  • Scheduling  • Diagnostics  • Final thoughts The foundation of concurrency in Go is goroutines – functions started with the keyword: The Go runtime juggles these goroutines and distributes them among operating system threads running on CPU cores. Compared to OS threads, goroutines are lightweight, so you can create hundreds or thousands of them. Goroutines are completely independent. The main function is also a goroutine, but it starts implicitly when the program starts. When ends, other goroutines also shut down. We use a wait group ( ) to wait for goroutines to finish in the example above. A wait group has a counter inside. Calling increments it by , while decrements it by one. blocks the calling goroutine (in this case, main) until the counter reaches zero. This way, main waits for both workers to finish before it exits. automatically increments the wait group counter, runs a function in a goroutine, and decrements the counter when it's done: Goroutines can pass values to each other through channels . A channel is like a window where one goroutine can throw something and another can catch it: Sending a value through a channel is a synchronous operation. When the sending goroutine writes a value to the channel ( ), it blocks and waits for someone to receive that value ( ). Only then does it continue. Returning an output channel from a function and filling it within an internal goroutine is a common pattern in Go. This allows the caller to receive values through the channel while the owning function retains control of it: To signal readers that all data has been sent, the writer goroutine closes the channel with : The reader checks the channel's status with a second value ("comma OK") when reading: While the channel is open, the reader receives the next value and a status. If the channel is closed, the reader gets a zero value and a status. A channel can only be closed once. Closing it again or writing to a closed channel causes a panic. The only reason to close a channel is to signal to its readers that all data has been sent. If this isn't important to the readers, then you don't need to close it. When a channel is no longer used, Go's garbage collector will free its resources, whether it's closed or not. automatically reads the next value from the channel and checks if it's closed. If the channel is closed, it exits the loop: Range over a channel returns a single value, not a pair, unlike range over a slice. You can protect yourself from accidental write/close errors by setting the channel direction. Channels can be: You can't read from a send-only channel or write to a receive-only channel (nor can you close it). Channels are usually initialized for both reading and writing, and specified as directional in function parameters. Go automatically converts a regular channel to a directional one: Buffered channels work like a FIFO queue with a fixed-size buffer for storing values. As long as the buffer has free space, writing to the channel doesn't block the goroutine. Similarly, as long as the buffer contains values, reading from the channel doesn't block the goroutine: By default, if you don't specify a buffer size, a channel is unbuffered (buffer size equals zero). Buffered channels work with the built-in and functions: Reading from a closed buffered channel returns values from the buffer and a status. Once all values are taken, it returns a zero value and a status, like a regular channel: Like any type in Go, channels have a zero value, which is . Writing to or reading from a nil channel blocks the goroutine indefinitely: Closing a nil channel causes a panic: The select statement is somewhat like , but specifically designed for channels. Here's what it does: Select is used to manage data flow in pipelines: To cancel goroutines: For non-blocking operations: And for much more. A pipeline is a sequence of operations where each step takes input data, processes it in a specific way, and outputs it. The input and output of each operation is a channel. A typical pipeline looks like this: A goroutine can signal other goroutines that it has finished its work using an output channel : If a goroutine doesn't need to return results, it can signal completion using a done channel : To terminate a goroutine early, a calling goroutine can use a cancel channel : There are three approaches to error handling in concurrent pipelines. ➊ Return on the first error: ➋ Use a result type: ➌ Collect errors separately: Besides handling date and time, the package offers tools for managing time-sensitive operations in concurrent programs. returns a channel that is initially empty, but receives a value after the timeout period. It's useful for timing out operations: waits for to complete, but thanks to , it won't wait longer than the duration: A timer ( ) is a structure with a channel to which it sends the current time when it triggers (expires). Timers are useful for planning future executions: stops the timer and returns if it hasn't expired yet, and otherwise: It's often more convenient to use the wrapper function. It waits for duration and then executes function : returns a timer that you can cancel before execution starts: If a timer is used in a loop, it's better to create a single timer and reset it instead of creating a new instance on each iteration: A ticker is like a timer, but it keeps firing until you stop it. Tickers are useful for executing periodic tasks: creates a ticker that sends the current time to the channel at interval . You must stop the ticker eventually with to free up resources. If the channel reader can't keep up with the ticker, the ticker will skip ticks. The main purpose of context is to cancel operations, either manually or by timeout/deadline. The function accepts a context and uses its channel to listen for cancellation: Cancel manually ( error): Cancel by timeout ( error): Cancel by deadline ( error): Context is layered. A context object is immutable. To add new properties to a context, a new (child) context is created based on the old (parent) context. The shorter timeout between the parent and child contexts always wins. The child context can only shorten the parent's timeout, not extend it: Multiple cancels are safe. You can call on the context as many times as you want. The first cancel will work, and the rest will be ignored. You can specify a custom cancellation cause using , and . This cause is accessible through : You can register a function to execute when the context is canceled with : Context can pass additional information about a call using , which creates a context with a value for a specific key. But it's generally better to avoid passing values in context. It's better to use explicit parameters or custom structs instead. The type lets you wait for one or more goroutines to finish: A doesn't know anything about the goroutines it manages. It works with an internal counter. Calling increments the counter by one, while decrements it. blocks the calling goroutine until the counter reaches zero. The method combines , starting a goroutine, and : All methods are safe to use from multiple goroutines. Normally, all calls happen before . But technically, there's nothing stopping you from doing some of the calls before and some after (from another goroutine). You can call from multiple goroutines. They will all block until the group's counter reaches zero. A data race happens when multiple goroutines access shared data, and at least one of them modifies it. We need to protect the data from this kind of concurrent access. A data race doesn't always cause a runtime panic. That's why Go provides a special tool called the race detector. You can turn it on with the flag, which works with the , , , and commands. Channels are safe for concurrent reading and writing, and they don't cause data races. Ways to prevent data races: A race condition happens when an unpredictable order of operations from multiple goroutines leads to an incorrect system state: If individual operations are concurrent-safe, Go's race detector won't find any issues. Because of this, it doesn't catch race conditions: You can't fully eliminate uncertainty in a concurrent environment. Events will happen in an unpredictable order — that's just how concurrency works. However, you can prevent a race condition — often by protecting a composite operation with a mutex: Sometimes you can prevent a race condition without using mutexes by applying an atomic compare-and-set operation or one of its flavors: The idea is always the same: The type protects shared data and parts of your code from being accessed concurrently: The mutex guarantees that only one goroutine can run the code between and at a time. A mutex is used in these situations: If all goroutines are only reading the data, you don't need a mutex. The method tries to lock the mutex, just like a regular . But if it can't, it returns right away instead of blocking the goroutine: The type distinguishes between readers and writers. It provides two sets of methods: Here's how it works: This creates a "single writer, multiple readers" setup. Both and implement the same interface: By using instead of a specific mutex type, you can build components that don't depend on a specific lock implementation. This lets the client decide which lock to use. You can use a channel instead of a mutex to protect shared data: A semaphore is like a container with N available slots and two operations: acquire to take a slot and release to free a slot. Here are the semaphore rules: You can implement a simple semaphore with a buffered channel, where N is the channel's size. To acquire the semaphore, send a value into the channel. To release it, take a value from the channel: For more complex situations, use the package. A rendezvous lets two goroutines wait for each other: You can implement a simple rendezvous with a wait group: A barrier is a general case of a rendezvous. It lets N goroutines wait for each other: You can implement a simple barrier with a wait group: The (conditional variable) type lets one goroutine signal to another that it's ready, and lets the other goroutine wait for that signal. A includes a mutex and has two methods — and . If there are multiple waiting goroutines when is called, only one of them will be resumed. If there are no waiting goroutines, does nothing. You can also use the method. While wakes up only one goroutine waiting on , the method wakes up all such goroutines. You can signal with a channel: And broadcast too: Broadcasting with a condition variable is limited: it only sends a signal, not the actual data, and it only works once. With channels, you can build a publish/subscribe system that doesn't have these limitations: The type makes sure that the given function runs only once. If multiple goroutines call at the same time, only one will run the function, while the others will wait until it returns: is perfect for one-time initialization or cleanup in a concurrent environment. Besides the type, the package also includes three convenience once-functions: The type helps reuse memory instead of allocating it every time, which reduces the load on the garbage collector: takes an item from the pool. If there are no available items, it creates a new one using (which we have to define ourselves, since the pool doesn't know anything about the items it creates). returns an item back to the pool. Things to keep in mind: An operation without synchronization can only be truly atomic if it translates to a single processor instruction. Such operations don't need locks and won't cause issues when called concurrently (even the write operations). There are only a few atomics, and they're all found in the package: Each atomic type provides the following methods: Numeric types also provide an method that increments the value by the specified amount. All methods are either translated into a single CPU instruction or are otherwise guaranteed to be atomic, so they are safe to use from multiple goroutines. The composition of atomics is always non-atomic: A bulletproof way to make a composite operation atomic and prevent race conditions is to use a mutex: Sometimes you can use an atomic type instead of a mutex to exit early: If your concurrent program uses channels or custom types with synchronization methods like , you can use those in your tests. This way, your tests won't be much more complicated than if the code were synchronous: If there aren't any suitable synchronization "handles" in the code you're testing, you can use the package. It exports two functions: runs an isolated bubble. The bubble uses a fake clock, and you can manually control goroutine synchronization with . blocks until all goroutines in the bubble — except the one that called — have either finished or are durably blocked. This lets you wait for a specific goroutine to finish or get blocked, so you can check the program's state: The fake clock in move forward only if: ➊ all goroutines in the bubble are durably blocked; ➋ there's a future moment when at least one goroutine will unblock; and ➌ isn't running. Thanks to this, time-dependent tests run instantly: The following operations durably block a goroutine: Blocking on mutexes, I/O, or system calls is not considered durable, and the bubble can't handle them. At the hardware level, CPU cores are responsible for running parallel tasks. At the operating system level, a thread is the basic unit of execution. There are usually many more threads than CPU cores, so the operating system's scheduler decides which threads to run and which ones to pause. At the Go runtime level, a goroutine is the basic unit of execution. The runtime scheduler runs a fixed number of OS threads, often one per CPU core. There can be many more goroutines than threads, so the scheduler decides which goroutines to run on the available threads and which ones to pause. The scheduler keeps switching between goroutines to make sure each one gets a turn to run on a thread, instead of waiting in line forever. This is how Go handles concurrency. Goroutine scheduler The goroutine scheduler's job is to run M goroutines on N operating system threads, where M can be much larger than N. Here's a very simplified version of it's algorithm: The number of threads running Go code is controlled by the environment variable or the function. A goroutine is a structure that starts out using about 2 KB of memory, mostly for its stack. The stack can grow if needed. Since goroutines are so lightweight, you can run tens of thousands or even hundreds of thousands of them on a small machine. To troubleshoot concurrent programs in production, we use metrics, profiling, and tracing. Metrics show how the Go runtime is performing, like how much heap memory it uses or how long garbage collection pauses take. Each metric has a unique name and a value, which can be a number or a histogram. You can use the package to get a complete list of metrics or check the values of specific ones: In practice, people rarely do this manually. Instead, all metrics are automatically exported using Prometheus or OpenTelemetry libraries. Profiling helps you understand exactly what the program is doing, what resources it uses, and where in the code this happens. Go uses a sampling profiler that's suitable for production. The most commonly used profiles are CPU, which shows how much processor time each function uses, and heap, which shows how much heap memory each function uses. Goroutine, block, and mutex profiles help identify problems related to concurrency. The easiest way to add a profiler to your app is by using the package. To collect a profile with the given name, call the endpoint. To view the collected profile, use the utility: You can also profile manually: Tracing records certain types of events while the program is running, mainly those related to concurrency and memory. When the profiling server from the package is running, call the endpoint to collect a trace. To view the results, use the utility. You can also collect a trace manually: You can set up automatic tracing with a sliding window that's limited by size or duration. This is called "flight recording". It lets you always keep a recent trace available in case something goes wrong: We've covered a number of Go tools for writing concurrent programs: If you like the book, please recommend it to your friends or colleagues. If you're interested, check out my other books and projects . I'm glad you finished the book. Thank you, and I'll see you next time! (bidirectional): for reading and writing (default); (send-only): for writing only; (receive-only): for reading only. Checks which cases are not blocked. If multiple cases are ready, randomly selects one to execute. If all cases are blocked and there is a default case, executes it. If all cases are blocked and there is no default case, waits until one is ready. Reader : Reads input data from a file, database, or network. N processors : Transform, filter, aggregate, or enrich data using external sources. Writer : Writes the processed data to a file, database, or network. Avoid concurrent data modification (typically by using channels). Synchronize access with mutexes. Use only atomic operations. Check if the assumed (old) state matches reality. If it does, change the state to new. If not, do nothing. When multiple goroutines are modifying the same data. When one goroutine is modifying the data and others are reading it. / lock and unlock the mutex for both reading and writing. / lock and unlock the mutex for reading only. If a goroutine locks the mutex with , other goroutines will be blocked if they try to use or . If a goroutine locks the mutex with , other goroutines can also lock it with without being blocked. If at least one goroutine has locked the mutex with , other goroutines will be blocked if they try to use . Calling acquire takes a free slot. If there are no free slots, acquire blocks the goroutine that called it. Calling release frees up a previously taken slot. If there are any goroutines blocked on acquire when release is called, one of them will immediately take the freed slot and unblock. There are two goroutines — G1 and G2 — and each one can signal that it's ready. If G1 signals but G2 hasn't yet, G1 blocks and waits. If G2 signals but G1 hasn't yet, G2 blocks and waits. When both have signaled, they both unblock and continue running. The barrier has a counter (starting at 0) and a threshold N. Each goroutine that reaches the barrier increases the counter by 1. The barrier blocks any goroutine that reaches it. Once the counter reaches N, the barrier unblocks all waiting goroutines. unlocks the mutex and suspends the goroutine until it receives a signal. wakes the goroutine that is waiting on . When wakes up, it locks the mutex again. should return a pointer, not a value, to reduce memory copying and avoid extra allocations. The pool has no size limit. If you start 1000 more goroutines that all call at the same time, 1000 more buffers will be allocated. After an item is returned to the pool with , you shouldn't use it anymore (since another goroutine might already have taken and started using it). reads the value of a variable. sets a new value. sets a new value (like ) and returns the old one. sets a new value only if the current value is still what you expect it to be. A blocking send or receive on a channel created within the bubble. A blocking select statement where every case is a channel created within the bubble. Calling if all calls were made inside the bubble. If there's a free thread, assign it a goroutine from the queue. If a running goroutine gets blocked (for example, while reading from a channel), put it back in the queue and assign a different goroutine to the thread. If a running goroutine gets stuck in a syscall, start a new thread to run other goroutines until the blocked goroutine finishes the syscall. Check the running goroutines every 10 ms. Preempt long-running goroutines and return them to the queue to prevent starvation. Goroutines for running concurrent tasks. Channels and select as flexible communication tools. Timers and tickers for working with time. Context for canceling operations. Wait groups for synchronizing goroutines. Mutexes to prevent race conditions. Condition variables for signaling events. Once for safe one-time initialization. Pools to reduce garbage collector load. Atomic operations.

0 views
Sean Goedecke 2 weeks ago

You should all be asking way more questions

When someone is explaining something to me, I ask on average one question every thirty seconds. I’m sure this is frustrating to some people, but it’s actually a good habit and you should do it too. Most of the questions I ask are very short, and require very short answers. Typically I’m asking for confirmation: “so when you say X, you mean Y?”, or “this X is the thing you mentioned earlier when you were saying Z?“. The point of these questions is to make sure I understand. A small misunderstanding early on balloons into a big misunderstanding later, because all the things you misinterpret based on the first misunderstanding will become misunderstandings in their own right, and so on. That’s why you can’t just wait until someone’s finished talking and ask all your questions at once 1 . I think most people don’t ask questions because they’re not really trying to understand: they’re happy to just trust that the person they’re talking to knows what’s going on. This goes double when that person is a well-respected or more senior engineer. However, once you have skin in the game , that all changes. When you’re in a position where you are the one responsible for success or failure — where you are going to have to go away after the conversation and take a bunch of concrete actions — you will find yourself wanting to ask many questions. When someone’s explaining a plan to me at work, I am usually building it in my head: visualizing the specific lines of code that would need to be written in order to implement it. I’ll sometimes handwave the details for solved problems (for instance, I might say “send emails like subsystem X of the same service that also sends emails”), but at minimum I’m planning out: If I hear something that is suspiciously vague (for instance, someone describes service X as “storing data” when I know service X only talks to an ephemeral Redis store), I will immediately ask “hold on, how is that going to work?” Usually that indicates a missing dependency (e.g. service X has to make an RPC call to service Y, which does have a persistent database), which often suggests design changes (for instance, moving some or all of the functionality into service Y). Sometimes questions like these will uncover a fundamentally unworkable feature of the design. This usually happens when a plan fails to take into account some wicked features . I remember many years ago a neighboring team built a complex event-driven system that was very elegant but made it completely impossible to silo customer data in a single datacenter. Because our company had “all your data lives in a single location” as a flagship feature, this system just did not work and had to be effectively abandoned. If you’re involved in any kind of technical leadership role, you will have to do this work eventually. Doing it as early as possible — i.e. in the conversation where someone is describing the plan to you — can save hours or days of wasted implementation. Often this is enough to turn a failed project into a successful one . Maybe you only work with reliable engineers who can be trusted to make all the right decisions. You can thus let them talk without interruption, because it doesn’t really matter if you have a detailed understanding of what they’re saying. That’s great! But these days you probably also have colleagues that are inherently unreliable: AI agents. I mostly work with GPT-6-Astra and Claude Opus 5.5. These are good models that don’t often make code mistakes: when they set out to do something, they usually do it 2 . But they make design mistakes all the time. They assume that two services can talk to each other when in fact they can’t, or they forget about the fact that their code has to run both in the cloud and on-premises, and so on. This is mostly due to the lack of continuous learning : you didn’t know this stuff on your first day either, but you had plenty of time to pick it up. Language models are always on their first day. You should be absolutely peppering AI agents with questions. I constantly ask things like: There will probably come a day when I always get sensible answers to these questions that convince me the model knows what it’s doing. But today is not that day. About half the questions I ask get answers that convince me 3 the model has made a mistake: it should have reused the existing subsystem for X, or authed to Y differently, or kept the Z implementation simple, and so on. When this stops happening, I’ll stop asking questions (and try and see if my company will still be willing to pay me to occupy more of an architect role, I suppose). You should all be asking way more questions. In other words, you should all be trying harder to actually understand what you’re hearing . Don’t trust that the person (or AI model) you’re talking to knows what they’re doing, even if you think they’re smarter than you. Nobody understands complex software products. If you have deep domain knowledge of any area of a codebase, you will routinely find yourself correcting powerful AI models and principal engineers. Sometimes questions you have will be answered later on, but in my experience this is more about questions like “what are the broader consequences of X”, not the simple confirmation questions I often interrupt to ask. Your experience might be different if you’re working in a different domain or with a different language. I know some of these might sound like leading questions that would trigger sychophancy, but in my experience good coding models are very happy to robustly defend themselves. How data needs to flow between services (what kind of data, and how it will travel over the wire) How services will communicate with each other (e.g. how will they auth?) What data needs to be persisted, and where it’s going to live Do we do X elsewhere in this codebase? Does service Y really support this type of authentication, or are you assuming we’d have to go build that too? Is this subsystem you built necessary to satisfy requirement Z, or does it in fact satisfy some other requirement you assumed? Why do we need to update the interface for A? Why do we need to touch this file? Isn’t that unrelated to the change? Sometimes questions you have will be answered later on, but in my experience this is more about questions like “what are the broader consequences of X”, not the simple confirmation questions I often interrupt to ask. ↩ Your experience might be different if you’re working in a different domain or with a different language. ↩ I know some of these might sound like leading questions that would trigger sychophancy, but in my experience good coding models are very happy to robustly defend themselves. ↩

0 views
ava's blog 3 weeks ago

a source of drain

I used to have a three screen setup on my desk. At some point, I removed the third screen because I felt weirdly overwhelmed. There was always something to see, to keep track of, to click on, to watch or to adjust while I played or chatted. I wanted more focus on fewer things. Removing the screen really helped me because it restricted what I see at once, but now, maybe two (?) years later, I notice I am running into a similar issue again with just two screens. The way I have been using my laptop(s) hasn't been beneficial to me lately in that regard. I can't tell if it's a phase, or simply age, but quickly switching between tasks and contexts after 5-20 minutes on each, digitally, seems a lot more draining than it used to be. And I've developed digital habits that facilitate that to the extreme. On a typical work day, I might have work for 20-60 minutes before I run out of things to do (underutilized worker good and fast at her job). Then I see what else I can do in the meantime. I'll probably check Matrix, read several channels, then check my RSS reader, open a few links in a new tab. I'll check the Bearblog Discover feed, open a few things from there as well. Then I check work again. Afterwards, maybe I check my private email, reply to one, and become distracted again. I start reading the first article, but might get distracted/bored/feel my focus wane after half of it, so I switch to something else - like a message I got, or another work thing came in, or I'll start reading another article. When I feel myself getting tired from it, I jump again. Whenever I dedicate myself to something - like studying, reading, writing a longer blog post, gaming - I still have so many interruptions because I check another message, or I have to check my work emails again, or I put on a new video or music, or interrupt that one to put another one on to continue the other one later, or I answer another email again... it wasn't always this way, but now it is. That doesn't even include all the physical interruptions when I have to get up from the desk. That behavior results in so many articles and PDFs left at different points, several YouTube videos paused, several things opened to check later, ticking off/closing one thing just for it to replaced by another thing, blog posts at several stages of completion (instead of my usual writing-until-completed) and feeling absolutely drained. I do finish most stuff I read or watch within the same day or two days, and I get a significant chunk of things done that I planned to do; but it weirdly does not feel that way, because I chipped away at it all in micro-increments, instead of sitting down and properly working at it for however long it takes, uninterrupted, in a flow, and then having a proper sense of closure and possibly accomplishment before moving on to the next thing. It just always feels like playing catch-up instead, or tying up loose ends, or doing the bare minimum to keep everything afloat. In the past, I was really convinced that if I everything I needed or wanted to do was easily accessible (open) at all times, it would allow me to freely bounce between them and do all of them a little, getting progress in on everything, and feeling behind in nothing. If I needed a break from studying, why not use that break to finish that informative video, or make some progress on that other lengthy PDF, or answer emails, etc.? It initially feels productive because you think you don't waste time. You keep bouncing and at the end of the day, you can list all these things you get to tick off or at least made progress on. For me, that advantage or feeling definitely didn't last, and now I crave the opposite. It just feels like I do everything a little, but nothing fully. I miss the focus, the flow, the dedication. It's been more difficult for my brain to keep jumping between different mediums and laptops and browser spaces and tabs all the time. I need more time to adjust and to get back to where I was, and it affects my attention span, my resilience to work through stuff, and my energy levels. And I miss proper breaks that are breaks and not just "briefly looking at another thing I have to finish". It feels like I do so little because so much is still open or not entirely done or only has been worked on for about 10-20 minutes that day, but it is simply spread out between so many things, with so many interruptions. This way, nothing feels like it is truly getting 'done' or making significant progress, even when it is true. At this point, I'd rather dedicate 2 hours to answering and sorting emails and have a clear inbox, than deal with 1-2 every day and it just feels never-ending (especially with newsletters). The way I set up Zen browser probably added to it, which is ironic given its name. But: I had several things permanently open in the Essentials, I had some things pinned at the top of the specific space, and I had at least 5 spaces dedicated for specific open tabs. While I do have compact mode enabled so the browser is essentially in full screen without a top or sidebar, I kept getting distracted and furthering the above bad habits that were really draining anyway. But it's not just the setup; since I had a bit of a rough time a few weeks ago and still occasionally struggle a bit, that also probably led to escapism through online stuff. Craving as much stimulation as possible, no breaks or boredom so I don't have to think or feel about certain things, and so on. Behind bad online habits is usually a difficult time in real life. I really need a stronger focus now, more self-imposed limits on what is happening at the same time, and less opportunities for me to jump between everything I needed or wanted to do. I've now removed most things I had put in Essentials, deleted several spaces, finally closed some tabs I had saved for later, and have closed others that were permanently open but that I should visit manually and then close when done, instead of giving myself constant easy access. I'm also using Virtual Desktops again, so I can better separate when I truly just want to focus on a specific task without all the other things open or accessible. If needed, I can also shut my laptop screen or disable it so I only have my one external screen, or take it from the setup to somewhere else, or even go get my old laptop as a separate device :) It really is a curse sometimes to have so much of your life bundled up in the same thing, as this portal to it all; from work and socializing to games to blogging to studying to other entertainment, and it's all in the same space, ready for you to jump between it all and feel like doing much of neither of it, starting one thing but your mind wandering to the other, over and over again, and that just going on the entire day, while so much of it feels equally urgent... Published 22 Sep, 2026

0 views
Stratechery 3 weeks ago

Frontier Overhangs

There has been, over the last week, what I think is a healthy debate about the philosophy and psychology that undergirds the views of meaningful segments of the AI community, particularly those obsessed with doomsday scenarios. It is, in the end, difficult to reason with a philosophy that grants equivalent moral weight to not just all beings — human or not — who exist today, but who may ever exist in the future; this tilts the scales in such an absurd fashion towards safetyism that innovation is impossible and freedom is intolerable. Worse, it taps into the psychology of religion, where dissent is not brooked and questioning the premise is heresy. I guess that makes me a heretic then: I reject the premise in favor of doubt in our ability to foresee the future, combined with faith in humanity figuring things out along the way. If that leads to the most fantastical doomsday scenario, then I think the State of New Hampshire said it best : I’m referring, of course, to the question of Effective Altruism and the extent to which it is intermingled with Anthropic and CEO Dario Amodei’s insistence that We Must Pace the Frontier . I expanded on my objections to some of the philosophical implications in the last | two episodes of Sharp Tech , but Stratechery is a site about strategy and technology, and those angles deserve examination as well. Three months ago I explained in Anthropic’s Safety Superpower how the company’s genuine belief in its safety rhetoric conveniently gave it license to pursue extraordinary goals that aligned with its business interests, including aggressive attempts to disintermediate software, collect sacrosanct customer data, and secretly sabotage would-be competitors. This analysis is in a similar vein but from the opposite direction: “Pacing the Frontier” is framed as — and I believe motivated by — a concern about safety, but it also happens to address several distinct problems faced by the frontier labs. These problems take the form of overhangs that have formed by virtue of how rapidly models are improving; slowing model improvement would reduce the overhangs. It was six months ago that I wrote Agents Over Bubbles , where I argued that the agentic paradigm, which kicked off with the release of Opus 4.5 in late November 2025, was so incredibly capable and so incredibly token hungry that we were not in an infrastructure bubble: we really do need all of the compute that is being built. I do, as a matter of my job, talk about things that I do not necessarily experience personally; the canonical example is my long-running analysis of and advocacy for advertising as a business model, despite the fact I run a subscription business. I mention this because I developed this take on agentic AI before I dove headfirst into agentic coding for myself, and just as well: I have been so taken by the possibilities of making software for myself that I have started multiple new projects over the past few months, and I’ve even finished a few of them! It’s thrilling, and the possibilities seem endless. Needless to say, I believe this more than ever. At the same time, there is one part of that Article that I’ve wavered on, and that is the importance of model company harnesses: Specifically, I noted above that what made Opus 4.5 compelling was not the model release itself, but changes to the Claude Code harness that made it suddenly dramatically more useful. What this means is that model performance isn’t the only thing that matters: the integration between model and harness is where true agent differentiation is found. This is a very big deal when it comes to figuring out the future structure of the AI industry and where profits will flow, because profits flow away from modular parts of the value chain — which are commoditized — and flow towards integrated parts of the value chain, which are differentiated…It follows, then, that if agents require integration between model and harness, that the companies building that integration — specifically Anthropic and OpenAI — are actually poised to be significantly more profitable than it might have seemed as recently as late last year. And, by the same token, companies who were betting on model commoditization may struggle to deliver competitive products. One of the projects I’ve undertaken over the last month or so actually entailed — at least in one of its permutations — building my own harness, and it’s doable! It also is very difficult, at least with my level of agent-mediated capability; that noted, my go-to example in that Article about how much harness-model integration matters was Microsoft anchoring its new E7 enterprise offering around Claude Cowork, but CEO Satya Nadella broke the news in a Stratechery Interview that that was only a temporary state of affairs: We’re using the same harness that we use in GitHub and the same thing in security, too. So we have the same harness that’s a multi-model harness in which we will rotate through — obviously MAI by default gets trained in our harness, but we will have GPT, we will have Anthropic in there and any open weight model. We will allow anyone to take any of the models they fine-tune or build. In fact, they can take an open weight model from Fireworks, tune it, put it into Copilot, no problem. It took a while for Nadella’s claims to reflect shipping reality, but sure enough you can now choose a model for Copilot Cowork ; it will be up to end users to determine how competitive Microsoft’s harness is with Claude’s, but clearly the harness and the model can be different things. This is actually a more meaningful development than it might seem, for reasons that go back to the theory of integration and modularity put forward by the late Clayton Christensen; from The Innovator’s Solution : The left side of figure 5-1 indicates that when there is a performance gap — when product functionality and reliability are not yet good enough to address the needs of customers in a given tier of the market — companies must compete by making the best possible products. In the race to do this, firms that build their products around proprietary, interdependent architectures enjoy an important competitive advantage against competitors whose product architectures are modular, because the standardization inherent in modularity takes too many degrees of design freedom away from engineers, and they cannot optimize performance… Once their requirements for functionality and reliability have been met, customers begin to redefine what is not good enough. What becomes not good enough is that customers can’t get exactly what they want exactly when they need it, as conveniently as possible. Customers become willing to pay premium prices for improved performance along this new trajectory of innovation in speed, convenience, and customization. When this happens, we say that the basis of competition in a tier of the market has changed. The pressure of competing along this new trajectory of improvement forces a gradual evolution in product architecture, as depicted in figure 5-1 — away from the interdependent, proprietary architectures that had the advantage in the not-good-enough era toward modular designs in the era of performance surplus. Modular architectures help companies to compete on the dimensions that matter in the lower-right portions of the disruption diagram. Companies can introduce new products faster because they can upgrade individual subsystems without having to redesign everything. Although standard interfaces invariably force compromise in system performance, firms have the slack to trade away some performance with these customers because functionality is more than good enough. One of my go-to examples in Anthropic’s Safety Superpower was the company’s decision to predicate Fable usage on Anthropic holding onto all customer data for at least a month; this was a big deal, and I argued at the time that Anthropic was making a bet that its models were good enough to convince enterprises to give up on zero data retention: It’s pretty significant, I think, that Anthropic is declaring that not retaining data is no longer an option, at least if you want access to their best models. Yes, today, that retention is for safety purposes only, and not for training; it’s plausible, however, that Anthropic’s lead becomes so significant that they quietly announce that they are going to train on that data as well, and companies will feel they have no choice but to go along. That additional training data, of course, will only further increase Anthropic’s lead, and all of this will be justified because Anthropic has already clearly decided they are the only ones who can be trusted to be in charge. In fact, Fable wasn’t good enough: customers pushed back, and Fable usage stayed relatively low ; when Fable 5.1 was released, the Anthropic-gets-to-keep-your-data provision was gone . This is evidence of Christensen’s theory in action: customers demonstrated the willingness to base their model-choice decision on something other than pure performance, namely, data retention policies. This doesn’t, in and of itself, suggest that new model capabilities aren’t desired; it does, however, suggest that current model capabilities are “good enough” for customers to not do whatever is necessary to get access to the cutting edge, which reduces the value of the cutting edge to its proprietors, and gives credence to the strategy of Microsoft and others focused on separating harness and model. Pure capability no longer translates directly into a moat. The modularization of models and harness explains why the frontier labs have what I called an economic imperative to own end user touchpoints; again from Anthropic’s Safety Superpower : It has long been clear to me that the frontier labs have the economic imperative to move closer to the user. If you own the user touchpoint, then you have meaningful lock-in, and the best way to own the user touchpoint is to be the canvas for everything they need to do. This, by extension, means that the frontier labs are on a collision course with software companies: it’s software that owns the user touchpoint, and it’s in the frontier labs’ long-term interest to not simply be a commodity input into software but to simply replace software outright. The key for the frontier labs, then, is to build those user touchpoints while they have superior capabilities. However, this is where Meta’s recent launch of Muse is a bearish signal. Muse is, by a significant margin, the best and most approachable personal agent product I have tried. Meta deserves a tremendous amount of credit for the product work they have put in, as well as the massive infrastructure commitment entailed in providing users with a very capable virtual machine for free. Oh, and of course they deserve credit for the Muse Spark model undergirding Muse. That noted, Muse Spark 1.3, the most advanced Meta model, is still not state-of-the-art, and that is the bearish signal: it is good enough for a very good personal agent product, and critically, a personal agent is much stickier than a chatbot. Once you have put all of your information into a personal agent and actually incorporated it into your day-to-day life, it is much more of a challenge to change to something else. This is in contrast to Codex/ChatGPT and Claude Code: yes, you may have developed your own set of skills and understanding of how each harness works, but at the end of the day the relevant artifacts (i.e. your code) are in GitHub, and it’s not that much of a lift to point a different agent and harness at those artifacts if the alternative is better and/or cheaper (or doesn’t want to keep all of your data). In short, model capability is good enough that compelling products — products that actually have moats — can now be built, and from a business perspective it would do Anthropic and OpenAI good to devote more of their resources to actually building such products. In July I wrote Who’s Afraid of Chinese Models , where I argued that nearly everyone’s understanding of the threat Chinese models posed to the frontier labs was overstated, and an artifact of demand exceeding supply. A world with sufficient compute is one where intelligence is a commodity, and in commodity markets margin comes from a superior cost structure, which I would expect the leading model providers to have, in part because they have a meaningful lead in scaling and can apply superior AI to their infrastructure. I wrote: All of this is to say that I think the reaction to Kimi and Chinese models generally is pretty over-blown, at least from an economic perspective. Right now there is a price umbrella that is downstream of the lack of compute; I highly doubt that Chinese models are cheaper to serve on a marginal cost basis, they just seem cheaper because Anthropic and OpenAI are so supply constrained that they are charging far more than they would if there were sufficient supply to meet the demand for intelligence. A price umbrella is another way to say price overhang. OpenAI and Anthropic charge high prices because they can: there is so much demand for their products at their current price levels that they can barely keep up. At the same time, a huge amount of OpenAI and Anthropic’s supply does not go towards inference, but rather training, reinforcement learning, and R&D: that is compute that does not go towards reducing their price overhang. If they slowed down progress they could re-allocate computing and fully capture the market. I reiterated above that I don’t think that there is a bubble in terms of compute availability, but I noted in Nvidia’s Risky Business that there might be a timing problem: This might not cost Nvidia anything in the end: if AI revenues truly take off, then the debt markets will open back up, and ultimately companies will go back to funding infrastructure investment through free cash flows. Right now, however, is the danger zone, as hyperscalers blow through the debt markets and Google at least starts to tap equity. To the extent Nvidia competes through novel funding mechanisms that, at the end of the day, draw on things like insurance floats and pension funds and other long-run liabilities that are the bread and butter of the asset managers the company is partnering with, the risk — unmarked, unlike equity — is considerably higher. That’s why I started with 1870 and Cooke’s ill-fated agreement with Northern Pacific. Yes, the upside the deal afforded Cooke was incredible, but it was incredible for a reason: it was very risky, and pioneering new funding mechanisms only served to spread the pain when it all blew up. It’s one thing to spend all of your free cash flow; it’s another thing to tap the debt markets. And, beyond that, it’s a completely new nerve-racking thing to bring safety-seeking assets to bear. AI better deliver before it’s too late. Anthropic is telling investors that it is profitable, but that comes with a big caveat; from the Financial Times : Anthropic has told its backers it will be profitable this quarter, as it moves to allay investor concerns about the aggressive cash burn of frontier AI companies ahead of its blockbuster initial public offering. The company has told a small group of shareholders that its adjusted operating income will be positive for the second consecutive quarter, according to multiple people with knowledge of the matter. The measure strips out costs including stock-based compensation. Anthropic’s gross margins are above 80 per cent before accounting for revenue shared with distribution partners, including Amazon, and the cost of training its models, according to two of the people. Excluding stock-based compensation is a pretty big caveat, but to be fair, the concern in terms of a capital overhang is cash; the bigger issue is the exclusion of training costs, which are massive (and which, in a true depiction of gross margins, would count as depreciation). That is the part that needs to be covered by revenues before the world runs out of capital, which is to say that Anthropic would be fine if only they didn’t have to pay for training! As it stands, both Anthropic and OpenAI and the hyperscalers and neoclouds need AI revenue to increase dramatically; there is a limit to available capital, and finding that limit will end badly for everyone depending on outside capital to fund future buildouts — even if it by no means would mean the end of AI. I made pretty clear at the beginning that I fundamentally disagree with the framework that many in the AI safety movement operate under; that doesn’t mean I don’t think that AI safety is a serious issue, or that there aren’t already real risks. Indeed, one of the implications of there being a capability overhang is that cybersecurity risks are very real, and are going to be a massive challenge in the next few years. From Autonomy and Innovation : The expected value for a hacker’s automated attack is always positive. If the offensive agent finds a vulnerability and creates an exploit, and that exploit fails or is itself buggy, then nothing has changed about the status quo: the exploit doesn’t work (or, perversely, makes the original vulnerability larger by virtue of its own bugs); if the agent executes the exploit perfectly, meanwhile, the attacker has gained access to the system. The attack only needs to work once for the entire endeavor to have a positive payoff. The challenge for the defender, on the other hand, is that they need to keep the software in question working correctly, and not make the situation worse. This means that any automation has a negative expected value: successful automated vulnerability discovery and patching preserves the status quo, i.e. the software is not hacked. However, any unsuccessful patches make the situation worse, either by breaking the software or by introducing new vulnerabilities. The agent only needs to fail once for the entire endeavor to have a negative payoff. This is the dynamic that leads to the exact situation Dalton describes, where offensive actors are fully automated while defensive systems, even if they use AI, will be incentivized to keep a human in the loop, and no human in the loop will be able to keep up with fully automated agents. Truly effective defense will mean truly trusting agents to act autonomously, but most companies won’t do that until they are forced to by regular and unremitting hacks by fully autonomous attackers. First, it’s worth pointing out that this risk is not alignment risk, at least as that term was traditionally defined: a model being directed to do bad things is aligned; suggesting that models ought to know what is good or bad is an entirely different consideration, and I think it is very problematic that these questions have been conflated. The fact of the matter is that LLMs are, if anything, too obsequious; there simply isn’t any evidence of LLMs having a will or operating with malevolence. Second, arguing against progress because of cyber risk was relevant before the agentic paradigm; at this point the genie is out of the bottle — and open weights models capable of attacks are already here. Third, this reality actually makes the case for pushing the frontier, not pacing it. The capability overhang is entirely on the offensive side: it’s defenders that need models that are not only good enough to mount a defense, but to do so in an entirely automated way that doesn’t bring down the infrastructure being attacked. We’re not there yet. In other words, when it comes to the tangible safety risk that exists today — bad actors using aligned LLMs to attack infrastructure — pacing the frontier actually increases the window in which bad things can happen. This isn’t, of course, what Amodei and the doomers are talking about: they are worried about recursive self improvement enabling AI to improve itself, independent of human control, and I’m open to debating the risks that might result were there actually a tolerance for debate, instead of an insistence on acquiescence. And, of course, all of the frontier labs are free to pace their own progress. That they won’t is a reminder that this is personal: Anthropic exists because Amodei and his cofounders didn’t trust Sam Altman and OpenAI; I’m not sure it’s a coincidence the demand to pace the frontier came when the frontier was, for the first time in a while, set by OpenAI. Indeed, that’s probably the overhang that matters most of all: competition. Anthropic is fine with Anthropic being in the lead; anyone else requires government intervention. That there are safety arguments to be made that just so happen to align with their need for more time to build a moat is, I’m sure, but a sign from Silicon Valley’s newest god to its self-ordained priesthood.

1 views

Superpowers 6.4

I'm pleased to announce the release of Superpowers 6.4. This release adds new support for three coding agents: Meta's Muse, OpenCode 2.0 and Qwen Code. Every new model behaves just a bit differently than the ones before it. As the frontier models get more capable, they also develop new quirks. A lot of the work in this release is meant to help the latest models use Superpowers effectively. We've done some work on and to help steer your agents away from skipping steps or doing significant unrequested work. Superpowers offers two plan execution styles. One is "Subagent-Driven Development", which uses the primary agent session as an orchestrator, dispatching all implementation and test tasks to right-sized subagents with limited context and limited mandates. The other is "Native (inline) Execution", which dates to the earliest days of Superpowers. It was originally intended for a human-in-the-loop flow where you acted as a go-between for two agentic sessions. As the frontier models have improved, they've gotten better at executing work inline without getting distracted or losing the plot. Superpowers 6.4 reinvents Native Execution (the skill) for today's more capable models. We've been running extensive evals comparing Subagent-Driven Development, Native Execution and "bare metal" implementations with Claude Code. With a frontier model, Native Execution is about twice as fast and half as expensive as Subagent-Driven Development. Building without Superpowers gets you results faster and cheaper, but in our testing, even with Astra and Fable, builds without Superpowers implementation skills are significantly buggier than builds using Superpowers. We are, of course, working hard to make Superpowers faster and cheaper to use. We're currently testing out some fairly big changes to the planning skills that we hope to debut soon. 6.4 also introduces a new skill, which you can use when a session has done something you didn't expect. It knows how to dig into your coding agent's history to understand what Superpowers was doing and can help you put together a good bug report. We've done a little bit of work to tune the code reviewer prompts to apply a bit more "what would a reasonable person think?" judgment. This should help overzealous models calm down and converge rather than work themselves into aggressive review loops. You can read the full release notes at https://github.com/obra/superpowers/blob/main/RELEASE-NOTES.md If you install Superpowers from the Superpowers Marketplace ( https://github.com/obra/superpowers-marketplace ) or from OpenAI Codex, 6.4 is available in your coding agent today. Anthropic and other providers update their plugin marketplaces on their own schedules.

0 views
Dan Moore! 3 weeks ago

When is software done?

This is something I’ve been thinking about recently. First, let me clarify that when I say software being done, I mean a particular feature. I don’t mean an entire package of software. A standalone application or SaaS software package is never actually done; there’s always new desires and demands from human beings, and all pieces of software must expand until they either send email or build their own messaging inbox because they can’t be bothered to interface with email. The most successful communications protocol in history; but I’m not bitter. I’d argue that a holistic view of software success actually looks at the user and asks when a certain percentage of them — say, 7% or 15% — are using it. What that exact percentage should be is a matter of art and depends on the feature being built. Not everyone is going to use every feature of every piece of software. You won’t always use every feature of software you write for yourself. But people are using this feature, and critically, they were able to discover it, and they’re happy that they discovered it, or at least not actively angry about it. Discovery, usage, and happiness. (Look ma, it’s a framework!). If you don’t have all three, you’re in trouble. What people mean when they say software is “done” depends on where they are in this picture. If you’re a developer focused on a certain set of features, deep in a codebase, whether you’re writing code manually or using some kind of tooling or agents to write code, you might consider the software done when it’s merged to main, or more likely when it’s deployed. That is, when people can interact with it and learn the code the same way jessitron talks about in this beautiful piece . That’s deployment, but it’s only the first step. If you’re a marketing person, you might consider the software or feature done when you’ve launched it: submitted the press release, sent a bunch of emails about it or otherwise tried to engage people and teach them about it. If you’re a business person, you might look for the first deal that was signed because of this software feature, or maybe the first incremental bit of revenue you can point to. If you’re an end user, you might consider a feature done when you use it for the first time. Each of these is really pointing at one leg of the same framework without necessarily covering all three. So let’s take the “discovery, usage, happiness” framework leg by leg. Discovery. The cost of creating software has decreased drastically (ahem, AI, ahem). And when the cost of one component of something decreases, what happens to the other pieces? I think you can’t lose track of the ultimate source of value, which is: a user discovers this particular feature, whatever it is, uses it, and is happy about it. Discovery means you have to have users, which is why the go-to-market or distribution side of software is so important. After all if someone doesn’t have your entire software package, they won’t be able to find the cool new feature you just shipped. Discovery also means your users need to be able to find the new feature of their software. Lots of options for helping them: There are a lot of different ways to do it, and the truth is that you aren’t going to be able to do just one of these. Inform folks the way they want to be told; everybody wants to be communicated with in a different manner. Usage. After discovery comes usage, which means someone actually trying the feature out, at least a few times. At a base level, it needs to work the way they expect it to. No surprises. If it doesn’t, people won’t get past this stage no matter how well you nailed discovery. But usage isn’t just about whether it works; it’s about whether it’s worth the effort. The friction of using the feature should be calibrated to the perceived value someone will get from using it. Not the value, the perceived value. And not value for you, the software provider, but for the end user. For example, if you are adding a feature to enable better recommendations, don’t have a 100 question survey. Ask one or two questions. Happiness. Happiness is a step beyond usage. It’s someone incorporating the feature into their normal workflow. People don’t use software just to use software; they’re using it to accomplish something in their lives, whether that’s communicating with friends, doing part of their job, or just amusing themselves. Once something becomes part of how they actually get things done, that’s happiness. And that means it needs to not be a constant churn of change. If you push too much churn on them (constantly pushing out features, changing interfaces, tweaking things, and not letting people turn new features off) you’re going to make them dissatisfied, and they’ll never fully incorporate it into how they work. Maybe “happy” is too strong a word. Or maybe you just want people to be content, or at least not upset — it depends on the kind of software you’re using. And it’s worth saying that people hate change. I hate change myself, so some percentage of your users are always going to be grumpy about it. Don’t want to measure happiness just at the moment they encounter the new feature; that’s usage. Some people, early adopters, will be thrilled to be exploring new areas, while others will be pretty pissed off. So you want to gather that data after a certain period of time has passed. Have I ever worked somewhere that has this kind of maturity around it? No. Would I like to? Yeah. I think that if you can close the loop on discovery, usage, and happiness, you can build software that people love, and that becomes a sustainable business. It doesn’t matter how much your competitors churn out software because it’s cheaper to do; you’ll win if you make software that can be discovered, that people use, and that they like. in-app notifications a pop-up on a related feature targeted communications because you’ve seen a user explore related features hopping on the phone with them (if you’re a smaller company) a face-to-face conversation and show people what’s new release notes

0 views
fLaMEd fury 3 weeks ago

Open Tabs August 2026

What’s going on, Internet? If you don’t folllow my Bookmarks through the feed , then here’s the bookmarks from August. Enjoy. For more, check out the bookmarks archive, and subscribe to the feeds if you want these as they happen. Hey, thanks for reading this post in your feed reader! Want to chat? Reply by email or add me on XMPP , or send a webmention . Check out the posts archive on the website. MOUSELING.net - Hyper Text Muck-up Language Solid tips for budding webmasters Welcome to the web we lost Sacha Judd tracing the good internet through X-Files fandom and webrings, and it’s still out there in the corners. How to Fix the Internet Not everything is fine on the Internet, but there’s an opportunity, and it starts with choosing what you pay attention to yourself. How I built Timeframe, our family e-paper dashboard - Joel Hawksley Definitely keen to build one of these for our home! So cool! Conceived in a secure military facility, this Jedi Knight fansite has been running consistently for almost 30 years: ‘It looks really close to what it did back in 1998’ | PC Gamer A community site for Star Wars Jedi Knight: Dark Forces II, still active and still looking like 1998. It’s just a website The humble website 😃 I Wonder How Many is Too Many When it Comes to Adding Pages – The Land Where Turbie Posts How many pages is too many? As many as you want 😃 owls’ guide to webshrines A sweeet practical guide for creating your own “shrines”, mini sites for any one of your interests. Creating a Successful Fansite: A Comprehensive Guide A comprehensive guide for getting started creating your own fansite. Go build one.

0 views
Jason Tucker 4 weeks ago

Inside Quartermaster: A Conversation with Lewis Egley

I've been running Quartermaster on my phone for months now, watching it grow from a solid Sonarr and Radarr companion into something that's trying to handle my entire homelab in one app. When I reviewed it back in the spring , it was already doing more than most apps in this space attempt. Since then, developer Lewis Egley has shipped an enormous amount of work, and the app is now getting a Docker-based companion tool that changes what it's capable of entirely. I sat down with Lewis, virtually and asynchronously (Lewis as you will find is British and I'm located in California so there is an 8 hour time difference and Lewis doesn't sleep much) over Discord, to talk about where Quartermaster came from, what Companion actually does under the hood, and where he sees this whole thing heading. He was candid about the late nights, the pressure of shipping fast in a space that's gotten suspicious of anyone who does, and what it's like going from a private side project to something with a real user base watching every move. If you want to follow along or dig into any of this yourself, the app is on the App Store and the GitHub repo for Companion is open source under MIT. There's an active community over on Reddit and in the Discord server, which is honestly the best place to catch build announcements before anyone else does. Walk me through your path in tech, how'd you go from where you started to building Quartermaster? It's a very good question and sort of cliché if you will :) The world of computers has always interested me, ever since I was young. I started making little programs as a child using Python, and weird little websites I'd show my friends. At the time, people my age didn't understand, but I did, and that was enough. In regards to my professional career, I started as any other young and hungry tech advocate. I got my study on! I majored in Computer Science and got my diploma, and then I started out in the working world. My first ever job was IT 1st line, a helpdesk position. Looking back, I hated it, but it's the only way in. A true rite of passage for anyone who wants to start. Over the years I started picking up skills and moved quickly into cloud infrastructure, building out cloud environments for businesses up and down the UK. That opened the door to DevOps and ultimately software engineering. I built Quartermaster as a personal project and ran it locally for a few months. Then I was speaking with a few guys on a tech forum and they really wanted to test it. They gave me improvement ideas, I built them, and so on and so on. The app launched on the 3rd of July and I haven't looked back since, just a lot of late nights, haha. Is Quartermaster your full-time thing now, or are you building it around a day job? Believe it or not, Quartermaster is not my full-time job. It is, and probably always will be, a side project. I love my full-time job, the companies I get to work with and the people I meet. It has taught me everything I know and I just don't think I'm ready to turn my back on it just yet. What pulled you into the homelab space specifically? Are you running a big stack yourself, and if so what's in it? One word. Community. The ideas people come up with, and everything in between, is honestly captivating. It's odd, because it's essentially its own ecosystem, and once you're in it, you're in it. I've been homelabbing for around three years and every day I'm amazed by what people do. No matter where you look there's something out there for everyone, a dashboard here, a service there. There just isn't anything quite like it. The stack I run is small compared to some. I have a soft spot for networking, so it's Unifi throughout, switches, CCTV, the gateway. For media I run a 16GB ECC box with a 12TB array, which covers around 60% of what Quartermaster supports. Obviously I have to spin the rest up locally for proper testing. Any other apps, side projects, or open-source work people might know you from? No, lol. Long-time lurker, first time going public. I'm a very honest guy who keeps himself to himself, so opening the floodgates on a project like QM as a solo dev has been a very big learning curve. Your day job took you through cloud infrastructure and DevOps before software engineering. How much of Companion's design, the proxy layer, the tiered access, the whole approach, came out of instincts from that world rather than from building consumer apps? Almost all of it, if I'm honest. The consumer app side I had to learn as I went, but the Companion architecture came out of habits I already had. The clearest example is the Docker access model. A consumer app instinct would be a settings toggle, switch on Docker management, done. What I built instead is a ceiling you set at deploy time. The Compose profile you start with decides the maximum access the process can ever have, and every profile boots into read only regardless. The owner can raise the active mode from inside the panel, but only up to the ceiling they already committed to on the host. You cannot escalate past it from the UI, because the UI was never given the authority in the first place. That's just least privilege, and it's the sort of thing you internalise building cloud infra for other people's businesses. Nobody gets prod write access because a toggle in a dashboard said so. The socket proxy is the same idea. Companion doesn't get the raw Docker socket, it gets a keyed proxy in front of it with its own scoped surface. The other bit that came from that world is being blunt in the docs about what the thing can actually see. Docker inspection exposes container metadata, mounts, network layout and anything sitting in environment variables. A read only mount still means Companion can read that file. That is in the README because someone running this at home deserves the same honesty I'd give a client during a design review. Do you see Quartermaster becoming a full home lab platform in its own right, or is Companion still in service of the mobile app? I don't know yet in all honesty, and I'd rather say that than invent a roadmap. Companion came about because I hit the ceiling of what a phone can do on its own. Some things need something running on the box with real permissions, and no amount of clever iOS code gets you round that. So it started very much in service of the app, and it does that job well. Where it goes from here is the exciting part, because it isn't entirely my call anymore. Open sourcing it under MIT means people are already running it for things I never designed it for, and that's brilliant to watch. It's the whole reason I love this space. You put something out and the community immediately finds uses you'd never have thought of on your own. I'm still careful with the word platform, mind. It carries promises about support and longevity, and I want to earn those rather than claim them. But if it keeps growing the way it has, and I can keep it secure and well maintained, then I'd love to see where it ends up. Right now I'm just enjoying building it. You clearly thought hard about the trust boundary here, the proxy, the hard ceiling, the tamper-evident config. Was there a specific moment or scare that shaped that approach, or was it just discipline from day one? Discipline, mostly, though it came from a very specific realisation rather than a scare. When I sat down and properly thought about what I was asking people to install, it focused things fast. Companion holds service credentials and can be granted Docker control on the host. Get the owner session and you don't have a media dashboard, you've got someone's box. Once that landed, a lot of the design decided itself. That's why the setup transfer is short lived and single use. It's why pairing makes you compare words on both screens before you approve anything. It's why the default install is read only, and anything beyond that has to be a deliberate choice you make at the host level rather than a toggle in an app. The bit I'm most pleased with is that you don't have to take my word for any of it. There's a verification script in the repo that runs the boundaries against a live instance. If you're going to ask people to trust something running on their own hardware, they should be able to check rather than just believe you. Homelabbers are all over the map, some just want Home Assistant, Unraid for media, and Immich for the family photos. Others are running full racks with oddball switch configs, learning Proxmox, juggling Tailscale, Twingate, ZeroTier, Netbird, Netmaker. That's a massive range of technical comfort under one roof. How do you think about serving both ends of that spectrum with Quartermaster, and does that split change how you approach onboarding and documentation? The way I've approached it is that the complexity lives in their setup, not in mine. I don't try to guess how someone's network is put together. I just give them enough ways to describe it that whatever they've built, they can point QM at it. So you get a local address, an optional remote one, a home WiFi pin so it forces local when you're in, reverse proxy domains, Cloudflare Access header sets, custom headers, basic auth, and certificate pinning for self signed setups. Tailscale isn't a special mode, it's just an address, because to the app it should be. Someone on a Synology with three apps uses one field of that. Someone with a rack and five overlay networks uses most of them. Same app, same flow. Onboarding does branch, though not by skill level. It branches by how you're arriving. There's a manual path, a Companion path where the server exports your whole stack and the phone imports it in one go, a demo mode, and a restore path. Then you pick your services off a list, and it walks you through connecting them one at a time, most depended on first. There's very little discovery, which is deliberate. Where I'd say I've genuinely got it right is what happens when a connection fails. The Test is the moment everything hinges on, so that's where the work went. Four stages, real error codes, the server's own refusal text surfaced verbatim rather than swallowed, and one tap fixes for the specific traps, certificate trust, SSH host keys, Synology OTP, a port left prefilled on a public domain. You can't save a service that hasn't passed a Test, so nothing sits there claiming to be connected when it isn't. You mentioned there's very little service discovery by design, and that onboarding branches by how someone arrives rather than their skill level. That's a strong opinion, a lot of apps in this space lean hard into auto-discovery as a selling point. What made you bet against that? Mostly that on iOS it doesn't work as well as the marketing suggests, and the failure mode is brutal. The Local Network permission is a one hot prompt. You get it once, and if the user denies it there is no API to ask again. They have to go into Settings and find it themselves. So if that prompt fires during a background dashboard load rather than something the user deliberately did, the request races the prompt, fails as a generic "couldn't connect", and you've burned your one chance. The app actually fires a throwaway probe when onboarding opens specifically to get that prompt out of the way at a moment that makes sense to the user. Then there's the yield. Bonjour browsing on iOS needs you to declare every service type up front in the app's config, and most selfhosted software doesn't advertise over mDNS anyway. Radarr doesn't. Sonarr doesn't. SABnzbd doesn't. So you'd be building it for almost nothing. Subnet scanning is worse. Hundreds of connection attempts, straight into iOS's six per host limit, and a conversation with App Review about why your media app is port scanning. And a big chunk of these users are on Tailscale or behind CGNAT, where sweeping the local subnet finds nothing that matters. So I moved discovery to where it can actually work, which is a box that already has Docker. Companion reads the container list, works out what's running, and reads the API keys straight out of the config files on disk. The *arrs keep theirs in config.xml, SAB in sabnzbd.ini, and so on. The user types nothing. That's real discovery, it just isn't happening on the phone. Plex is the other exception, and it's the same principle. It's account side, not network side. You sign in and Plex tells you every server on your account with every route, so the app picks the best one. Where a proper API exists I'll use it. I'm just not going to guess at somebody's subnet. A lot of the competition in this space, LunaSea, Zagreus, Ruddarr, Unraid Deck, are unitaskers, they do one thing really well. You told me you're not using AI to build any of this, it's all hand-written. That's a lot of surface area to cover solo without leaning on tooling to move faster. What made you go all-in on the everything-app bet instead of picking a lane and doing it well? Because the unitasker advantage disappears the moment you run more than one thing, and almost everyone in this space runs more than one thing. The bet wasn't really "cover everything". It was that the hard part isn't the services, it's the layer underneath them. Getting a connection to somebody's box is genuinely difficult. Dual local and remote addresses that switch on their own, self signed certificates on iOS, reverse proxies, Cloudflare Access, custom headers, per host concurrency limits, a transport that won't follow a redirect and replay your API key somewhere you never configured. That's about thirteen thousand lines of plumbing, and I only had to write it once. Everything after that is a service riding on top of it. A new integration inherits the URL assembly, the auth handling, the certificate pinning, the reachability engine, the four stage connection test, the add form, the backup and restore, all of it. I won't pretend it's cheap though. A new service is still six to eight thousand lines by the time you've done the client, the screens, the mappers and the demo data (needed for Apple review). There's no plugin system, no manifest, nothing schema driven. It's all compiled in. That's linear cost forever and I knew that going in. What I got for it is that each of the 59 services has a real screen and real actions rather than a generic card. The schema driven approach would have let me claim two hundred services that all look identical and none of which actually does anything. I'd rather have 59 that work. Unraid Deck does almost exactly what Companion does, direct Docker control, SSH, local push notifications, no hosted relay, but it stays narrowly focused on just Unraid. Companion's trying to sit underneath a much bigger app that spans dozens of services. What's the trade-off there, and do you think staying narrow like that is actually the smarter long term bet? Narrow is smarter if you're solving one platform's problem. I'd not argue with that at all, and I think Unraid Deck is doing the right thing for what it's for. Companion isn't Unraid specific. It's a Node app talking to the Docker API, so it works against Proxmox, Synology, bare metal, whatever. Unraid is just the best documented install path because that's where a lot of people are. But that generality has a cost. Deck can make assumptions about paths, about the array, about the whole shape of the system. I can't make any of those, so I end up handling Linux host paths generically, Synology's volume layout, socket proxying instead of a raw socket mount, and so on. The trade off is what Companion is for. It isn't primarily a Docker manager, it's the thing that makes setup disappear. It reads your config files, finds your keys, works out you're running three Radarrs on different ports, and hands the phone an encrypted bundle so you type nothing. That job only exists because there's a big app sitting on top of it that needs 59 services configured. A narrow tool doesn't have that problem to solve, which is exactly why it can stay narrow. How do you decide what to actually support in the mobile app? I noticed Companion now has a marketplace, is that the mechanism for covering more of these different setups without you having to hand-build support for every single one in the mobile app and just let Companion handle it? It's a catalogue generated from Companion's/QMs own supported list of what it knows about. There are exactly three hand reviewed Compose starters in there. Everything else is either generated from a template or connect only. And no, it isn't a way round hand building support. I wish it were, but I'd be lying if I said otherwise. The app's list of service types is compiled in. It's a fixed union in the source, it's a key in dozens of exhaustive lookups, and it's the discriminant the app stores things under. There's a hard boundary on import that drops anything not in that list, with a test asserting it does. So if Companion learns about something new tomorrow, it can detect it, deploy it and show it in the web UI, but the phone will not treat it as a service. No card or even screen and certainly no status probe. That's a build time thing and no amount of Companion architecture changes it. There's one escape hatch, and it's honest about it all, the Companion control plane in the app shows containers, stacks, images and events generically, without knowing what any of them are. So something new turns up there the moment you deploy it. But it turns up as a container, not as an integration. How I actually decide what to support is much less interesting than that. It's what people ask for in the Discord, weighted by whether I can get a real instance running to test against. That last bit matters more than people expect. I pulled up the actual Marketplace in Companion. Right now every one of the built-in entries is first-party, curated by you, and the catalog banner says every deployment still starts with a review. But there's already a Sources tab sitting there, empty at zero right now, that lets people point Companion at external Portainer v2 template feeds, and it says outright those entries aren't reviewed by you. That's a real two-tier trust model already built and waiting. Was that the plan from day one, community sources living alongside your own with clearly different guarantees, and how worried are you about someone pointing that at something malicious? I wouldn't say the exact shape of it was mapped out from day one, but the separation was deliberate once the Marketplace started taking shape. There had to be a hard distinction between something included by me and something a user has added themselves. If it appears in the built-in catalogue, I'm putting my name against it. I've looked at it, I know where the image comes from, I know what it mounts and I know what permissions it asks for. An external source cannot inherit that trust just because Companion happens to be displaying it. And yes, a malicious feed is a real risk. A Compose template can ask for privileged mode, mount the Docker socket, mount the host filesystem or run an image that does something completely different from what its description claims. There is no honest way for Companion to turn arbitrary third-party Compose into something safe automatically. That's why external entries are labelled differently and why deployment still starts with a review. Companion shows you what is actually going to be created before anything runs. It doesn't quietly install something because it appeared in a feed. But I don't want to pretend that removes the responsibility from the person deploying it. If you add an untrusted source and then approve a template with access to your host, Companion can't make that decision harmless. The aim is to make the trust boundary obvious and make dangerous requests visible, not to claim I can sanitise arbitrary code from the internet. You mentioned there are only three hand-reviewed Compose starters in the Marketplace right now, everything else is generated or connect-only, and even if Companion learns about something new, it can't become a real integration in the phone app because the service list is compiled in. So what would "other devs contributing to the Marketplace" actually look like in practice, more Compose starters and Sources feeds on the Companion side, or is there a future where the compiled-in service list itself opens up somehow? Right now it means the Companion side. Other developers can publish Portainer feeds, build Compose starters and help Companion understand how something is deployed or detected. If something is going into the first party catalogue, I still need to review it before it gets that guarantee. Alternatively, somebody can maintain their own source and users can choose to add it with the external-source warning attached. What it does not mean today is dropping a manifest into Companion and suddenly gaining a full Quartermaster integration on the phone. The phone app is deliberately much stricter than that. A service type is compiled into the app and tied into the connection layer, storage, backup and restore, onboarding, demo data and native screens. Opening that at runtime would either require an enormous plugin system or reduce integrations to generic cards driven by a schema. Neither gives me what I want from Quartermaster. So in the near term, community contribution means deployments, templates, detection and knowledge on the Companion side. For the app itself, people can request integrations, help me get a test instance running, document strange API behaviour and test the result, but the actual implementation still has to become part of the app and go through a release. I won't say the service system can never open up, but there isn't a hidden plugin SDK around the corner. If I ever do it, it has to produce something that feels like a real Quartermaster integration rather than a web form wearing an iOS skin. A while back a security researcher found serious auth bypass vulnerabilities in Huntarr , and the maintainer banned people who raised concerns about it. It's become the go-to cautionary tale people point to for vibe-coded apps cutting corners. Has that story stuck with you? Does having something like that out there change how you think about your own pace, or your own security process? It's stuck with me in the sense that I've watched how the community reacted to it, and that told me something useful. People in this space care about this stuff. They will look, they will test, and they'll say something when they find it. What it reinforced more than anything is how you handle it when someone does come to you. If a researcher turns up at my door tomorrow with a genuine finding, that's someone doing me an enormous favour for free. The right response is thanks, then a fix, then telling everyone what happened. Closing the door on that is how a small problem becomes a permanent reputation. On pace, it hasn't changed it exactly, but it's part of why the security model went in early rather than later. It's much harder to bolt a trust boundary onto something after people are already running it. Do it up front and you're building on top of it rather than retrofitting round it. There's a lot of noise right now about "vibe coded" apps, and Huntarr's become the example everyone points to, real security holes, a maintainer who didn't seem to understand what he was shipping. Where do you actually sit on that? Do you use AI tools in your workflow, and if so, how do you keep the line between using them well and leaning on them too hard? I sit somewhere that probably annoys both camps. I don't use AI to build Quartermaster. Not the app, nor the Companion. Where I do use it is checking a GitHub URL for a new integration in case an API's changed and I'd otherwise miss it. That's a research task and it's genuinely useful for that. But I think the "vibe coded" label gets used as a lazy shorthand, and it points at the wrong thing. The tool isn't the problem. You can write every line by hand and still ship something dangerous if you've never had to think about a trust boundary. Equally you could use AI sensibly, review properly, and be completely fine. The real question is whether you understand what you're shipping. That's it. Can you explain what you decided, what it exposes, and what happens if someone attacks it? If you can't answer that, it doesn't matter who or what wrote the code. I'll use it to research and to check my blind spots. I won't use it to make decisions I'd struggle to defend afterwards. I've watched you build this out in real time, and you've even put a verification script in the repo so people can check the security boundaries themselves instead of just taking your word for it, which is the exact opposite of what happened with Huntarr. But I know "vibe coding" accusations get thrown around fast, especially with that story still fresh in people's minds. Does being anywhere near that comparison worry you? Has anyone actually come at you with that kind of accusation, and how do you handle it when the work itself is the real answer? It doesn't worry me, no. If someone wants to lob that at me they're welcome to, but they'll be arguing with a repo that's sat there in the open. The security model was designed deliberately, because I've spent years doing exactly this kind of work for businesses and I know what happens when you get it wrong. Least privilege, a ceiling you set at deploy time, read only by default, a proxy in front of the socket rather than handing over the socket itself. None of that is accidental and I can explain every bit of it. But the strongest answer isn't me telling anyone that. It's that I'd rather nobody took my word for it in the first place. That's the entire reason the verification script exists. Run it against a live instance and it tests the setup and authentication boundaries for you. If you think I've cut corners, that's the quickest way to prove it. The repo's open, the app's on the store. Have at it. You've shared some pretty stark numbers on what the hosted notification relay was actually costing you to run. What made you consider dropping it entirely, and how did you land on moving notifications into Companion as a local relay instead? Did that same cost reality shape the new pricing structure for 1.2, and how did you balance that against keeping your lifetime purchasers taken care of? The relay costs got out of hand, plainly. We're talking millions of requests a day through Cloudflare, and that's not sustainable for one person funding it out of pocket. The fix is Companion. Notifications are moving to a local relay, which means people will be able to set their own alerts and run the whole thing on their own box rather than everything routing through infrastructure I'm paying for. That's better architecturally as well as financially, which is the nice version of a cost problem. It's also more in keeping with the whole point of self-hosting. Your stuff, your box. The 1.2 price change is partly those notification costs and partly everything else that's landed since launch. Companion, the custom built macOS version, all of it. I've kept the increase as small as I can and it'll still come in cheaper than the alternatives. The important bit: every current subscription stays at the price you're on until you cancel, and everyone who bought lifetime keeps lifetime. That was never up for discussion. People backed this thing early, before it had proved anything, and they're not getting punished for it. You mentioned QM is a side project on top of a full-time job, and you shipped an enormous amount of work in eight weeks. What does a week actually look like for you right now, and how close have you come to burning out on it? Honestly? Weekends are soaked. During the week it's evenings after work, and more often than not I'm going to bed around four in the morning. Then up for the day job. That's just been the rhythm since launch. My partner hasn't always been thrilled about it, and I don't blame her. But when I show her what's actually happening, the numbers, the people using it, the messages coming in, she's amazed. That helps. It's my passion, and having someone who gets that even when it's eating the weekend makes a big difference. As for burnout, it's real and I won't pretend otherwise. I make mistakes when I'm running on not much sleep. Things slip through that wouldn't if I'd been fresh. That's genuinely where the closed beta community has been unreal. They catch things and they tell me straight when something's broken. When you're a solo dev at two in the morning, having a group of people who care about the project as much as you do is worth more than any amount of extra hours I could put in. I'd have shipped a much worse app without them. You said this is the first project you've taken public after being a long-time lurker. What's that jump been like, going from something you built quietly for yourself to a real app with a user base and an active Discord community? Terrifying, then brilliant, roughly in that order. Building something quietly for yourself is safe. Nobody sees the bad decisions, nobody files a bug at midnight, and if it breaks it only breaks for you. Putting it out means every choice you made alone is suddenly up for discussion by people who know what they're talking about. That first week I checked everything constantly. What I didn't expect was how generous people would be. I'd braced for criticism and what I got was people wanting it to work. Detailed bug reports, feature ideas better than mine, folk explaining their setups so I could understand why something wasn't behaving. The Discord has genuinely made the app better in ways I couldn't have on my own. The other thing is that it's changed how I build. When it was just mine I could cut corners and know where the bodies were buried. Now there are people relying on it, and that raises the bar on everything. Not in a stressful way particularly, more that it makes you take it seriously. I'm still a fairly private person, so going from lurker to having a community around something I made has been a big adjustment. But I'd not go back. Whatever I thought I'd get out of shipping this, the people have been the best part of it by a distance. I'd just add a closing note on that. Quartermaster is ultimately just code running on people's devices. What actually drives the late nights, the new features and everything in between is the community. They keep me going and I couldn't ask for a better one. There's a stereotype that British people apologize for everything, even when it's not their fault, always "ever so sorry" or "terribly sorry" for the smallest things. You've said sorry in Discord more than once for delays on something you're building for free in your spare time. Is that just very British of you, or do you genuinely feel like you owe people an apology? How do you deal with the pressure of a deadline you set for yourself versus one the community's expecting, and which one do you think has more pressure? Bit of both, honestly. It's very British, lol. When someone clicks purchase, or takes time out of their day to message me about a problem, I think they deserve the best service I can give them. That's not me being self flagellating about it, it's just how I think it should work. They've given me something, whether that's their money or their time, and a delay means I've not held up my end. As for which deadline has more pressure, it's mine by a distance. The community are generous. They're patient, they tell me not to worry about it, and nobody in the Discord has ever given me grief over a slipped date. The pressure is entirely internal. I set a date, I want to hit it, and when I don't, that's on me. Which is probably exactly why I keep apologising for things nobody's actually asking me to apologise for. You mentioned "the custom built macOS version" when talking about what's driven the 1.2 pricing changes, so it sounds like Mac already got real dedicated attention rather than just running the iPad build. What does "custom built" actually mean there, is it a fully native AppKit app, or an iPad app with Mac-specific work layered on? And what about Android or Windows, are those actually on the table, or does it make more sense to lean into a solid web app for anyone outside the Apple ecosystem instead of chasing every platform natively? It's the iPad app running on Apple silicon through "Designed for iPad", with a significant amount of Mac specific work layered on top. It's not Catalyst and it's not AppKit. Shipping it is a checkbox. Making it feel like a Mac app was not. There's a whole separate shell for it, a sidebar, a today column, a desk bar, sat behind a flag with a kill switch. Detecting you're even on a Mac needed a small native module, because React Native reports Catalyst as false in that mode. Then there's window minimum sizing, hardware keyboard shortcuts, and disabling keyboard avoidance in three places because the system still fires keyboard events for the input accessory strip with a hardware keyboard, and the maths for a movable window is completely wrong. There's a good story in there about boring choices too. The sidebar was originally a real UISplitViewController via an experimental router feature. It misbehaved three times in one day during beta (as you know), so I ripped it out and replaced it with a plain flex row. It's less clever and it works. Android and Windows, honestly, not soon. There's more Android scaffolding than you'd guess. Five of the twelve native modules already have real Kotlin implementations. But there are over a thousand icon call sites using SF Symbols with no Android fallback, seven native modules with no Android side at all, and the whole visual language is built on iOS 26 glass. The business logic would port cleanly, because the connection layer and the data mapping are deliberately framework free and tested under plain Node. The presentation layer would be a rewrite. I might even need to bring on a dev to help me, the project has grown that large. 1.2 shipped later than planned, but the delay was actually Apple's review process, not you. What's it like waiting on something completely out of your hands after you've done everything you can on your end? Honestly, worse than when the delay is mine. If something is broken, I can fix it. If testing turns something up, I can stay up late and deal with it. Once the build is submitted and sitting with Apple, there is nothing useful left for me to do. You end up refreshing App Store Connect even though you know perfectly well that refreshing it isn't going to move the queue. The frustrating part was that everybody could see me saying 1.2 was ready, but nobody could actually download it. From the outside, a delay is still a delay, even when the finished build is just waiting for somebody else to press the button. Apple review is part of shipping an iOS app, so ultimately it is still something I have to allow for. The lesson for me is probably to stop treating submission day as release day when I talk about dates publicly. I can control when I finish a build. I cannot control exactly when Apple lets it out. You just announced QM Reader for 1.3, a full request/read/listen experience for books. That's a real expansion beyond media and homelab management. What made now the right time to go there, and how does it relate to the book and audio tools already in the Marketplace, Audiobookshelf, Kavita, Shelfarr? It looks like a jump from the outside, but from inside the app it feels like the next missing piece. Quartermaster already understands the different parts of that stack. Shelfarr is about finding and requesting something. Kavita is about the library and reading side. Audiobookshelf handles audiobooks and playback. They are good tools, but today those experiences still live next to each other rather than feeling like one journey. QM Reader is the layer that joins them together. The idea is that you should be able to find a book, request it through the service you already run, see when it arrives, then read or listen without the experience suddenly falling apart into three unrelated integrations. It isn't about replacing Audiobookshelf, Kavita or Shelfarr. They remain the servers and the source of truth. Quartermaster is the native experience across them. Now feels like the right time because the underlying work is finally there. The connection system, multiple instances, secure credential storage, downloads, media handling and Companion discovery have all already been solved in other parts of the app. I can build Reader on top of those foundations instead of creating another isolated app and asking people to configure the same stack twice. It is a bigger piece of work than adding another dashboard, definitely, but it also feels like the first time Quartermaster can turn several integrations into one complete feature rather than simply putting them beside each other. With Pulsarr, PeaNUT, Uptime Kuma, QM Reader, and a pricing review all confirmed for 1.3, that's a lot on the plate right after shipping the biggest release yet. How are you pacing yourself into this next stretch instead of just running the same sprint again? By trying not to pretend all five of those things are the same size. Pulsarr, PeaNUT and Uptime Kuma are contained integrations. They still need proper clients, screens, tests and demo data, but they inherit the connection and onboarding work that already exists. QM Reader is the large piece. The immediate priority after 1.2 is stability. There is no point announcing the biggest release I've done and then sprinting past every bug people find because I'm already chasing 1.3. I'll deal with the real-world feedback first, then build the next pieces behind TestFlight rather than trying to land everything at once internally. I'm also being more careful about dates. I am very good at turning my own estimate into a deadline and then treating that deadline like somebody else imposed it on me. Nobody in the community is asking me to work like that. That's entirely self-inflicted. So the plan is to keep moving, because I'm obviously not very good at sitting still, but to sequence it properly. Stabilise 1.2, land the smaller integrations, give Reader the time it actually needs and only call 1.3 ready when it is ready. Whether I manage to behave that sensibly for the entire release is a separate question. What are you most excited for people to actually experience once this update is in their hands? The moment Companion makes the setup disappear. People have heard me list service counts and talk about Docker discovery, but the number isn't the interesting bit. The interesting bit is scanning once and watching the app fill itself with the services you actually run, including their addresses and credentials, without spending half an hour copying API keys out of config files. That's the point where Companion stops being another thing you have to manage and starts earning its place. I'm also excited for people to see that 1.2 isn't just a collection of integrations. The app, the Mac experience and Companion now feel like parts of the same system. You can still use Quartermaster directly without Companion, exactly as before, but if you do run Companion the whole thing becomes much more joined up. I've been looking at individual screens and broken beta builds for so long that I'm probably too close to it now. I want to see what it feels like when somebody encounters the complete flow for the first time. Is there anything I haven't asked that you think people should know, or that you've been wanting to say? Probably that Companion being here does not change the original promise of Quartermaster. It is optional. The phone can still connect directly to your services. There is still no Quartermaster account, no analytics profile and no requirement to send your homelab through infrastructure I control. Companion is there when running something locally makes the experience better, not to drag a self-hosted app towards somebody else's Cloud. The other thing is that beta genuinely means beta. I've tried to be clear about where the boundaries are, what has been verified and what still needs real-world testing. I would rather tell somebody a feature has a limitation than quietly let them discover it after trusting it with their server. And, genuinely, the amount of useful feedback people have given me has changed the app. A lot of what is in 1.2 exists because somebody joined the Discord , explained their strange setup and then patiently tested three builds while I got it right. It might be one person writing it, but it has not been built in isolation. Where should people go to follow along, report bugs, or get involved, Discord, GitHub, somewhere else? Discord is the main place. That's where I post development updates, TestFlight builds, feature previews and the occasional message apologising for a delay nobody has complained about. It is also the quickest place to report a Quartermaster bug, request an integration or show me a setup that behaves differently from mine. GitHub is the better place for Companion issues that need logs, reproduction steps or a technical discussion around the implementation. The Companion repo is open, so people can inspect it, run the verification tooling and raise something properly if they find a problem. For security issues, please report them privately first rather than dropping exploit details into a public channel. I will take them seriously, confirm what is affected and be open about the fix. The website and App Store have the polished version of what Quartermaster is. Discord and GitHub are where you see how it is actually being built. What stuck with me most out of all of this wasn't the security model or the thirteen thousand lines of connection code, it was watching someone build in the open and mean it. Lewis is still answering bug reports in Discord at hours most people are asleep, still apologizing for things nobody's upset about, and still building toward something bigger than what fits in one app. I'll be watching what QM Reader turns into, and I'd bet a lot of you reading this already have Quartermaster running somewhere in your stack. If you don't yet, now's a genuinely good time to start. I'd love to hear in the comments what you think about Quartermaster, how you are using it and how friendly we are in the Discord there.

0 views
Sean Goedecke 1 months ago

Slow developer experience will bottleneck fast models

Right now developer experience is measured in seconds. If your tests take a second to run, that’s good; if they take thirty seconds, that’s bad. Any faster than a second doesn’t really matter, because most of your time is spent either thinking or waiting for an AI agent to spin. Shaving milliseconds off your dev server reload time or whatever is pointless: that’s not the bottleneck. It will be. Small models are getting faster and faster, and smart models are getting smaller. I think most engineers will still want to use the smartest available model — software engineering is hard — but we will increasingly see faster models get used as subagents or for well-understood tasks. This is largely uncharted territory. Very few people have developed intuitions for what it is going to be like to work with agents that run at thousands of tokens-per-second. GPT-6-Astra can run at about sixty tokens per second. That means you spend a lot of time waiting for it to think. You work with it like you would work with another human: delegating a task and then context-switching until that task is complete. If you haven’t yet, have a play around with Jimmy , Taalas’ version of LLaMA-3.1-8B running 1 at seventeen thousand tokens per second . No matter how long the response is, it arrives in the instant of you hitting send. The model is not good enough for agentic work, but it gives a glimpse of what it would be like: you would simply get your answer instantly 2 . Well, that’s assuming the agent’s tool calls are fast. When generating tokens is not the bottleneck, it will suddenly matter a lot whether it can read a file in 100ms vs 10ms, or whether it can run your tests in 500ms vs two seconds. Fast tool calls are going to be the difference between a near-instant response and having to wait several minutes. There is thus going to be enormous pressure to do agentic coding in languages with fast compilers and tests, like Golang, and to tightly optimize the dev loop in agentic codebases. Teams focused on DevEx — developer experience — are largely a relic of the 2010s, when companies were incentivized to make their engineers happy. Most companies have cut them down to a skeleton crew or removed them entirely. But we may see a return of DevEx in the late 2020s, focused on speeding up the experience for AI agents. Like most ultra-fast inference, it relies on fitting the entire model onto a huge GPU-like chip that’s specially designed to run inference: for Taalas, it’s in the silicon itself; for Cerebras and Groq, it’s in giant embedded onboard memory units. Will AI providers simply train models to spend more time reasoning, so users would have to wait for roughly the same time? I doubt it. Most ordinary engineering problems would not be solved better by spending an extra million tokens thinking. You only need to do that when you’re pushing right up against the limits of the model. Like most ultra-fast inference, it relies on fitting the entire model onto a huge GPU-like chip that’s specially designed to run inference: for Taalas, it’s in the silicon itself; for Cerebras and Groq, it’s in giant embedded onboard memory units. ↩ Will AI providers simply train models to spend more time reasoning, so users would have to wait for roughly the same time? I doubt it. Most ordinary engineering problems would not be solved better by spending an extra million tokens thinking. You only need to do that when you’re pushing right up against the limits of the model. ↩

0 views
Sean Goedecke 1 months ago

Don't build tools for AI agents

Lots of people are making the case that we should stop building software for human users and start building it for AI agents. This kind of makes sense. For instance, my AI agents now use Datadog way more than I use it myself, purely by virtue of them moving much more quickly and running in parallel. But I think most attempts to build “X for AI agents” are going to fail. Here are three reasons why: First, tools that are good for AI agents are also good for humans . If you took a popular software product — say, Jira — and tried to redesign it for AI agents, you would end up with something very similar to Jira. Agents use a computer in the same way human engineers do, by entering text and making API calls. They ingest new information in the same way humans do, by reading and viewing images. They prioritize and delegate and categorize in the same way humans do. This isn’t intrinsic to how AI works — we could potentially design agents that are more inhuman — but human-like agents are pound-for-pound more useful in our current world. As an example, let’s imagine that humanoid robots have become ubiquitous. What kind of tools would you build for them? Well, they’re shaped like humans, with human hands and limbs, so tools that are great for humans will also be great for robots. It’s a self-reinforcing cycle: if you’re building a robot, you should make them humanoid so they can do a wide range of human tasks 1 , and that means they’ll be best suited to use human tools. The same principle applies to AI agents. Second, being in the training data is a huge advantage for existing tools . Suppose your new tool for AI agents is 20% better for them than the equivalent piece of software for humans. If the benefit of the agent already knowing the human software is greater than 20%, they shouldn’t use your new tool. This is why I’m always suspicious of plans to develop a new programming language for AI agents. The agents have billions and billions of tokens of knowledge about existing programming languages, including their libraries, patterns, and idioms. It is going to be very hard for them to be as effective in a brand-new language. Third, we don’t yet know the ideal ergonomics for AI agents . There are lots of just-so stories floating around (like that AI agents prefer statically-typed languages because the feedback loop is tighter), but when you actually measure it seems really unclear which tools agents use better. You can construct a plausible story in either direction: Golang is a great agent language because it compiles quickly and is statically typed; Golang is an awful agent language because it requires extensive boilerplate which clogs the context window. It’s also changing so quickly: last year, one primary worry with AI agents was keeping the context window small, but in recent months compaction has become so good 2 that you can re-compact a 272k context window almost unlimited times. There are still some ways you can and should position your tool to be usable by AI agents. Having a way to expose information in plain text or Markdown, building a functional API, implementing MCP servers or CLIs, and so on: these all make it easier for current AIs to use your tool. But these are all improvements on the margin, not fundamental redesigns of the product. Right now, “building for AI agents” just means “we’re prioritizing the API over the UI”. And it’s not even clear that that’s a durable strategy. Now that GPT-6-Astra is getting really good at computer use, the gap between tools-for-AIs and tools-for-humans is closing. Another reason to make them humanoid is because you can draw their training data from human behavior, which is exactly analogous to why AI agents are human-like too. Since compaction is equivalent to handing off a task to a new AI instance, it scales with model quality. I expect compaction to steadily improve until we hit the literal information-density limits for what can be contained in a given context window. Another reason to make them humanoid is because you can draw their training data from human behavior, which is exactly analogous to why AI agents are human-like too. ↩ Since compaction is equivalent to handing off a task to a new AI instance, it scales with model quality. I expect compaction to steadily improve until we hit the literal information-density limits for what can be contained in a given context window. ↩

0 views

Debian Code Search: Fast TurboPFor with Go SIMD

This August, I accomplished what I wanted for many years: I deleted the last cgo dependency in Debian Code Search! This was made possible by Go’s recently introduced SIMD support, because now we can implement the TurboPFor integer compression format as efficiently — more efficiently, in fact, by using the newer AVX512 instruction set! — as the reference implementation. Debian Code Search (DCS) is a search engine that allows searching all the Open Source source code within Debian, with either literal search expressions or regular expression search queries. A search engine uses an inverted index: a map from term to documents containing the term. Each document is typically represented most efficiently by using an id, so the index consists of many lists of document ids. When searching, it is important to quickly decode these lists to answer the search query. However, there is a point of diminishing returns where the decoding speed, even though it can still be measurably improved quite a bit, no longer influences the overall query duration. From 2012 (its inception) to 2019, Debian Code Search used to use a small index format, and queries were fast because the index was kept entirely in RAM. In 2019, I implemented the new index format , which adds an on-disk positional index. For literal queries (78.2% of DCS queries), querying the positional index on disk is faster than querying the non-positional index in RAM. The efficient encoding of the TurboPFor format makes it possible to fit such an index on a mid-sized Hetzner server, which I rent with two 1 TB SSD disks. The optimized decoder of the C TurboPFor library is what made decoding fast at query time. If you want to dive deeper into the algorithm, see this blog post from February 2019: Motivation I have recently been looking into speeding up Debian Code Search. As a quick reminder, search engines answer queries by consulting an inverted index: a map from term to documents containing that term (called a “posting list”). See the Debian Code Search Bachelor Thesis (PDF) for a lot more details. Read more → If you want to learn more about the positional index, see this blog post from September 2019: Over the last few months, I have been developing a new index format for Debian Code Search. This required a lot of careful refactoring, re-implementation, debug tool creation and debugging. Read more → For many years, you had the following options for using SIMD instructions in Go: The C TurboPFor library has served us well, but Debian Code Search was always intended to be a project using Go, so I would prefer it if I did not have any C code in the project. Go 1.26 (released in February 2026) introduced the package: Go 1.26 introduces a new experimental package , which can be enabled by setting the environment variable at build time. This package provides access to architecture-specific SIMD operations. It is currently available on the architecture and supports 128-bit, 256-bit, and 512-bit vector types, such as and , with operations such as . The API is not yet considered stable. — Go 1.26 Release Notes For my 2019 TurboPFor analysis, I implemented , a native Go teaching decoder (without any SIMD), because I find Go code easier to follow than C code, especially optimized C code. My implementation was intentionally not optimized so that the code was easier to study. The TurboPFor format/algorithm has a vector-optimized part: bitpacking comes in a scalar variant ( ) and a vector variant ( ) , where the vector variant is used for full blocks (256 values) and the scalar variant is used for remainder blocks (< 256 values). When Go 1.26 was released, I used Claude Code to explore whether my native Go decoder’s function (for the vertical vector layout) could be implemented using Go SIMD, and the answer was yes, it was possible and it was faster than without SIMD, but not quite at the level of C TurboPFor. If you let Claude Code try for long enough, it eventually finds enough optimizations (about 10) to match C performance. I don’t want to vibe-code Debian Code Search, though, so I figured I would find some time to review the SIMD code at some point and see if I could implement something similar myself. Before I found enough time and motivation to complete said review, I discovered that to not regress real-life query performance by more than 10 to 100 milliseconds (which seems acceptable), I don’t actually need to add SIMD code to my teaching decoder at all; it would be sufficient to reduce allocations in my teaching decoder and specialize it per bit width. Encouraged by the possibility of using the optimized native Go decoder in Debian Code Search, I explored whether I could also implement a native Go encoder so that I could get rid of the C TurboPFor dependency entirely. The answer is yes, it is doable in a few days, and it isn’t even that much slower: Go is at 76% of C, see Debian/dcs commit . The goal I set myself at that point was to see if I could learn enough SIMD to optimize the native Go encoder such that its performance would match how DCS uses C TurboPFor (via cgo). Beating C TurboPFor was possible in 2-3 commits (SIMD and bit width specialization). To my surprise, Claude Fable 5 pointed out that the encoder’s block scanning could be done more efficiently using a technique called positional popcount, and that is another 2x speed-up ! 😲 To be clear: I am not saying the Go compiler beats C here. Certainly, the C compiler can also produce fast AVX512 code and can be used to implement positional popcount. When comparing apples to apples, i.e. backporting the AVX512 kernels and positional popcount technique to C TurboPFor, Go benchmarks a little slower at ≈1.4x C. This spectacular result (much faster than what DCS had before) got me curious how far I could push the decoder with SIMD after all. I ended up matching/exceeding the cgo version here, too! The rest of this article explains a few classes of optimizations I encountered along the way. When I wrote my teaching decoder , I named its functions to match the upstream C TurboPFor library, but now I want to get away from names like — they make sense from the TurboPFor perspective, but for Debian Code Search, we can use cleaner names. Before writing any code, I audited how DCS uses integer compression / decompression. In Debian Code Search, we have the following usage patterns: For reading the index, we do keep the decoded s fully in memory, so we only need , a function that reads values ( ) from and returns how many bytes it consumed. For writing the index (both in partial indexing, and when merging), keeping the entire index in memory is prohibitively expensive, so we need a streaming API, for decoding and for encoding. Ultimately, I converged on the following API: This API (the decoder works similarly) allows us to process data in TurboPFor format without any memory allocations. The types are not safe for concurrent use by multiple goroutines. The zero value is ready to be used. For the streaming API, the result only stays valid until the next call. Before we can optimize anything, we need a working decoder and encoder. The decoder already exists: my teaching decoder. Next up, I needed an encoder. Writing a TurboPFor encoder has a delightfully simple starting point: You can encode all values at bit width 32, in little endian, at which point you only need to add a one-byte TurboPFor block header every 256 values and you’re done: Of course, this is a terribly inefficient compressor, so after the first commit, the real work starts: implement each block type until the compression matches the original C TurboPFor implementation (same output file size), or in other words: do the reverse of the decoder. I found it interesting to realize that the main work of the encoder is to scan the input values and choose the optimal block type, whereas the actual encoding itself is cheap in comparison. At this point, we can look at performance and see that the Go encoder is at 76% of the C encoder. In all honesty, I could have probably stopped here, but now that the milestone of a viable replacement was reached, I got curious to see how far it would be possible to push the encoder (how much work to reach C speeds?) and afterwards, the decoder, too. The microarchitecture of a CPU determines which instructions it provides, and that includes not just SIMD instruction sets (like AVX2), but also other useful instructions like (Leading Zero Count), which can be used to implement more efficiently, which the TurboPFor encoder needs to call on every input value to determine the ideal bit width. Let’s walk through how to set the microarchitecture level when using Go on 64-bit x86 (x86-64). Go uses the environment variable to configure the target compilation architecture, and I am using the value to select 64-bit x86 (AVX2 and AVX512 are instruction sets found on x86-64 CPUs). With , the architecture-specific variable configures the microarchitecture level for which to compile and Go 1.18 introduced these 4 different levels : (default): The baseline. Exclusively generates instructions that all 64-bit x86 processors can execute. : all v1 instructions, plus CMPXCHG16B, LAHF, SAHF, POPCNT, SSE3, SSE4.1, SSE4.2, SSSE3. : all v2 instructions, plus AVX, AVX2 , BMI1, BMI2, F16C, FMA, LZCNT, MOVBE, OSXSAVE. : all v3 instructions, plus AVX512F , AVX512BW, AVX512CD, AVX512DQ, AVX512VL. In 2026, I generally recommend compiling with so that functions like are compiled into intrinsics ( ) instead of using a lookup table. For Intel CPUs, setting means your programs will only start on Haswell CPUs (2013) or newer; for AMD CPUs that means Zen 1 (2017) or newer. In this specific case (DCS), I am even compiling with . The microarchitecture level requires AVX512, which means AMD Zen 4, Zen 5 or newer (Intel’s story is… complicated). Luckily, both my main development PC (Zen 5) and the Debian Code Search server (Zen 4) are recent enough. Setting has little effect on Go 1.27 itself: the only change is that maps use one less instruction ( instead of ). But compiling with allows us to move one more feature check from runtime to compile time, see SIMD build tags . It makes sense to set the microarchitecture level in your benchmark setup so that you don’t measure the slow fallback implementations. I use in my . Go’s built-in package contains support for benchmarks which are written in functions of the form . The simplest way to run such benchmarks is , but I ended up configuring a few convenience targets, which write results to and compare against (the previous commit’s results, usually), using the very useful tool . The and units are custom metrics I am reporting from the various sub-benchmarks , which are arranged such that I can filter / report them with . The main encoder (and decoder) benchmarks compare 3 different implementations (cgo, Go, Go with the StreamEncoder API) with a number of benchmark cases that are designed to cover the different block types and contain a similar mix of values as what we see in Debian Code Search: Go has included excellent performance tooling for many years, see the “Profiling Go Programs” blog post (2011) for an example of how to use , a sampling profiler. This profiler can help track down which part of a program runs slow, or where memory allocations happen. Once you identified the slow part of a program, how do you know why it’s slow? To learn more about the specific bottlenecks your program encounters, you can consult your CPU’s hardware performance counters . For example, you could check the branch predictor counters to see if your program is slow due to a high number of branch mispredicts. On Linux, the tool is the best way to access the CPU hardware performance counters. A good starting point for working with is the documentation on “Top-down analysis with the perf tool” , which describes the optimization method that Intel established. In my , I set up two targets: The numbers are high level numbers that indicate how much work the implementation is doing. Reducing the number usually increases speed. To see the counters for each instruction (and source code lines), I use , followed by . A quick shortcut is , which directly shows the hottest function. Let’s first see how far we can get without reaching for SIMD instructions. (The examples are not necessarily in commit order, but cherry-picked for clarity.) PGO stands for Profile-Guided Optimization and is a feature that Go introduced as a preview in Go 1.20 (released in February 2023) and shipped as ready for general production use in Go 1.21 (released in August 2023). The idea is to capture a CPU profile that records where your program spends most of its CPU time, which you then provide to the Go compiler to give it more data to make better decisions. Most importantly, this way the Go compiler can inline functions much more aggressively than its usual heuristics allow, which does have a measurably positive effect in my series of optimization commits. Another optimization that a PGO profile allows the compiler to do is conditional devirtualization — but our TurboPFor code does not use any interfaces. My strategy is to enable PGO before doing any other optimizations, so that we have the full inlining budget available that PGO gives us, and can measure the effect of other commits clearly. Surprisingly, turning on PGO actually decreases our performance (-13% geomean), but a closer investigation reveals that we just got unlucky. Let me explain. Aside from inlining and conditional devirtualization, PGO also influences alignment: The Go compiler sets on the first block of a loop (the “loop body”) for all loops in hot functions (per the PGO profile), i.e. Go will insert up to 31 bytes of padding to make the block land on a 64-byte boundary. Documentation like AMD’s “Software Optimization Guide for the AMD Zen5 Microarchitecture” (2024, #58455) explicitly recommends aligning hot loops that way: […] for hot loops, some further knowledge of trade-offs can be helpful. Because the processor can read an aligned 64-byte fetch block every cycle, it is suggested to either align the start of the loop to the beginning of a 64-byte cache line […] Indeed, when compiling with to disable the alignment, performance remains as good as without PGO. How can the padding hurt more than help? The answer is: It’s not the padding itself! It’s a side-effect of the padding moving instructions to different addresses. In the unlucky arrangement, a macro-fused + instruction pair now ends up exactly on a 32-byte boundary . However, the Go compiler ensures fused branch sequences must never cross or end at a 32-byte boundary to fix Intel erratum SKX102 (discussion: Go issue #35881 ) by inserting s. This padding, unlike the loop alignment padding, is not free; these extra instructions slow down our otherwise dispatch-bound loops. Because the commits after the PGO enabling commit change the code, this unlucky situation is avoided for the rest of the optimization series (by chance). Memory allocations are quite expensive, at least in comparison to encoding/decoding integers, so I followed my usual strategy of first reducing memory allocations as much as possible. In my teaching decoder, whenever the code needed a scratch buffer, it would allocate it right then and there with : The Go compiler can turn calls into stack allocations, if is known at compile-time. But, in this case is not known at compile-time. We can verify that Go calls into the runtime ( ) by dumping the object code (assembly) with source annotated ( ): An easy speed-up was to avoid allocations through reuse (in ). In the DCS package (with the improved API design ), I ended up with a field in the type, which brings us from 773 Mval/s to 858 Mval/s on the debian-mix: Aside from the speed-up, avoiding memory allocations is generally nice in benchmarks because it removes the garbage collector from the equation and makes it less likely that your benchmarks get other processes OOM-killed on the same machine. In general, we want to make it easy for the compiler to understand as much as possible about our algorithm. Consider this implementation: Let’s think through what determines the iterations and control flow this function uses: With a bit of careful rearrangement, we can provide the compiler with both, a fixed number of input values (say, 32), and a bit width, both known at compile time. Why is this worthwhile? Because we can manually unroll the loop, let the compiler eliminate much of the repetition and get much faster compiled code as a result! Let’s first fix the number of input values to 32 and rewrite the loop to calculate the position offsets within instead of changing on each value (with ): Next, we want to specialize not just for 32 input values, but also for each of the 32 bit widths. Can we do better than hand-copying 32 times (= 8192 lines of Go code)? Yes, we can use Go generics to help us with the code generation! In Go, array types like (not slices like !) contain the length of the array as part of their type, meaning (an array of length 1) is a different type than . Instead of passing the bit width as a function parameter, we can declare 32 different types (one for each bit width) and recover the bit width (at compile time!) from the type system: When we instantiate with all 32 different types ( , , …, ), the compiler substitutes the type parameter and produces 32 copies of the function, which we can find in our compiled executable with names like . The “shape” of a generic type is based on its memory layout, so a shape for must be different than the shape for . Because the is now known at compile time, the Go compiler can generate close to the optimal machine code for each bit width, which we can confirm using . The code is branchless (after the one bounds check per 32 values) and aside from the loads and stores (from/to memory) consists only of shifts and bit operations, all with constant operands: Now we need to actually call from the general function: Encoding remainder blocks is quite a bit faster (full blocks use the vertical layout anyway): This performance win comes at the cost of binary size increase. In this case, the section (executable code) grows by about 20 KB and the section grows by another 26 KB. Definitely a price I am very willing to pay, but the case might not be as clear in all circumstances. Even without reaching for SIMD instructions, a TurboPFor implementation can be made faster by making it work bigger strides. Take this code from the teaching decoder which counts the number of exceptions by checking if each value’s bit is set in the exception bitmap: We can use the functions to count ones bits in the exception bitmap, 64 values at a time. For remainder blocks, the rest is processed 8 values (1 byte) at a time: uses a 64-bit register. For comparison, AVX2 SIMD instructions use 256-bit registers (= 8 ) and AVX512 SIMD instructions use 512-bit registers. In the following sections, we will first set up our build tags for conditional compilation to use a trivial SIMD instruction, then walk through an AVX2 and AVX512 SIMD kernel. Let’s assume we have the following scalar code: To increase throughput, we can use AVX2 instructions if they are available on the CPU on which the program runs, i.e. using runtime dispatch. We’ll first rename to (it’s now the fallback path): Next, we’ll supply two different implementations ( and ), the latter of which is selected when compiling for with (the latter will hopefully be dropped in a later version of Go). The variant just dispatches to the , which will likely be inlined: The variant assigns the global variable by doing a check and then jumps to the scalar fallback if , i.e. the CPU is too old: We can go one step further by conditionally compiling when is set to or higher (i.e. the build tag is set). As a practical example from Debian Code Search, we currently need the following checks / dispatches: In DCS, the effect is measurably positive, but small . First, here is the layout explanation from my 2019 TurboPFor analysis blog post : In regular (non-SIMD) bitpacking , integers are stored on disk one after the other, padded to a full byte, as a byte is the smallest addressable unit when reading data from disk. For example, if you bitpack only one 3 bit int, you will end up with 5 bits of padding. SIMD bitpacking works like regular bitpacking, but processes 8 little-endian values at the same time, leveraging the AVX instruction set . The following illustration shows the order in which 3-bit integers are decoded from disk: The scalar implementation uses an array of 8 to process 8 values at a time: The SIMD version also processes 8 values, but without a loop! One difference is that we no longer have the luxury of using for (holding rest and current bits); because AVX2 registers only fit 8 (not 8 ). Instead, we split into and . The SIMD version benchmarks about 3x as fast as the scalar version. Another significant speedup is to use generics for bit width specialization for this SIMD kernel so that becomes a compile-time constant and the compiler can generate better code. For my TurboPFor encoder, I implemented the same techniques as described above: Bitpack full blocks with SIMD (AVX2) Gather exceptions using SIMD (AVX512) Use generics to specialize per bit width These changes are sufficient to roughly match the cgo performance, but then Claude Fable 5 found another 2x speed-up on top of that ! The key observation is that once encoding blocks is fast, the preceding step of scanning the input values to decide which block type to use becomes the bottleneck. Here is the encoder’s main function, which first does one pass over the input values ( ) and then prices all different block types at all relevant bit widths (requires fast access to the histogram): I’ll show you a slightly shortened version of , the function which is the bottleneck: Let’s consider the following 3 example values to understand the resulting : The resulting exception count histogram would contain ( shortened to ): In words, this means that at bit width 10, we could encode all the values without any exceptions. But most values do not need 10 bits, so a bit width of 5 would be more efficient, but requires storing one exception. Encoding at bit width 4 requires 2 exceptions, and so on. The function above is intentionally kept simple for illustration. We can make it faster by moving the per-bit-width loop outside the per-element loop . The fast version still needs about 12 instructions per value. With SIMD, we can reduce this to by 8x to only 1.5 instructions per value! The trick is to turn each input value into its “smear mask” (imagine taking the first 1 bit and smearing it across the remaining positions). Here are the smear masks for our example: Turning a value into its smear mask is computationally cheap: Go implements (functions like ) by calculating . We can calculate the “smear mask” of a value with , i.e. starting with a 32-one-bits mask and shifting it by the number of leading zeros. Now, to obtain e.g. , we can count the 1 bits at bit position 4 of all input values. The instruction counts bits very efficiently, but it counts one bits within a register, so it counts rows, not columns. Counting columns is called Positional Population Count . I found the following papers that describe positional popcount with SIMD: To understand the AVX512 implementation of positional popcount, I found it most helpful to visualize an AVX512 register (512 bits, i.e. 64 bytes). The graphic below uses the layout, meaning it divides the register into 8 lanes of 64 bits (= 8 bytes) each. This illustration shows the whole process: how s are loaded into an AVX512 register (all 4 of its bytes, in sequence) and where we end up, i.e. the 32 positional popcounts: Let’s break down this process into its individual steps. First, we turn each loaded value into its smear mask as explained above. The vector instruction calculates (1 byte) of 64 bytes at once, but first we need to shuffle the bytes inside the register: in load order, we have a full (4 bytes), followed by another , per lane. First, we permute the bytes ( ) such that all the first bytes of each value end up in one lane (“transpose the bytes”): Next, we “transpose the bits” using the instruction, which sounds scary but turns out to be quite flexible for bit manipulation of all kinds. The instruction is also “the star of the show” in Go’s Green Tea Garbage Collector (2025). Here is the bit transpose, shown in the AVX512 register layout (see below for a different layout): I found it easier to understand the transpose step when arranging the 8 bytes of lane 0 from top-to-bottom (instead of left-to-right), because then it looks like a 90 degree clockwise rotation: Now we can use to count the bits in all 64 bytes at once: After all loop iterations (processing 16 values each) are done, we add the two groups (first 8 values, second 8 values) to obtain the 32 exception counts: Here is the Go code that implements what I described visually above: Have a look at the commit introducing positional popcount to DCS for the full code (including shuffle tables and ISA checks) as well as the detailed benchmark results. The SIMD optimizations I showed above beat the cgo TurboPFor library that Debian Code Search used before. When comparing apples to apples, i.e. backporting the AVX512 kernels and positional popcount technique to C TurboPFor, Go benchmarks a little slower at ≈1.4x C. Could we make my Go TurboPFor implementation even faster, to truly match the C speed? Yes! But also no. Let me explain: We could use more SIMD instructions to remove all code that still processes one value at a time. For example, in my encoder’s function. Or we could price all bit widths concurrently in . Or in the decoder’s exception apply code path. But all of these SIMD instructions make understanding (and changing) the code harder, so I am cautious regarding which ones I introduce. A big part of the performance gap is due to Go’s bounds checks . While it costs performance, bounds checking is great for safety, so I will not turn off bounds checking. The Go compiler eliminates a number of bounds checks when it understands it’s safe to do so. One optimization avenue could be to make the prove pass in the Go compiler smarter to eliminate more bounds checks. When doing mid-stack inlining (proposal #19348) (2017), Go sometimes needs to put instructions into the binary so that it can attach inlining markers. For dispatch-bound functions, these extra NOPs can measurable slow down execution. The Go compiler currently allows specifying the architecture ( ) and microarchitecture ( ), but not a specific CPU architecture (like AMD Zen 4). Therefore, CPU-specific workarounds for one vendor affect all the generated code. The specific one I encountered in my code is that the Go compiler emits before every to break a false-output-dependency from the Intel Sandy Bridge Skylake era, which is unnecessary on AMD Zen CPUs. I suspect that Go intentionally does not offer this level of customizability. After all of the above points are addressed, what remains is better code generation in specific cases. To illustrate what I mean, consider the example of incrementing a loop variable, where Go re-derives an index every time: Go: (3 instructions) clang: (2 instructions) Depending on the specific case, improving the compiler might be easy or prohibitively complex. Often, such improvements are hard to measure conclusively. Go’s SIMD support makes available — in Go code without having to resort to cgo or assembly — a powerful part of modern CPUs which allows speeding up the kind of computation that TurboPFor needs by an order of magnitude! 😲 I found it very valuable to use a coding agent (Claude Code, with Opus 5 and Fable 5 in this case) to help with the many tedious parts of such performance work (and still it took me weeks!). The LLM can read objdump output much faster than I can, can see patterns and correlations I might never identify, never becomes frustrated after a compiler error or runtime panic, and never runs out of patience to run one more experiment, as long as I give it measurable and reachable goals. The performance of the SIMD code which one can get from the Go compiler is pretty close to what a good C compiler like clang provides. The CPU performance counters show value decoding speeds of 7 instructions/cycle (IPC) on a machine where the maximum is 8 IPC. To me, SIMD support is a very welcome addition to Go. Hand-writing Go assembler code . This is only doable for small functions, for example is implemented with hand-written Go assembly (including AVX2). Generating Go assembler code with tools like Michael McLoughlin’s “Avo” . This is how uses AVX2 . While Avo generator code definitely is higher-level than hind-written assembly, it is still too close to assembly for my taste. Use a C library via cgo so gcc or clang compiles SIMD code. Debian Code Search used to use the powturbo/TurboPFor C library via cgo for the last 7 years. Partial Indexing: When a new package (or package version) enters Debian, all of its (text) files are indexed. If the package (hypothetically) contained only with , we would assign document ID to and store in the partial index that trigrams , , , , etc. are all found in doc ( ). Full Index Merging: The many thousands of partial index files (for each Debian package) are combined into a small handful of large index files: When searching, it would be expensive to consult thousands of indexes. To merge multiple partial index files into one larger index (which can then be efficiently queried), we need to re-encode the partial index files: what used to be document ID in the partial index might be document ID in the full index. Querying (searching): When users enter search queries, these queries need to be answered as quickly as possible. The relevant entries in the full indexes are decoded (in parallel). The TurboPFor bitpacking block type ( bitpacking implementation commit ) encodes a bit stream of variable bit width (where the bit width is in range ) in little endian byte order. By scanning all values and choosing the smallest bit width that allows representing all values, this technique saves disk space (compresses). The bitpacking with exceptions block type ( bitpacking with exceptions implementation commit ) determines two bit widths: one for values, the other bit width for encoding exceptions. This allows choosing a lower bit width (that does not cover all values) compared to the bitpacking block type. A bitmap encodes whether a value has an exception or not. The bitpacking with VB exceptions block type ( bitpacking with VB exceptions implementation commit ) is a variant which does not use an exception bitmap and encodes exceptions using a variable byte integer encoding. This is more efficient when there are few exceptions (less than 20) or the exceptions are very different in bit width compared to the other values. Lastly, the constant block type ( constant implementation commit ) stores just one value on disk. This is useful for all-zero or all-one blocks, for example. The number of input values ( ), but not their actual value. The bit width to pack into ( ). Bitpack full blocks with SIMD (AVX2) Gather exceptions using SIMD (AVX512) Use generics to specialize per bit width 2019: “Efficient Computation of Positional Population Counts Using SIMD Instructions” (Klarqvist, Muła, Lemire) introduces an AVX512 implementation using Carry-Save Adder (CSA) networks, but in my testing, that approach is slower for TurboPFor encoding. 2024: “Histogramming bytes with positional popcount (GF2P8AFFINEQB edition)” (Harold Aptroot) shared a C++ implementation illustrating the technique I am using. If you are not familiar with how C++ vector intrinsics look, take a look at this example. 2025: “Faster Positional-Population Counts for AVX2, AVX-512, and ASIMD” (Clausecker, Lemire, Schintke) improves performance over the 2019 paper, but cites the Aptroot blog post as “Future Work”, promising even faster processing for smaller inputs. We could use more SIMD instructions to remove all code that still processes one value at a time. For example, in my encoder’s function. Or we could price all bit widths concurrently in . Or in the decoder’s exception apply code path. But all of these SIMD instructions make understanding (and changing) the code harder, so I am cautious regarding which ones I introduce. A big part of the performance gap is due to Go’s bounds checks . While it costs performance, bounds checking is great for safety, so I will not turn off bounds checking. The Go compiler eliminates a number of bounds checks when it understands it’s safe to do so. One optimization avenue could be to make the prove pass in the Go compiler smarter to eliminate more bounds checks. When doing mid-stack inlining (proposal #19348) (2017), Go sometimes needs to put instructions into the binary so that it can attach inlining markers. For dispatch-bound functions, these extra NOPs can measurable slow down execution. The Go compiler currently allows specifying the architecture ( ) and microarchitecture ( ), but not a specific CPU architecture (like AMD Zen 4). Therefore, CPU-specific workarounds for one vendor affect all the generated code. The specific one I encountered in my code is that the Go compiler emits before every to break a false-output-dependency from the Intel Sandy Bridge Skylake era, which is unnecessary on AMD Zen CPUs. I suspect that Go intentionally does not offer this level of customizability. After all of the above points are addressed, what remains is better code generation in specific cases. To illustrate what I mean, consider the example of incrementing a loop variable, where Go re-derives an index every time: Go: (3 instructions) clang: (2 instructions) Depending on the specific case, improving the compiler might be easy or prohibitively complex. Often, such improvements are hard to measure conclusively.

0 views
Xe Iaso 1 months ago

It took a year to ship WebAssembly in Anubis

After a year of work, hundreds of commits, 5 generations of pull requests, dozens of tests, rewriting part of Anubis in Rust, the first compiler bug of my career, and at least three times making my tower run out of ram I think I have finally done it. The next version of Anubis will ship with WebAssembly-based proof of work checks that admins can enable in their thresholds or bot rules: This makes Anubis challenges use a memory-hard proof of work function ( argon2id ) instead of just a CPU hard one. It also means that the "hey Claude vibeslop me a CUDA Anubis solver" route is on its way to being fundamentally dead. Overall, this project taught me a lot of things about how WebAssembly works in practice, what the rough edges are when operating on the bleeding edge like I am, and led to the first genuine compiler bug of my career. Today I want to take you through the journey and process behind adding these WebAssembly based proof of work checks and all the problems that came up along the way. The big impetus behind wanting to use WebAssembly in Anubis is that there's a constant tension that's underlined a lot of the performance work I've been doing: phone CPUs suck and trying to balance equitability to phone CPUs with trying to punish scraper CPUs is a huge challenge. Note To be clear: I am tackling the "Anubis makes my phone overheat" problem. It's hard to just lower the difficulty for phones without giving attackers enough information to lower the difficulty for scrapers. If you want to contribute performance data so that I can improve my classification process and/or target future development, please get in touch with me . I want to make things better but can't without data. I am actively testing with a Moto G8 Power alongside my normal testing steps. Using WebAssembly here means that the binary will run faster, which will make Anubis go away faster, which is what we all want, right? One of the other big advantages of this setup is that it lets clients and servers run the same binary in order to solve and validate challenges. Note that I didn't say the same code, I said the same binary . This means that the client and server are in lockstep, much like the CIC chip in the NES . This also does mean that a bug in the shared code means that the client could send an invalid value that the server would accept as valid, but I don't think this is a practical concern until something like that happens. Right now the challenge has two implementations in Anubis: one in JavaScript and the other in Go . Updating it in one place means making the same updates in other places and also making sure that everywhere else that assumes how challenges work is also updated. Note Yeah, it's probably bad to have made those assumptions about how challenges work across the codebase. There's a lot of weird technical debt here that needs to be solved at some point. I expect that to be a fair bit of effort to untangle, it may end up with keeping the challenge only for tests when the WebAssembly based routes are working out well enough to make the default/only option. Making the same binary run on both the client and the server means that future experiments like per-client program synthesis can also proceed. I'll get more into that sometime in the future. Right now the core of Anubis is written in Go. Go's standard library HTTP server is surprisingly performant and is the key component of things like Google's HTTP frontend service. I have no intention to rewrite Anubis in Rust or anything as drastic as that. However breaking up Anubis from one monolithic binary into what amounts to a plugin loader is a lot more interesting from a maintenance standpoint. One of the main problems with Anubis is that the combination of rules I've set for myself (no CGo allowed and everything must cross compile to prod from my MacBook) means that updating any part of Anubis means recompiling all of Anubis across prod. Making Anubis be able to have parts of it downloaded and swapped out at runtime is really interesting to me because it means being able to adapt to threats as soon as the threat actors change behaviour . Also I figured out pretty quickly that Rust builds some of the smallest WebAssembly binaries that run hilariously fast, so I wrote all the WebAssembly code in Rust (no-std, wasm32-unknown-unknown)! Along the way this also lets me fix one of the bigger administrative problems with Anubis: challenges scale incorrectly with the challenge. Anubis uses string comparison to count the number of leading zero nibbles in a hash instead of the number of leading zero bits . This means that adding one (1) to the difficulty of a challenge makes it 1024 (one thousand twenty-four) times as hard to solve in the worst case. The original post has an interactive diagram that shows the difference in scaling at play. This was a mistake in retrospect, but if we're changing details about how challenges work here then we may as well wrap up the fix into that. In a normal world you'd not have about half of the restrictions that I have when working with Anubis. Normally you just write your code, compile it to WebAssembly, and then make sure it runs on modern browsers. This is nice and simple. I wish I could live in this world. Comparatively, this is what it's like getting all of this working across browser versions, platforms, and so many other things: Anubis supports Chrome 75 and newer. I wanted to reduce the support range to not have to deal with Chrome that old (the feature difference between Chrome 155 and Chrome 75 is absolutely massive ), but there are a lot of smartphones, smart TVs, and other electronic devices running Android out there that are just marooned on Chrome that old with no path to upgrade it . As a result, I gotta target Chrome 75 for features, but out of an abundance of caution and to make sure that things are generally compatible with browsers older than Chrome 75 (eg: old iOS releases), I target my JavaScript to Chrome 66 as an abundance of caution. I haven't been able to test with iOS or Android versions of the same vintage as Chrome 75, so a lot of this is aspirational backwards compatibility. I sure hope it's good enough! When you're doing this kind of work, you're effectively writing an operating system kernel that makes the WebAssembly guests run as processes. Any calls you pass to the WebAssembly guest are effectively system calls into the host. Normally I'd love to be able to reuse the WebAssembly Component Model so that a lot of the "hard part" is done for me. WebAssembly Components make you define your calls in packages that contain interfaces with functions in worlds. Here's an example of the kind of WebAssembly Interface Types world Anubis would need: If this worked (I wrote this all out in one go based on how the current handcrafted ABI works and have not tested it, sorry), this would describe the shape of the API that we need for doing the proof of work operations. At the time I wrote the API/ABI that Anubis uses, the main tool for generating the server-side bindings for WebAssembly Component Model stuff in Go gravity didn't support passing records (structs) or bytestrings ( or / ) from the host to the guest. As such, I had to do it by hand. So given that we can't do it the "right way", we have to do it the "bad way". Also given that no matter what I pick for this I'm going to be "wrong", I just decided to treat the WebAssembly modules as dynamic libraries that just happen to use WebAssembly as an implementation detail. There only needs to be three buffers that are read from / written to, so let's just focus around those: Challenge modules also expose two entrypoints: Challenge modules also import from the environment so that they can periodically report their hash rate back to the frontend. This allows users to see a progress bar based on how long it should take to finish the process. This works enough for now. It'll be interesting to see how this falls short in the real world! Honestly, the first bit of this took a few days at most. Most of the hard work was making sure that pointers, offsets, and whatnot were all wired up correctly so that the browser worked the same way as the server. My experience building and messing around with many WebAssembly runtimes and egregious hacks meant that making it was really easy for me. The devil came out in the details. Here are all the things that came up while I was working on this. All of these problems added up is the sole reason that this took a year instead of a week. Early on in development I found out that WebAssembly has a SIMD extension . SIMD stands for Single Instruction Multiple Data and is a family of instructions that let you do operations on multiple values at a time. This gives programmers data-level parallelism (this is distinct from multi-threading) so that things like hash calculations and MP3 decoding can be done faster than they would be if each operation had to be its own instruction. CanIUse considers WebAssembly SIMD to be "baseline" (supported by browsers newer than Chrome 91), but I have a lower version bound of Chrome 75. However the benefits from SIMD on mobile devices are so drastic that it's worth having two builds of the WebAssembly code: one with SIMD and one without it. In the browser it dispatches which version to use by using wasm-feature-detect to probe WebAssembly functionality by trying to parse/run trivial minimal programs that exercise those features. Hopefully I don't need to add an additional build into this process, but there is nothing in the tooling that would prevent it! One of the big things that blocked this shipping for so long was not having an escape hatch of some kind to allow clients that disable WebAssembly by policy to get through the gate. In my experience most clients don't have JavaScript enabled but WebAssembly disabled, however there are a few notable usecases that forced my hand: iOS Lockdown mode and GrapheneOS' Vanadium 's default configuration. This combination of factors means that there would need to be another implementation of the proof of work code in JavaScript that would actually execute the number crunching. I don't want to make another implementation of the proof of work function (the entire point of this is to only have one implementation!) so while I was browsing around I came across the legendary talk The Birth & Death of JavaScript and got a horrible idea. What if you just compiled the WebAssembly to JavaScript? Would that even work? It's "just" turning one Turing machine into another, right? How would it fare in practice? Turns out I'm not the first person to think about this! The team behind binaryen have made this escape hatch in the form of wasm2js which takes WebAssembly binaries and produces moderately cromulent JavaScript in response. One of the main downsides is that the generated binaries tend to be rather large. For example consider this simple WebAssembly module that exposes a function that adds two numbers together: Seems simple enough, right? Here's the JavaScript that generates: As you can imagine, this only gets progressively worse as you end up making the Rust standard library get compiled from WASM to JavaScript, and even worse when you actually get hashing functions into the mix. The end result is probably very optimized when you run it through a JIT, but given that this runs on an interpreter it's probably gonna be slow no matter what I do. Sorry! I tried! This ended up working fairly well in testing, but I tried building it in GitHub Actions and ran into an issue. I noticed that wasm2js was packaged in Ubuntu and that the version in Fedora worked fine but the version in Ubuntu did not and threw an obscure error message about not understanding the tail call extension that Rust was using for some reason. I ended up bisecting versions of binaryen by downloading a tarball, building it from source, and then seeing if the result of running it on the Anubis WebAssembly modules worked in a browser. I ended up selecting Binaryen version 128, the newest version at the time. It was new enough that most of the distributions that package Anubis don't have that version of Binaryen packaged. However I didn't want to make my life more complicated by having some kind of conditional compilation step that would effectively tell end users "sorry, the admin is using an unofficial build that just so happens to not support your browser, please complain to them" because they'll just end up complaining to me. In my experience the kinds of people who run this exact combination of circumstances also tend to be the kind of people that have a wide variance in the level of kindness they display to the authors of open source programs that happen to be in their way. So I needed an escape hatch that would force build systems to use the exact version that I use in my builds. Then inspiration hit me as if Apollo himself sniped me from the heavens. We're dealing with WebAssembly here right? What's stopping us from just compiling the WebAssembly to JS tool to WebAssembly with some kind of reproducible build, committing that blob to the repo, and then moving on with life? I ended up finding a bug in LLVM around how it was iterating over exception handling blocks by the compiler iterating over them in machine pointer order. As a result each build would drift by about 29 bytes per build: This is what lead me to write I hate compilers as a combination blogpost/cry for help which made me realize this was actually an LLVM bug. I didn't instantly lean towards it being an LLVM bug until I figured out that disabling ASLR (via ) made the results consistent on the same host within the same boot. Honestly this is the first time in my career I've ever run into a compiler bug like this. When I do a lot of my work I usually work under the assumption that the compiler is bug-free and that my inputs are wrong somehow. As such, even thinking it could possibly be an LLVM bug was just outside of the realm of possibility for me. Once that LLVM bug got fixed and a new version of wasi-sdk with that fix got released, I was off to the races, updated my build of wasm-opt/wasm2js, made my build scripts run it with wasmtime (alongside a wazero-based fallback process that would be slower, but did work enough) and everything worked out. Every time Anubis builds the WebAssembly in CI it uses the version of wasm-opt and wasm2js that ships in the repo to ensure that everything is as byte-for-byte deterministic as possible. Your build tools can't differ from my build tools if I ship you the build tools I use. Then we get into the other big problem that made this difficult: browser testing. One of the most common failure modes of Anubis is that someone uses some browser that I don't test in CI and then things don't work with it. I'm tired of installing 50 different browsers on several machines to test things and I have gone through so many throwaway VMs that I'm sure it's reduced the lifetime of my SSD. Note Yes, I really have been testing Anubis by hand in god knows how many browsers. Why do you think it takes so long to tag new releases? I built a harness that I call "chromesweep" that lets me spawn many Googles Chrome (term c.f. Attorneys General, et.al) in their default configuration to try and hit a version of Anubis listening over HTTPS. Getting this far meant making a library of all of these browser versions. I've put that library up on Github at TecharoHQ/gubal in case it's useful for you. One of the other big problems I ran into while getting browser testing working was making sure that my Googles Chrome strictly stay within the bounds of my Kubernetes cluster's network. Chrome this old is actively radioactive and I want to treat it like the security threat it is. As such, I set up a strict NetworkPolicy to only allow it to access the Anubis instance under test and make DNS queries. I also wrap each Chrome pod in a microVM with Kata containers as an additional layer of security. Note It honestly terrifies me to think that I am putting more effort into securing these Googles Chrome than big AI companies are putting into securing their AI agent testing infrastructure . It literally doesn't take much to put a big dent into securing things! The current state of our industry boggles the mind. All I want is for Techaro's FelonyBench score to remain at 0, is that too much to ask? I also rigged the browser testing infrastructure up to a single slash command in GitHub pull requests. Doing it makes my office very warm so I try to avoid doing it when possible. This works well enough that it lets me move on to the next stage and has already caught something that lead to building all the JavaScript with the flag. I wonder if this is yet another case where making infrastructure for Anubis could result in that infrastructure alone being its own viable tech product. I run into a lot of those. In the process of doing that automated browser testing I found out that Chrome 75 had a weird error pop up when it tried to compile Anubis' WASM to native code: This also caused failures up to Chrome 100, so this signaled to me that something I was doing with my "strict MVP" build of Anubis' WASM wasn't in fact sticking to just the MVP features of WebAssembly. It turns out that the function referenced (probably somewhere in std::sync::Once ? I probably should have traced it down to the exact bit) was in the standard library. This surprised me because I assumed that building Rust code with CPU features selected would apply that to everything, including the standard library, right? No, turns out that when you download the component in rustup , that doesn't just download the standard library. To aid in cross compilation and I guess to avoid disk space waste, the Rust standard library is precompiled. This surprised me as Go typically has you recompile the standard library (and runtime for that matter) when doing normal builds and cross compilation. Note The actual issue here is that the stdlib function in question was compiled down to use reference types , which made references get stored as a table index instead of what MVP WebAssembly would put there. Chrome tried to read a null byte, got not a null byte, and then understandably exploded. I looked into the process involved for rebuilding the standard library twice: once with only MVP wasm features enabled and once with an "all yes config" like usual. Based on some research I did this seemed like a massive pain. However, I had gone through that effort to build reproducible WASI versions of wasm2js and wasm-opt. wasm-opt is a tool that lets you take compiled WebAssembly modules, optimize them, and more importantly remove features from them so they can run in older browsers. After a bit of hacking to make sure that the tools were able to run properly, I set up a Claude Opus / GLM 5.2 loop to fuzz various wasm-opt flags and make sure Chrome 75 could parse the output. I ended up with these flags: This strips away all the other WebAssembly features from the build like unwanted paint (the flag means "disable everything not in the original MVP definition of WebAssembly"). I think that it'd be safe-ish to enable reference types in the SIMD build (they were added before Chrome added SIMD), but it's not hurting anything to remove them so I'll just let cowardice win here. Either way, I threw the results into chromesweep and got a successful response, so win! If/when this comes to bite me I'll try and improve it. I'm pretty sure that this work isn't perfect , but at some point you gotta cut your losses, ship it, and then see where things fail to prioritize perfecting it. These issues include but are not limited to: Overall though, I'm hopeful that most of the worst parts of this can be solved. It would be nice if I didn't have to work what amounts to two full time jobs. I'm pretty sure that this is stable enough to ship as off-by-default in Anubis v1.28.0: Wuk Lamat . Based on the feedback I get from administrators and users, I'll enable it in the default configuration in Anubis v1.29.0. I hope this look into how Anubis is developed can give you ideas as to the scale and challenge involved. Making something like this is tireless and thankless work and it's really weird to see people talk about it in the same breath as Cloudflare or AWS' WAF. Have a good day all! Note AI was not used in the production of the prose of this article. I have my draft as a Google Doc so you can see exactly where and when I typed every word myself. The only use of AI was Claude Opus to help me make the visual bit/nibble diagram. The data buffer: up to 4096 bytes of challenge data. This is 4096 bytes so that it can (hopefully) land in its own 4Ki machine page. This is the only variable-length buffer in the setup, so it needs two calls: : return the pointer to the data buffer in WASM linear memory. This is used as the base address for copying data into the guest. : update the globally mutable "data length" variable to signal to the guest how much data was actually written into the data buffer. The combination of these two calls lets you treat that global data buffer as a slice. The result buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is read out of the guest's linear memory when the challenge is done processing. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). The verification buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is written into when the server is validating a challenge. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). : the main entrypoint for browsers. Given the data loaded into the challenge buffer, hash it in a tight loop until you get a solution that matches the difficulty. : the main entrypoint for the server. Given the data loaded into the challenge and verification buffers, ensuring that one run through the hashing function produces a result that both meets the difficulty demands and exactly matches what the client sent. The wasm2js flow doesn't currently have a way to update the progress bar with its import stubbed out. I have no idea how to properly wire that up with the constraints of my runtime, but I'm sure I can figure it out eventually. The WebAssembly that's shipped with this flow is ridiculously performant. This may mean you need to adjust the difficulty to compensate for this. Oops! Sorry! This is better on mobile phones, but I'm working on a mobile request classifier that will use a combination of IP address reputation, TLS fingerprinting, and other signals to determine if a request is from a phone and give it the appropriate amount of grace. This is hard. Please contact me if you have ideas on how to make my current prototype better. I need some way to dynamically rotate out challenge programs at runtime instead of just at compile time when I work on Anubis. Eventually I hope to have this pull WebAssembly files and supporting bundles from OCI/Docker registries, but I need to do a lot more experimentation with this before I can conclude how good/bad of an idea this is.

0 views
Himanshu Anand 1 months ago

a fake resume invoked China's defence-tech elite, then installed VShell

Disclosure: this research was conducted using an ANY.RUN account provided as part of a collaboration. All analysis and conclusions are my own. I did not execute the malware locally. Local work was limited to archive extraction, parsing, disassembly and decoding traffic already captured by the sandbox. I found a Chinese-language “resume” that is actually a Windows executable. It claims to come from a student at one of China’s Seven Sons of National Defence , opens a real Word document, which is nice of it and quietly delivers SNOWLIGHT and the VShell remote-access trojan , which is less nice. The chain is: What is it meant to do? Give an operator quiet, interactive access to a professor or research-lab workstation: command execution, file access, screen capture and a route further into the victim network. That is the most defensible objective. The malware proves remote access. The decoy tells us why that access was packaged as this particular document. The lure is most consistent with a mainland Chinese academic recipient , at moderate confidence. That describes the intended victim, not the attacker. Attribution is less exciting: this is best described as an unattributed actor using a mainland-China-oriented academic lure and the now widely available SNOWLIGHT/VShell ecosystem . The evidence does not establish the operator’s language, nationality, employer or government sponsor. UNC5174 is relevant history, not a conclusion. The sample appeared in ANY.RUN with a filename that translates roughly to: Inside the archive was an executable carrying nearly the same name. Windows hides known extensions by default, document icons remain a thing and humans remain human. This business model has survived more Windows releases than Internet Explorer. ANY.RUN gave it a 100/100 malicious score, labeled the process and showed the executable launching , opening a DOCX in Word and talking to one IP in a Hong Kong-registered netblock over two ports. Public geolocation sources disagree about where the server was physically located, so the registration is the claim I can actually support. Public task: ANY.RUN analysis 8d27f4bf-ed8c-461d-96e6-86968464dd86 The obvious answer was “it is a RAT.” The useful question was what does each stage do and what does the combination tell us about the operator’s objective? So I took it apart. The malware downloads a real Word document from: It saves it as: Then it launches Word. The victim gets the thing they clicked for, while the interesting part continues in memory. At first glance it looks normal: Zhang Yuguang, male, Beijing Institute of Technology, class of 2026, network engineering. Then you read past the first paragraph. The education section says the major is Electrical Engineering and Automation , not Network Engineering. The grades field is blank. The honors say “delete as needed.” The certificates say “adjust as needed.” There is no phone number, email address, employer or verifiable internship. It is a three-page resume that forgot the small detail of telling the reader how to contact the applicant. The research interests are much more specific: This is not really a corporate job application. It reads like an unfinished template for contacting a prospective graduate supervisor . That changes the victim model. The named university is probably the applicant’s claimed school, not the target. The likely recipient is a professor or research group working on electrical engineering, energy systems or applied AI, possibly at another institution. Beijing Institute of Technology is not just a convenient university name copied from a rankings table. ASPI’s China Defence Universities Tracker categorizes BIT as one of the Seven Sons of National Defence , a group of universities administered by China’s Ministry of Industry and Information Technology and closely associated with the country’s defence research ecosystem. The tracker lists BIT research areas including armament science, control systems, communications, optical engineering and unmanned aerial vehicles. BIT’s own laboratories page prominently lists the State Key Laboratory of Explosion Science and Technology and the Key Laboratory of Intelligent Control and Decision of Complex System. Subtle institution. Very difficult to imagine why a social engineer might consider the name technically prestigious. The harder-edged fact comes from the U.S. government. In December 2020, the Commerce Department added BIT to the Entity List . The published rule says BIT was added for “acquiring and attempting to acquire U.S.-origin items in support of programs for the People’s Liberation Army.” That makes the lure more interesting. A supposed student from a prestigious, defence-linked technical university is a credible reason for a professor working on power systems, intelligent control or applied AI to open a research statement. The affiliation adds authority and may signal that the sender’s work is worth reading. It does not prove any of the following: The malware gives us a claimed affiliation, not an enrollment record. The accurate headline is that the lure invoked the name of a defence-linked university . Anything stronger would turn an excellent hook into a false allegation. Metadata supports recent preparation but does not identify an operator: The sample ran on 21 August, eight days after the last edit. The WPS user ID and author fields may have come from a template. Treating inherited Office metadata as an operator passport is how threat-intel fan fiction begins. There are four different questions here and combining them produces bad attribution very efficiently: The first two are directly observable. The document uses Simplified Chinese and claims to be from a Beijing Institute of Technology student. The claim is unverified. The fourth is unknown. The third can be assessed, but only probabilistically. The mainland-China hypothesis is strongest because the document combines several local academic markers: A Taiwan-oriented version would be expected to use Traditional Chinese forms such as , , or . It does not. Hong Kong, Macau, Singapore and overseas Chinese-speaking supervisors remain possible, but an English-language, department- or laboratory-framed application would be less surprising in those settings. None of this proves the server operator was in mainland China. It says the lure writer wanted the file to look native to a mainland academic workflow. The likely recipient is a professor or research-group leader working on power systems, electrical engineering, renewable-energy control or applied AI. Beijing Institute of Technology is the claimed applicant’s institution, not necessarily the victim’s. Assessment: mainland China is the leading intended-victim geography at moderate confidence; attacker geography remains unknown. The first executable is a 5,073,728-byte, 32-bit Go binary built with Go 1.22.0 for Windows , with , a zeroed linker timestamp and a nominal Kingsoft signature that fails validation with . Its internal project name is and unlike the final payload it left useful symbols and source paths behind: The spelling is not mine. Malware developers also ship on Fridays. refers to Weibu / ThreatBook , a Chinese threat-intelligence and sandbox provider. The function obtains its own executable directory, lowercases it and checks whether it contains: If that sandbox-like path is present, it enumerates the directory and looks for a filename containing . Matching the condition terminates execution. If the path is absent, the function returns and execution continues. This is not generic “maybe I am in a VM” behavior. It is a check written for a particular analysis environment. The loader reads Go’s cached logical-processor count and exits when the machine has fewer than four. Small sandbox VMs often receive one or two. A real professor’s workstation probably has more. Very scientific threat model: if the computer can handle MATLAB, it can handle malware. Instead of trusting , the loader dynamically resolves , calls it at 30,000 Hz for ten seconds, records the time before and after and checks that ten whole seconds elapsed. If the sandbox accelerates time, it exits. On normal desktops that frequency is inaudible; the function name is funnier than the user experience. The loader’s strings are not stored as normal URLs. The decoding pipeline is: AES key and IV: Decoded values: This is reproducible directly from the embedded bytes. The first value begins as 128 printable Base32hex characters, becomes 80 bytes after Base32hex decoding, 72 after AES-CBC and padding removal, then 53 bytes after inversion and Base64 decoding: the full staging URL. The other two values follow the same pipeline. No live request is required to recover them. is the Word decoy. is encrypted shellcode. The names are camouflage for anyone glancing at HTTP logs and apparently losing interest after the file extension. The function named dynamically resolves: It allocates memory, copies the downloaded bytes, marks them executable, creates a thread at the payload and waits. The shellcode never needs to become a normal executable on disk. At this point the user is reading a resume and the loader is starting stage 2. Multitasking. The HTTP server identifies itself as . Its 6,224-byte response decodes to 1,454 bytes of x86 shellcode : It uses a classic ROR-13 API resolver and loads functions without a useful import table. All 16 hashes resolve to , , , , , , , , , , , , , , and . The behavior is a byte-for-byte conceptual match for the Windows SNOWLIGHT stager documented by SOCRadar , Sekoia and HPE Threat Labs . Its job is intentionally small: The exact client check-in was captured in the PCAP: That 40-byte packet is useful for detection because it is much more specific than “a Windows computer used TCP.” SNOWLIGHT does not authenticate the server, verify a signature or hash, validate an header or receive an explicit payload size. It keeps copying results into the fixed 30,000,000-byte region and appears not to enforce that boundary. The panel stager is small because it delegates both trust and memory safety to whoever answered the socket. What could go wrong has been scheduled for the next stage. The sandbox network table showed a 4 MB response, but no file appeared in the dropped-file list. That makes sense: SNOWLIGHT receives the payload directly into executable memory. The PCAP still had it. I downloaded the recorded PCAP from ANY.RUN, reassembled only the server-to-client stream for and XORed it with . No connection to the live server was made. The result is a valid nine-section, 32-bit Go PE with image base , entry point , image size 4,648,960 and a zeroed timestamp. Its 3,088,384-byte section has entropy , almost the theoretical maximum of 8, which is consistent with the embedded configuration and much of the program being encrypted or obfuscated. Go build information exists but reports ; symbols are obfuscated and there is no helpful plaintext label. The author did not leave a README inside the implant. Disappointing. Windows Defender blocked the cleartext PE immediately after reconstruction. I did not disable or bypass it. Further inspection decoded the captured stream only in the analysis process’s memory. Why call it VShell if the string is hidden? That is stronger than trusting one antivirus label. Independent static and network features all land on the same family. Execution did not stop at the final PE. Immediately after the 4.65 MB transfer, the host opened three new TCP sessions to the same listener from local ports , and . I reassembled both directions and parsed 198 complete VShell frames . Every frame uses the public protocol’s four-byte little-endian length followed by a 12-byte nonce, AES-GCM ciphertext and a 16-byte tag. Subtracting that 28-byte encryption overhead exposes a very recognizable startup sequence: The first connection is consistent with completing registration, including the 405-byte client record. The second performs another channel handshake. The third repeats small, nearly mirrored health frames for about 142 seconds. Public VShell protocol research documents the same version negotiation, MD5 verification, registration and heartbeat loop. VShell derives its AES-256-GCM key as the ASCII hex digest of . The salt is obfuscated inside this final payload. Default and common values including , , , , , and an empty string failed authentication, so I cannot show the plaintext fields. The sizes, ordering, direction-marked nonces and cadence nevertheless prove that the RAT initialized and exchanged its normal control protocol. They do not prove a human was at the console. I found no unusual frame sizes, file transfer, shell stream or other break from registration and health traffic. Observed: successful VShell registration and C2 health traffic. Not observed: an operator issuing a command. There are two answers: what this sample demonstrably does and what the delivered VShell platform allows the operator to do . Public VShell analysis and recovered console material document: This matters because VShell is not a one-shot password stealer. It is an operator platform . The initial executable does not need to know which documents are valuable or which neighboring machine should be targeted. A human can decide after the victim checks in. I did not observe an operator issuing commands in this sandbox run. I also did not prove persistence, document theft or lateral movement on this particular host. Those are capabilities and plausible follow-on actions, not facts I am going to promote to “observed” because the conclusion sounds cooler. Malware rarely contains a string saying . You infer objective by combining behavior, access level, targeting and concealment. Full VShell RAT, not a simple downloader: the operator wants interactive control after infection. That supports continued access, collection and human-directed follow-on decisions. Fileless staged execution: reducing obvious disk artifacts helps the implant stay quiet long enough for somebody to use the access. Real DOCX opened after infection: keeping the victim from suspecting failure is part of the operation, not a side effect. Graduate-supervisor language: the document is written for an academic recipient, making professor or research-group targeting more likely than corporate recruiting. AI, power-grid and renewable-energy themes: research data, institutional accounts and access to technical networks are all plausible targets. Screen, file, shell and proxy capabilities: the workstation is a foothold. It does not have to be the operator’s final destination. My confidence-graded assessment: High confidence: the immediate objective is remote access to the victim machine. Moderate confidence: the intended victim is mainland Chinese university faculty or a technical research group, not ordinary corporate HR. Moderate confidence: likely follow-on goals include collecting research material, credentials and internal access, with lateral movement available if the host is valuable. Low confidence: whether the ultimate motivation is state-directed espionage, commercial theft or access brokerage. One-line version: turn a believable academic document into quiet, full-featured control of a research workstation, then let the operator decide what is worth stealing. SNOWLIGHT has history. Mandiant named it while tracking UNC5174 and Sysdig linked SNOWLIGHT-delivered VShell to UNC5174 , a China-nexus contractor associated with espionage and access brokering. If this analysis stopped in 2025, “possible UNC5174” would be tempting. But the ecosystem changed. Cracked VShell releases and loader-generation panels circulated widely. More importantly, SNOWLIGHT is not merely malware that several actors happened to copy. It is a default stager generated by the VShell management panel . The operator selects Windows 32-bit, TCP and staged delivery; the framework produces the same broad implementation seen here. Trend Micro demonstrated why that distinction matters . Another report had assigned a SNOWLIGHT-to-VShell intrusion to UNC5174. Trend instead tied it to Earth Lamia with actor-specific VOIDMAW packaging and a repeated PDB path, while explicitly warning that SNOWLIGHT alone was unreliable because any VShell user could generate it. The panel-generated stager invalidated the tool-only attribution; the packaging artifacts supplied the positive one. HPE reached the same practical conclusion in a 2026 PyPI investigation: leaked and cracked VShell builds have moved the framework beyond an exclusive APT toolkit. What supports a mainland-China-oriented victim context here: Separate facts place parts of the development and tooling context in Chinese-language security communities: Neither category is a nationality test. The lure describes the intended social context; it does not tell us who sat behind the panel. The tooling facts may belong to an upstream loader author, copied source or a commodity framework rather than this operator. The technique was also published in Chinese offensive-development material before this sample, so copying it does not identify a private toolmaker. The last row requires the fewest leaps. Cisco Talos calls SNOWLIGHT a generic VShell stager in its UAT-8302 research and Google describes VShell as publicly available and used by actors with varying motivations in its UNC6586 reporting . Neither source links this campaign to those groups. What is missing for actor-level attribution: The infrastructure adds one useful but limited fact. A ThreatFox-derived feed listed as VShell on 15 April 2026, months before this sample used and . That supports repeated VShell use of the host. The original historical ThreatFox record is no longer available through the unauthenticated API, so I treat it as secondary-source evidence, not a campaign bridge. Best label: unattributed actor using a mainland-China-oriented academic lure and the leaked/commodity SNOWLIGHT and VShell ecosystem. Less exciting than naming an APT. More likely to survive contact with evidence. The exact campaign infrastructure is compact: An ANY.RUN TI search for the exact destination IP across the available 180-day window returned one analysis: this one. It exposed the two staging URLs, three recorded connections and the VShell network alerts, but no second sample or campaign link. Passive DNS tells a longer and mostly unrelated hosting story: OTX passive DNS directly supports the old DNS resolutions. urlscan independently observed serving nginx content from this IP in December 2024 with a certificate valid only for that hostname. No named domain appears in the malware configuration or PCAP, the hosts have no matching public urlscan results and no certificate, sample or URL joins them to VShell. Classification: historical tenants of the same IP, not campaign IOCs . The same applies to unrelated domains on the subnet or ASN. Infrastructure graphing becomes fiction if every former neighbor is invited to the conspiracy. Block or investigate: High-signal SNOWLIGHT check-in: This is , network-order port , the ASCII IP and zero-filled fields. Adapt the IP and port portions if hunting generically across generated SNOWLIGHT samples. The Kingsoft certificate attached to the first loader is invalid with . It is camouflage, not evidence that Kingsoft or WPS participated in the operation. The interesting part of this sample is not that a resume delivered a RAT. We have been putting malware in job applications since recruiters first learned to double-click. The interesting part is how cleanly the behavior and lure answer different halves of the objective question . The code says the operator wanted an interactive foothold. It spends effort on sandbox detection, time checks, encrypted staging and fileless execution, then installs a framework built for shells, files, screens and tunnels. The document says whose foothold might be useful: somebody reading graduate applications about AI, electrical systems, power grids and renewable energy, presented under the name of a university with real defence significance. Probably a professor or research lab. Not necessarily Beijing Institute of Technology and not necessarily a government target, but clearly more specific than “any Windows user with a mouse.” That is how I would present the objective: not “VShell equals espionage,” and not “China lure equals APT.” Instead: A targeted academic lure was used to establish stealthy VShell remote access, most likely enabling collection of research material and credentials and providing a pivot into the recipient institution. The exact operator and final motivation remain unconfirmed. Precise enough to be useful. Honest enough not to become malware astrology. If you are a professor reading unsolicited resumes: the applicant may be excellent, the research statement may be inspiring and the file extension should still be visible. Stay safe. And maybe do not let join the lab before the student does. A fake Beijing Institute of Technology graduate-school resume is opened. A custom Go loader checks for sandboxes, verifies the machine has at least four CPU cores and uses as a sleep timer. It downloads and opens a genuine DOCX decoy so the victim sees the document they expected. It reflectively runs a 1,454-byte Windows SNOWLIGHT stager. SNOWLIGHT connects to , downloads 4.65 MB, XOR-decodes it with and jumps into a fileless VShell RAT. AI-based power-grid fault diagnosis intelligent electrical systems renewable-energy converter control power-system automation joining “your research group” and completing experiments and papers That the applicant is real or has any connection to BIT That BIT created, distributed or knew about the file That BIT itself was the intended victim That defence research was the confirmed collection target That the operator was foreign to China, state-sponsored or conducting espionage What language and theme does the lure use? Who does the document claim the applicant is? Where is the likely recipient? Who built or operated the malware? , the standard mainland term for a fresh graduate , , and the deferential phrase , the mainland College English Test Band 6 , the National Computer Rank Examination’s second level , a PRC education-system honor WPS locale , plus and fonts A supervisor-centered pitch promising to follow the research group’s arrangements, complete experiments and publish papers Exit if that file exists. It is a kill switch or operator exclusion marker. Connect to , retrying every ten seconds. Send a 40-byte architecture and server check-in. Allocate exactly 30,000,000 bytes of executable memory. Receive the next payload in -byte chunks. XOR every byte with . Close the socket and call the decoded buffer. ANY.RUN’s Suricata rules identified the protocol as VShell. The delivery stub is the exact SNOWLIGHT family used to stage VShell. The final payload has the expected obfuscated Go core structure. The panel behavior, architecture tag, , XOR and cross-platform stager design all match public VShell/SNOWLIGHT research. Detect and avoid analysis environments Refuse low-CPU systems Detect accelerated sleep behavior Decrypt hidden network configuration Download a benign-looking decoy Open the decoy in Word to preserve the illusion Download shellcode from remote infrastructure Execute shellcode in its own memory Establish a raw TCP staging connection Download and decode a full RAT without writing it normally to disk Transfer execution into the VShell core Complete VShell registration and exchange sustained encrypted health traffic Interactive terminal and arbitrary command execution File browsing, upload and download Screenshot capture and interactive screen access Host, user, process and network reconnaissance Reverse proxying and tunneling into otherwise unreachable systems Multiple C2 transports, including raw TCP, WebSocket, KCP and others Simplified-Chinese academic lure and PRC-specific education vocabulary WPS Office locale Supervisor-centered research-group language A function specifically named Evasion tailored to the Chinese ThreatBook sandbox VShell’s origin and popularity in Chinese-language offensive-security communities A unique UNC5174 domain or known C2 A matching cryptographic configuration A reused final-payload hash Operator logs or identity artifacts A campaign-specific overlap stronger than commodity tooling Resume-themed executables launching and then Word or followed by and Processes allocating tens of megabytes as executable memory immediately after a large raw TCP receive Go binaries with an invalid or hash-mismatched certificate and document-style names Outbound TCP beginning with six-byte architecture tags such as , , , , or Post-stage TCP frames beginning with plausible little-endian lengths and direction-marked 12-byte AES-GCM nonces ANY.RUN public analysis ASPI China Defence Universities Tracker: Beijing Institute of Technology U.S. Commerce Department: 2020 Entity List additions Beijing Institute of Technology: research laboratories and centers China Education Examinations Authority: College English Test China Education Examinations Authority: National Computer Rank Examination Sysdig: UNC5174’s evolution from SNOWLIGHT to VShell Sekoia: SNOWLIGHT configuration extraction HPE Threat Labs: Unmasking the SNOWLIGHT stager SOCRadar: Tracing SNOWLIGHT Esonhugh: reverse engineering the VShell client and protocol Trend Micro: Earth Lamia and why SNOWLIGHT is not attribution Cisco Talos: UAT-8302 and its SNOWLIGHT/VShell deployment Google Threat Intelligence: UNC6586 React2Shell activity OTX: passive DNS for 38.207.178.192 urlscan: historical pan.qlam.cc result

0 views

Concurrent Servers: Part 8 - Go

This is part 8 in a series of posts on writing concurrent network servers. In this part, we'll switch to Go and see how it tackles the challenges described earlier in the series. All posts in the series: This post assumes a basic familiarity with the Go programming language. As before, we'll start with a sequential server for the basic state machine protocol presented in part 1 . This is the main function: As in the previous parts, the server is "infinite"; it never stops serving new connections until it's explicitly killed. This is the function implementing the protocol for a single client; it takes a net.Conn value that represents a socket with a client connected on the other end: Rather than directly exposing OS threads, the Go runtime implements its own M:N scheduling of lightweight goroutines on top of OS threads. Using goroutines in Go is cheap - both in terms of syntax and developer effort, and in terms of system resources . Here's a version of our serial protocol server that serves clients concurrently by launching a goroutine for each client. The part of the code that's different from the previous sample is highlighted: The concurrent modification in this case is particularly simple because the server is infinite; there's no point waiting for these goroutines to finish (and hence no need for a sync.WaitGroup ). The parameters for server.ServeSerialProtocol are lexically captured from the enclosing scope and its return value is handled by the surrounding closure. Because goroutines are very cheap, this server is very unlikely to run out of resources due to launching too many goroutines; in fact, it will probably run out of something else - like file descriptors for sockets - first. However, sometimes it's still useful to limit the degree of concurrency - even in Go, and we'll discuss some approaches to do so in the following sections. Here are some scenarios in which it makes sense to limit the degree of concurrency in Go programs, even though goroutines are cheap to launch and operate: Let's switch to the primality testing server from part 4 for the rest of the post, because it represents a somewhat more realistic workload. As a reminder: the server receives numbers, simulates blocking by sleeping, and returns "prime" or "composite". The unbounded one-goroutine-per-client version looks almost identical to the previous code sample, except that the goroutine invocation calls another function: Where ServePrimeProtocol is [1] : The simplest way to limit concurrency in Go is by using a counting semaphore, implemented with a channel: The channel sem serves as a semaphore; note that it's a bounded channel with a maximal size. A token is acquired by sending to the channel, and released by receiving from the channel. When the channel is full, the send operation sem <- struct{}{} blocks until a token was removed by some other goroutine [2] . The type of the channel is struct{} which means "empty", or "no data". This is idiomatic in Go for channels that are used solely for their semantics, not to send/receive any actual data. Since launching goroutines is cheap and limiting concurrency is easy as shown above, the "worker pool" pattern is often unnecessary for scenarios like our server. Still, it has occasional uses (such as when workers have to maintain some non-trivial state across tasks) so it's worth discussing it here. Here's a variant of our primality testing server that uses a worker pool: A fixed number of worker goroutines is launched; these goroutines all receive "jobs" from the same channel. In the Accept loop, each client connection is sent to this channel as a new job and is picked up by the next available worker. As mentioned before, you would typically see a sync.WaitGroup somewhere to ensure clean shutdown of goroutines, but in our case it isn't necessary because we have a server that never exits. Do programmers have to resort to async / event-driven programming in Go? In my experience, almost never. Go was designed from the bottom up to be suitable for large-scale concurrency; goroutines are very cheap to create, have a tiny memory footprint and switching happens very quickly, all in user space. Measurements I ran back in 2018 have shown switching times of ~170 ns, as compared to 1-2 microseconds for threads on Linux. Moreover, Go already uses event-driven loops like epoll underneath for I/O. Goroutines that wait for I/O like sockets are effectively "parked" and consume no resources (beyond their small memory footprint); they are woken up by Go's runtime when their I/O descriptors are ready - this is very similar to how asynchronous programming works! That said, some people certainly do try to stretch their resources even more with direct asynchronous programming in Go when millions of streams are handled concurrently. All I'll say is that this is very rare, and an overwhelming majority of users never have to do this. In 2018, I wrote a post named Go hits the concurrency nail right on the head , and after several more years of active coding, I fully stand behind that statement. Go is extremely powerful and ergonomic for concurrent programs; while other environments go to great lengths to implement async-await style event loops in libraries, in Go it's already baked into the core language and runtime. You want event-driven I/O with very lightweight green threads that can also execute blocking tasks without worrying about the function coloring problem ? Go has you covered. All the code for this post is available on GitHub . Careful readers will note two issues with this code: (1) the protocol assumes the complete number is read from the socket in a single conn.Read call, and there's no framing - separation between distinct numbers; (2) the prime checking loop uses i*i which may overflow for large numbers. These issues are consistent across all the versions of the prime server in C, Python, JavaScript and Rust in earlier parts, because my focus was on the simplest possible code to demonstrate a point about concurrency. Part 1 - Introduction Part 2 - Threads Part 3 - Event-driven Part 4 - libuv Part 5 - Redis case study Part 6 - Callbacks, Promises and async/await Part 7 - Rust Part 8 - Go (this part) Tasks may be compute intensive, and the CPU capacity of any server is inherently limited. If too many concurrent goroutines compete for limited CPUs, they will all make very little progress. It may make more sense to have fewer tasks that complete in a reasonable time. Protecting potentially limited downstream resources, such as concurrent DB connections or other services. For example, if the server has to send requests to other services for each task, and these are rate-limited, concurrency will have to be carefully managed. Security reasons when work is dictated by clients; malicious clients can overload and crash a service that's too eager to serve, making it unavailable for legitimate clients.

0 views