Technical Report: Drift

Drift is a Kotlin Multiplatform command line tool and desktop, web and mobile app, version 1.0.0. It records an environment as a capsule, compares two capsules, ranks what changed between a passing run and a failing one, proposes the experiments that would separate the candidates, and writes a container description that mirrors a capsule. Its Lab runs those experiments in containers under a rule fixed before the first trial, reports a verdict, and packs the evidence into an archive that a second command re-checks. It also works from one capsule or one issue text without a second environment: drift upgrade-check lists what a move to other versions breaks, drift triage reads an issue body offline, and drift contract writes what a program needs from an environment. It ships two measured data sets: the Kotlin Portability Atlas, a table of Kotlin behavior across targets and compiler versions, with a static site and a viewer in the app, and DriftBench, a seeded benchmark of container pairs with an injected cause. The release pipeline builds a CLI archive per platform, consumer installers for the app, a Homebrew formula and cask, and two Chocolatey packages.

This document is the engineering reference for what the working tree does today, what it costs, and what was measured. Each table names its instrument. The section Reproducing the Numbers lists the command or file behind every figure. Test counts, line counts, coverage, rule counts, Atlas hashes and command output describe the tree on 2026-10-13 and were read again from a clean build on that day on a Mac; they move with the tree. Figures from other machines or earlier days (the Docker and paisley-park runs, Wine, the installers, the browsers and the performance sweep) name where and when they were taken and were not repeated. Build Project, Record Atlas, Deploy Documentation and Mirror to GitLab have run on GitHub, and Bench Host Arms ran once; Release Project has not run (GitHub run status read on 2026-10-26). Every workflow is checked with actionlint and its steps were exercised by hand on a Mac and on a Linux host, and no Windows machine was available.


Summary

measured instrument
Targets built 6: JVM, macOS arm64, Linux x64, Linux arm64, Windows x64 (mingw), Wasm on Node; -Pdrift.mobile=true adds Android and the iOS simulator for the app drift.kmp plugin
Test runs in the tree JVM, macOS arm64 Native and Wasm on Node by Gradle on a Mac; Studio in Chrome by Gradle and in Firefox by Karma; Linux x64 and Linux arm64 Native in containers; mingw under Wine, and JVM and mingw on GitHub's windows-2025 runner in build.yml */build/test-results/*/*.xml, container logs (see Platform and CPU Findings)
Core tests, each of the 3 Gradle targets 638 passed, 0 failed, on every target */build/test-results/*/*.xml
Shared-code ratio, all modules 91.1% common lines (27,626 common, 2,697 platform); core and scan 100%, cli 72.1%, host 43.7% ./gradlew sharedRatio
JVM line coverage 96.0% (20,448 of 21,293 lines); floor 95% build/coverage/summary.txt
core and bench common code using Double or Float 0 occurrences grep -rn
macOS arm64 CLI, release executable 9,866,776 bytes (executable built 2026-10-15, before drift why; 28 subcommands then, 29 in the current tree; the Linux x64 executable of the current tree is 15,616,968 bytes) ls -l, drift --help
Probes in the catalog 71: 33 general, 38 Kotlin drift capture --probes
Scanners 23 Scanners.all
Rules 429 in 51 folders: 376 active and 53 pending-scanner; 1493 fixtures (469 positive, 1024 negative); 497 cited sources (315 distinct) rules/**/*.yml
Atlas, Kotlin 2.4.20, 16 columns 32 of 38 probes differ on at least one column; 6 are identical on all 16 drift atlas build fixtures/atlas/*.txt
Atlas, Kotlin 2.5.0-Beta1, 5 columns 18 of 38 differ drift atlas build over the Beta fixtures
Atlas version ladder 14 compiler versions, 1.3.72 to 2.4.21, plus 2.5.0-Beta1; 74 columns, 2,812 cells, 34 divergent probes drift atlas build
Atlas probe classification 10 documented, 4 platform-defined, 24 unclassified; among the 32 divergent probes on 2.4.20, 4, 4 and 24 drift atlas show
Atlas site 79 files, 18,548 KiB, 39 probe pages and versions.html ./gradlew atlasSite
DriftBench 272 scenarios from 135 template ids; 122 dev, 150 sealed test (split.json) bench/data
Ranker on the 105 dev scenarios that have a cause and probed captures, with probes top-1 46 of 105 (Wilson 95% interval 0.35 to 0.53); random order expects 36.2; exact P = 0.0012. Top-3 67 of 105 against 57.0 expected, P = 0.00002 evaluation.json
plausible or known top candidate wrong 25 of 115 dev scenarios (0.15 to 0.30); 40 of 62 plausible candidates same
Lab, 45 dev scenarios, first run 14 CONFIRMED (all correct), 9 CONFIRMED EFFECT (bundle), 1 NARROWED, 21 STUCK; 0 false CONFIRMED (the four migration scenarios added later: 2 CONFIRMED EFFECT (bundle), 1 NARROWED, 1 STUCK) 45 case directories, drift verify
Lab, three more runs of the same 45 180 runs in all: 0 false CONFIRMED, exact one-sided 95% upper bound 1.65%; 2 of 45 scenarios changed verdict class, both intermittent 135 more certificate.json files
.driftcase archives, JVM and macOS native byte-identical; verify output identical; 4 archives in fixtures/lab/cases cmp
Studio installers built here macOS dmg 74,624,330 bytes and pkg 70,015,031; Linux deb, rpm and AppImage in a container; Android APK 7,203,057; Windows msi and exe not built studio/build/compose/binaries, androidApp/build/distributions
CLI release archives 2.5 to 2.8 MB each for macOS arm64, Linux x64, Linux arm64 and Windows x64 .github/release/stage.sh
Drangler transition, ranked 9 candidates, 1 bundle, no single cause named drift diagnose
Flagship case D, migration PHP 7.4 to 8.3 upgrade-check frozen prediction 0 true positives, 12 false positives; the suite passed on 8.3 fixtures/cases/d
Triage on 78 fixture bodies precision 0.990, recall 0.980, Brier 0.0848; claim check: 0 of 67 labelled threads flagged TriageTest, triage/claim-labels.yml
Native CLI, Mac, 15 runs version 7.5 ms, capture 24.3 ms, diagnose 9.0 ms, 13.0 MB peak RSS (recorded 2026-10-12) tools/perf/results/SUMMARY.md

Six results shape how to read the rest. No fitted ranking weight beat the hand-set weights out of fold, so the weights are hand-set and every ranking says so. On the benchmark the ranked order beats a random order at the top: 21 of 45 at top-1 against 16.3 expected (P = 0.018) and 29 of 45 at top-3 against 25.9 (P = 0.009). An earlier measurement on 41 scenarios could not tell the top-3 order from chance (P = 0.37); the regenerated data refutes that. The plausible tier, which a differing probe that reads the candidate's attribute or a matched rule earns, is not reliable on this benchmark: 14 of its 25 candidates are wrong, because the probe agrees with decoys that really changed, and probes move the order of two scenarios, both for the worse. The probability the tool prints exists only for rankings that rest on attribute changes alone. It is the share of the posterior read through an isotonic map fit on the 115 probed dev scenarios and not held out. Out of fold it scores Brier 0.170 against 0.173 for "one of n", a gap far smaller than the spread between folds (0.139 to 0.245). In the migration case, Drift's predictions lost: the frozen upgrade-check output named 12 breaks and none happened (0 true positives, 12 false positives), because every rule fired on the version crossing alone and the real obstacles, a locked package with a PHP upper bound and a tentative return type, have no rule. And the claim check in triage is weak: it flags none of the 67 labelled threads and no clear true positive among its 6 hits in 222,707 corpus bodies.

What does hold up under test is the machinery around the ranking. Output is byte-identical on three targets and pinned by hashes, including the recorded certificates of the flagship cases. A bundle of changes the data cannot separate is reported as a bundle. A missing probe is missing evidence and never a pass. The planner prices each experiment in expected bits before anything is run, and says which experiments no one can run on a hosted runner. The Lab fixes its decision rule before the first trial and gave no false CONFIRMED verdict in 180 runs of the 45 dev scenarios (synthetic scenarios, one container configuration, and a bound that holds per run, not per kind of failure). verify re-derives every count, p-value, posterior and verdict from an archive alone. It does not re-run an experiment.

Platform findings sit beside those. A Kotlin probe can print different bytes on x86-64 and arm64 hardware, on different JDKs, and on a Native executable linked on a different build host, so an Atlas column describes a machine as much as a target. The deployed Cloudflare Workers matched the local workerd runs except for the sign bit of NaN results. Safari was not measured, Windows was not run, and no workflow has run on GitHub.


Problem and Scope

A test passes on one machine and fails on another, or passes on Tuesday's CI run and fails on Wednesday's. The environments differ in many attributes at once: runner image, toolchain versions, locale, resource limits, dependency pins. A diff lists all of them and ranks none. Deciding which difference caused the failure needs evidence about each one, and that evidence is an experiment.

Drift covers six steps of that work.

  1. Record. drift capture writes an environment as a capsule: a canonical JSON document with a SHA-256 hash. A capsule holds attributes (operating system, kernel, toolchain versions, environment variables, resource limits) and probe results (short programs whose printed output records how the environment behaves).
  2. Rank. drift diagnose diffs a passing and a failing capsule, assigns each change to one of 14 dimensions, and orders the candidates with explicit weights. Given a run history, it also uses which runs passed and failed.
  3. Plan. The planner turns the candidates into experiments, each with a predicted information gain, a cost and exact instructions, and says which ones Drift cannot run.
  4. Mirror. drift reproduce writes a Dockerfile, run.sh and a manifest that mirror a capsule and list, attribute by attribute, what a container cannot reproduce.
  5. Run. drift solve runs counterfactual arms in containers under a preregistered decision rule and ends in one of four verdicts. drift ingest adds results that someone else produced, under the same rule.
  6. Verify. drift pack writes the case as a .driftcase archive, drift report renders its static report.html, and drift verify re-derives what the archive records from its own data.

The Atlas applies the same capture idea to the Kotlin standard library: it records what 38 short Kotlin programs print on each target and compares a device against that table.

Further commands work without a second environment or without running anything: drift run, import, bisect and contract keep a CI run as a capsule, narrow a red and green pair and write what a program needs; drift upgrade-check (see Upgrade Check) lists what a move to other versions breaks from one capsule; drift triage (see Triage and the Corpus) reads an issue body offline; drift open shows a .driftcase in Studio. Three further commands deal with delivery and are described under Serving Studio Locally and Distribution. drift serve serves the Studio web build on the loopback interface. drift install and drift uninstall copy the executable onto the user's PATH and reverse that from a receipt.

What Drift does not do here: diagnose runs no experiment, reproduce never runs Docker, atlas show files nothing and contacts no one, and verify never re-runs an experiment. serve answers GET and HEAD on loopback only. The ranking tiers stop at known (a documented rule matched), and the text that diagnose, next and case show print never says "confirmed". Only solve, after its own experiments, can say CONFIRMED. solve needs Docker and runs experiments on the local machine; it has no CI mode.


Design Principles

Determinism. Everything in core and in the common code of bench is integer arithmetic. FixedPoint holds a value in micro-units (1,000,000 is 1.0) and implements ln and exp with integer series; IntSqrt takes integer square roots; probabilities are normalized by largest remainder so a belief always sums to exactly 1,000,000. A search of core/src/common/main and bench/src/common/main finds no Double and no Float. The fixed-point results agree with the platform math library to the micro-unit on the values GoldenTest pins:

expression fixed point (micro-units) math library
ln(3) 1,098,612 1.0986123
exp(-2.5) 82,085 0.0820850
ln(123.456789) 4,815,891 4.8158912
exp(7.25) 1,408,104,848 1408.1048482

Byte-identical output. Golden tests assert exact bytes and a SHA-256 in common test code, so the same assertion runs on the JVM, macOS arm64 Native and Wasm on Node. The .driftcase archive follows the same rule (see The Lab). GoldenTest pins the canonical bytes of a capsule with non-ASCII text, a control character and a surrogate pair (hash 9a69c086...edf1), the hash of 1,000 a characters (41edece4...7ea3, checked here against Python's hashlib), and a canonical rule. Ranking, plan, case and rendering goldens follow the same pattern. The macOS release CLI printing drift diagnose for a real pair produced the same hash, 99f637b2...38f7, as the golden that DriftCommandTest asserts on all three targets.

Canonical JSON. The encoder sorts object keys at every depth, writes no whitespace, accepts integers only, escapes strings with a fixed table and rejects a lone surrogate. A capsule's hash is the SHA-256 of that encoding. For fixtures/lab/node-alpine.json the hash printed in the reproduce header, 540e5c22...0531, equals sha256 of the file without its trailing newline.

Unknown beats incorrect. An absent tool writes no attribute. A probe that needs a capability the host lacks reports unavailable, and a diff treats that as missing evidence. A rule whose attributes are absent returns UNKNOWN and never matches. ci.runner.label and runtime.platform values such as workerd-local appear only when the workflow declares them in DRIFT_RUNNER_LABEL or DRIFT_RUNTIME_PLATFORM. A ranking whose candidates carry probe, rule or spectrum evidence prints no probability: no dev scenario carries rule or history evidence, and on the 13 that carry a probe the hand-set probe weight read worse than "one of n" (see Evaluation and Limits).

Labelled weights. Every ranking and plan carries its weight set (hand-set-1, plan-hand-set-1) and "calibrated": false. The renderer calls scores hand-set and says they order candidates and are not probabilities. A ranking that prints a probability adds a probability block with calibrated: true, read through the fitted map (see Probability).

Test seams. Tests run against a fake host, recorded tool transcripts under fixtures/transcripts, a fake HTTP fetcher for the rule-source verifier, a fake docker script for the benchmark runner, a fake Executor that the solve loop runs against, a fake InstallSystem for install and a fake ServeSystem for serve. One scan test runs real commands on the host and compares the result with the same parse of recorded output. A few tests touch the real file system or a loopback socket inside a scratch directory: NativeInstallTest, MacosInstallTest, JvmServeTest and PosixServeTest.

Gates. The build fails on a spotless (ktlint) violation, a Kotlin line over 100 columns, a warning (all warnings are errors), a shared-code ratio under 80%, a core that contains platform code, a host over its platform-line budget, or JVM line coverage under 95%.


Architecture

module owns
core Common code only. Capsule model, canonical JSON, SHA-256, fixed-point math, version parsing, diff, rank, rules, history, planner, case directories, symptom extraction, redaction and anonymization, spectrum scoring and delta debugging, the container synthesis behind reproduce, and the Lab (lab/): interventions, arms, the solve loop, ingest, the USTAR archive, report.html and verify.
host One expect/actual per target behind the Host interface: platform, environment, file read, process run. The JVM actual also offers hardware facts through OSHI 7.7.0; no CLI option calls them. Wasm has no filesystem and no process access. iOS declares files and environment only.
scan Capture, the probe catalog, the scanners and kit, and the Atlas (record, build, compare, show), including the typed probe classification.
cli The drift command on Clikt 5.1.0, with 29 subcommands: why, capture, run, import, ci, handsoff, bisect, diagnose, compare, next, case, reproduce, atlas, probe, solve, ingest, pack, verify, report, open, install, uninstall, serve, contract, triage, upgrade-check, doctor, help and completion. Nested subcommands such as atlas show are not counted. Writes files and exact stdout bytes through a small expect fun per target, holds the Docker executor behind solve, the installer, and the web server behind serve.
studio Compose Multiplatform 1.12.1 app for JVM Desktop and for the browser as Wasm, plus the Android and iOS builds when -Pdrift.mobile=true is set.
androidApp The Android application module (AGP 9.4.1), present only with -Pdrift.mobile=true.
iosApp The Xcode project for the iOS simulator app; it calls Gradle with -Pdrift.mobile=true.
tools JVM only. Freezes CI logs into sanitized fixtures, builds capsules from them, verifies rule source URLs, drives the Kotlin version ladder (tools/ladder), builds the triage corpus and scores the extractor on it (buildCorpus, downloadCorpus, evalTriage), and holds the performance rig (tools/perf). Also holds the Playwright scripts (tools/browsers: run.mjs records the browser columns, serve.mjs loads a served URL in each engine, layout.mjs captures page layout into capsules) and the installer test scripts.
bench DriftBench: scenario DSL, seeded generator, container runner, ingest, calibration.
build-core Included build with the drift.kmp, drift.layout, drift.coverage, drift.quality, drift.mobile, drift.docs, drift.docs-root, drift.atlas and drift.packaging plugins and the tasks they register: embedFixtures, embedRules, embedClassification, embedDriftVersion, embedKotlinVersion, sharedRatio, checkLineLength, the coverage tasks, atlasSite, docsSite, checkSiteLinks and renderPackaging.

The remaining directories are data and delivery, not modules: rules (429 YAML rule files in 51 folders), triage (the corpus manifest and the claim-check labels), notices (third-party notices that ship in every artifact), atlas (the hand-maintained classification of every Atlas probe), fixtures (recorded capsules, Atlas transcripts, solved cases, project trees, issue bodies and upgrade-check captures), packaging (templates for the Homebrew and Chocolatey packages), assets (the icon), and install.sh and install.ps1 at the root. .github/release holds the scripts the release workflow runs. The version is version=1.0.0 in gradle.properties; core embeds it for --version, the Studio and Android packages read it, and the Xcode project carries its own copy of the marketing version.

Toolchain. Drift builds with Kotlin 2.4.20, Gradle 9.8.0 and, for androidApp, AGP 9.4.1, on JDK 21 in every workflow. The Kotlin Multiplatform compatibility guide lists Kotlin 2.4.20 and 2.4.21 with Gradle 7.6.3 to 9.7.0 and AGP 8.5.2 to 9.3.1 (read 2026-10-13). Drift's Gradle and AGP are newer than that range, so the combination is outside what the guide lists; build.yml, atlas.yml and docs.yml build and test on it without a failure, on Linux, macOS and Windows runners. Nothing more than that is claimed for it.

Sources sit at src/<set>/main and src/<set>/test. The shared-code ratio counts non-comment lines under src/common/main against every other source set's main.

module common lines platform lines shared
host 423 545 43.7%
core 13,540 0 100.0%
scan 4,928 0 100.0%
cli 3,697 1,431 72.1%
studio 2,959 159 94.9%
bench 2,079 562 78.7%
total 27,626 2,697 91.1%

host is where the platform code lives, at 545 of its 545-line budget, and its own shared share is 43.7%. The budget was 400 until the process runners were rewritten and 630 after: the POSIX runner (posix_spawn through a small cinterop definition, with a drained pipe, shared by Linux and macOS in one intermediate source set; it replaced a fork whose child could stop before execvp while another thread was collecting garbage), the Windows CreateProcessW runner with batch-file escaping, and the JVM child wrapper account for the 230 added lines, which fixed a hang in which a grandchild holding the output pipe blocked the run forever. It dropped to 545 when the host class that wraps the native runners, the environment parsing, the wait-status decoding, the PATH search and the chunked file read moved into common code. cli holds the per-target file, binary file and stdout writers and the installer and server actuals: the registry and process code for Windows, the profile edits on Unix, the sockets of serve for POSIX and Winsock, and the OpenSSL loader; its share is 72.1%. The listener setup, the send loop, PEM decoding, address text and the cleanup command line are common. The total is 91.1% and the floor is 80%. bench keeps its platform lines in JVM-only code: YAML reading, scenario preparation, result ingest and an exact binomial test with BigInteger.

JVM line coverage comes from JaCoCo. No coverage tool exists for Kotlin/Native or Wasm in Kotlin 2.4.20, so no coverage figure exists for those targets. The same common tests run on them.

module covered missed lines
bench 1,714 75 95.8%
cli 2,302 100 95.8%
core 9,102 384 96.0%
host 302 5 98.4%
scan 3,080 39 98.7%
studio 2,022 74 96.5%
tools 1,203 147 89.1%
total 20,448 845 96.0%

build/coverage/summary.txt was written by coverageSummary on 2026-10-13 and verifyCoverage passed in the same run. The total, 96.0%, sits one point above the 95% floor, and tools is below it alone (89.1%) because the floor applies to the total.

Targets and what ran:

target executable tests with a recorded run
JVM jar; ./gradlew :cli:jvmRun yes, Gradle on a Mac
macOS arm64 drift.kexe, 9,866,776 bytes (release) yes, Gradle on a Mac
Wasm on Node none yes, Gradle on a Mac
Linux x64 drift.kexe, 10,411,416 bytes, cross-linked on the Mac test binaries run in a Debian container on a Linux host
Linux arm64 drift.kexe, 9,009,176 bytes, cross-linked on the Mac test binaries run in an arm64 Debian container; a build on an arm64 Linux host was not run
Windows x64 (mingw) drift.exe, 10,120,704 bytes, cross-linked on the Mac tests on the windows-2025 runner in build.yml; the quoted commands under Wine
Android, iOS simulator APK, simulator app (only with -Pdrift.mobile=true) emulator and simulator runs; see Platform and CPU Findings

Workflows. .github/workflows holds four files, and none has a run recorded in this report. actionlint exits 0 on all four.

KDoc site. ./gradlew :dokkaGenerate aggregates the KDoc of core, host, scan, tools, bench, cli and studio into one Dokka 2.2.0 site, build/dokka/html. It is reference documentation of the engine for people reading the source. Drift ships as an application, the modules are not published artifacts, and the site is published only as engine/ of the documentation site, by docs.yml. The copy built on 2026-10-13 has 3,827 HTML pages, and checkSiteLinks reports 132 relative links inside engine/ that point at pages Dokka does not write (it does not count them as failures, and the causes were not examined). An earlier Dokka failure on the cli module (two source sets sharing the POSIX source root, a known Dokka limitation, issue 3701) is worked around in build-core/src/main/kotlin/drift.docs.gradle.kts by dropping those roots from the Linux source set only.

Fixtures and assets. fixtures/drangler/README.md documents the sanitizer that produced the real CI data: the layout of the logs, run summaries, diffs and capsules, and a table of what each removal rule deletes with its count. fixtures/atlas/README.md says how each Atlas column was recorded, and fixtures/atlas-extra holds three measured columns that are left out of the dataset with the reason for each. assets/ holds the Drift icon as a 512 by 512 PNG, a 128 by 128 PNG and an ICO; the Studio packaging reads its own icon files under studio/src/jvm/packaging.


The Capsule and the Probe Catalog

A capsule has a schema number, a label, a list of attributes and a list of probe results.

An attribute is a path, a value, a source and a stability. Stability is static, volatile or noisy; a diff reports volatile and noisy changes separately and never ranks them. A capsule excerpt from a real CI run (fixtures/drangler/capsules/36995781138.json):

head -c 330 fixtures/drangler/capsules/36995781138.json
{"attributes":[{"path":"ci.provisioner.build-date","source":"log","stability":"static","value":"2026-09-01T19:56:44Z"},{"path":"ci.provisioner.name","source":"log","stability":"static","value":"Hosted Compute Agent"},{"path":"ci.provisioner.version","source":"log","stability":"static","value":"20260901.588"},{"path":"ci.runner.i

A probe result is an id, a status (ok or unavailable), the transcript the probe printed, and the SHA-256 of that transcript. Two capsules differ on a probe when the transcript hashes differ.

Capture.run records, without options: drift.platform, os.name, os.arch, kotlin.version (the Kotlin version the build was compiled with), kotlin.target, runtime.platform, every environment variable as env.<name> (PWD, OLDPWD, SHLVL, _, RANDOM, SECONDS and LINENO are volatile), kernel.release and cpu.count from uname and getconf (or ver and NUMBER_OF_PROCESSORS on Windows), os.release.ID, VERSION_ID and PRETTY_NAME from /etc/os-release, and the cgroup CPU and memory limits when the files exist. --tools adds the scanner attributes, --probes runs the catalog and --project reads the project files of the working directory (see Project and Source Facts). Four system facts are always captured when the host can read them: cgroup.version, tz.system-zone, runtime.jvm.options (the JAVA_TOOL_OPTIONS and JDK_JAVA_OPTIONS values) and java.net.preferIPv6Addresses. On Linux a capture also records the soft and hard resource limits from /proc/self/limits as limits.<name>, written soft:hard (Dev Solves Scored describes why). A capsule records drift.platform and no Drift version, so no recorded capsule, hash or fixture changes when the version does. On Windows the environment is read from the process's Win32 environment block with GetEnvironmentStringsW (host/src/mingw/main/.../SystemHost.mingw.kt, parsed by EnvBlock.kt), not from a cmd /c set child process, so it does not depend on a shell being found (see Platform and CPU Findings).

On the macOS development machine used for this report:

command attributes of which volatile probes
drift capture 90 (81 are env.*) 4 0
drift capture --tools 117 4 0
drift capture --probes --tools 117 4 71, all ok

The probe catalog has 71 probes, each a Kotlin function that prints labelled lines. Thirty-three are general: 7 numeric, 8 text, 3 collection, 2 time, 9 process and 4 resource probes (cgroup limits, locale variables, a missing file). They map to dimensions through Dimension.ofProbe. Thirty-eight are the Kotlin probes of the Atlas, described below.

Capture masks secrets and removes identifiers by default. Redactor replaces the value of any env.* whose name contains KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, AUTH or COOKIE, and any value matching a GitHub, GitLab or npm token, AWS access key id, Slack token, sk- key, JWT, PEM private key header, bearer token or user:password@ URL. It also masks IPv4 and IPv6 addresses in environment values (except version-shaped names such as VERSION and v-prefixed values), session identifiers (XDG_SESSION_ID, TMUX, WINDOWID, INVOCATION_ID, JOURNAL_STREAM, guid=) and rewrites /run/user/<n> to /run/user/user. Anonymizer then rewrites the account name (USER, LOGNAME, USERNAME and the last component of HOME) to user, the host name (HOSTNAME, COMPUTERNAME) to host and the exact HOME directory to ~, and turns any other /Users/<name>, /home/<name> or C:\Users\<name> path into .../user. Names are replaced as whole words and only when they have three or more characters. An attribute that changed gets (anonymized) appended to its source, which diff, rank and reproduce never read. --keep-identifiers skips this step and prints warning: the capsule keeps your account, host name and home path on standard error; it is the escape hatch for a capsule that stays on the machine.

On the capture above, 13 of the 81 env.* attributes were masked. With --keep-identifiers, 22 env.* attributes held the account name and 20 held the home directory path. By default no attribute of any family holds either, and 22 attributes carry the marker. Three limits remain. The identities come from the env.* of the capturing host, so a host name that is not in the environment is not known to the step (HOSTNAME is unset in zsh). Probe transcripts and labels are not rewritten; the transcripts of this Mac hold no occurrence. The redactor has no rule for emails or account ids. reproduce uses the same Anonymizer with its own allowlist (see Reproduce).

Exact bytes. Standard output carries the capsule, a ranking's canonical JSON and an Atlas transcript exactly as encoded. An earlier build sent them through Clikt's echo, which rewrote U+0085 and U+2028 to a line feed and expanded each TAB to spaces. drift capture --probes on the macOS release executable then printed raw line breaks inside the JSON string of kotlin.string.split-replace-lines, and a strict parser rejected the output at column 35,430. That was a bug, and capture, diagnose, atlas show, atlas compare and atlas record without --out now write UTF-8 bytes through one expect fun per target (System.out.write on the JVM, fwrite on native, process.stdout.write on Node). The text of case, next, reproduce, solve, ingest, pack and report still goes through echo. DriftCommandTest.captureBytesSurviveEveryLineSeparatorAndALongLine pins U+0085, U+2028, U+2029, CRLF, TAB, NUL, U+FFFD and a 1 MiB line on all three targets. With the macOS release executable, drift capture --probes --tools parses with a strict JSON parser and its output holds one raw U+0085 and one raw U+2028.


Scanners and the Kit

A scanner runs one command and parses its first matching line with plain character scanning. Scanners.all lists 23.

tool command attributes under tool.<name>.
java java -version implementation, version
cc gcc --version family (gcc or clang), version, vendor (apple or llvm, clang only)
go go version version, os, arch
php php -v version, sapi, thread-safety
node node --version version
npm npm --version version
python python3 --version version
rust rustc --version version
git git --version version
coreutils ls --version flavor (gnu, busybox, other), version
libc ldd --version family (glibc, musl), version
visualstudio vswhere -latest -property installationVersion version
python-managed (group python) python3 -c checking for EXTERNALLY-MANAGED externally-managed
python-gil (group python) python3 -c reading the GIL state gil-disabled
openssl openssl version version, family (a LibreSSL gives libressl and libressl-version)
openssl-providers (namespace runtime.openssl.) openssl list -providers legacy-provider (true or false, OpenSSL 3 only)
ssh ssh -V version, release
composer composer --version version
sudo sudo --version version, release, flavor (ws or rs)
ruby ruby --version version
named named -v version
iptables iptables --version version, backend (nft or legacy, only when the line names it)
curl curl --version version

A missing tool (exit 127) writes nothing. Output that matches no parser writes tool.<name>.version = unparsed and a raw attribute holding the first line cut to 120 characters and redacted. The grouped scanner never writes that fallback, so a failed marker check cannot overwrite tool.python.version.

The kit is the same scanner list as a script. Kit.sh() and Kit.ps1() emit a script that runs every scanner command and wraps each output in @@ <tool> exit=<code> markers; Kit.parse and Scanners.fromTranscripts turn the captured text into the same attributes. An environment without the drift binary, such as a minimal container, can run the script and have the result parsed elsewhere. fixtures/transcripts holds 77 recorded outputs across 23 tool folders, captured in Docker on an arm64 Mac (tools/capture-transcripts.sh has the commands for the first set; the macOS transcripts of the later scanners were taken by hand on the Mac). The two Visual Studio files are synthetic, since vswhere cannot run in Docker; everything else is real tool output. A rule that reads a scanner's attribute for an absent tool sees no value and does not match, except for rules that name present: 'false' with an explicit value on the other side.


Project and Source Facts

drift capture --project, drift run --project and drift contract verify --capture (for assertions under project.) read the project files of the working directory and add project.<ecosystem>.<name> attributes. The prefix maps to the build dimension. The ecosystems are Composer, npm (with pnpm, Yarn classic, Yarn Berry and Bun lockfiles), Gradle (version catalog, settings, wrapper, build scripts and the daemon JVM properties), Maven, Python (uv, Poetry and requirements files) and Go. A value is a resolved version string or a constraint. The scanner never runs a tool and never uses the network; a file that is missing or does not parse adds nothing, and on the Wasm build, which has no file access, nothing is added. At most 200 package keys per ecosystem are kept: packages that some rule names come first, then direct dependencies, then the rest by name, and project.<ecosystem>.omitted records how many were left out. The cap ordering matters because a Drupal capture holds hundreds of packages and the rule for Drupal 11 reads drupal/core. fixtures/projects holds 13 project trees (Composer, five npm lockfile forms, Gradle in Kotlin and Groovy, Maven, three Python forms and Go) that the scanner tests read.

The same switch turns on a source scan. It lists the source files with find through the host and matches imports and a fixed table of call sites by regular expression. The keys are project.<ecosystem>.imports.<name> (Python, Java and Kotlin, PHP and JavaScript; each dotted prefix of an import is a key) and project.<ecosystem>.calls.<name> for the call shapes that some rule names, plus project.gradle.calls.* for build-script calls and project.npm.lockfile-version. The scan reads at most 500 files per ecosystem in sorted order, 200,000 characters per file and 16,000,000 characters in all, and skips node_modules, vendor, build, .gradle, virtual environments, target, dist and .git. The rules that read these keys describe code, so they are the only ones that can say that a project uses the thing a version removed.

The limits are the limits of text matching. An import inside a comment or a string counts. A Gradle version held in a Kotlin variable is read only for libs.versions.* and kotlin_version, and a plugin key means that the plugin is declared, not that it is applied. Windows and Wasm give no source facts, since neither has a listing command here. Running the scan on the Drift tree itself found a false positive (a baseName assignment inside a Kotlin/Native binaries.executable block was read as an archive task); it was fixed and ProjectScannerTest pins it. That test class has 33 tests on each Gradle target, and one of them reverses the order of the file listing and requires equal attributes. Real runs covered the Gradle, Kotlin and fixture trees; the Python, PHP and JavaScript signals were checked on fixture text only.


Diff and Dimensions

CapsuleDiff.diff(green, red) compares two capsules and returns three lists: changes (static attributes that were added, removed or changed), ignored (volatile and noisy attributes, with the less stable side deciding), and one entry per probe id with status same, different or unavailable.

Two details matter for correctness.

Each changed path maps to one of 14 dimensions by longest matching prefix: os, kernel, cpu, cpu-limit, memory, runtime, compiler, build, userland, env, locale, limits, network and browser. For example tool.node. is runtime, tool.npm. and deps. and ci. are build, env.TZ is locale, and env.HTTPS_PROXY is network. A path no prefix claims has no dimension; it is listed under changes and never ranked. ADVANCED_USAGE.md holds the full prefix table. Probe ids map the same way, so a probe that differs in a dimension can support a candidate in that dimension.


Rules and Provenance

A rule records a documented difference between two environments that can break software. The catalog has 429 rules in rules/**/*.yml, one folder per subject (51 folders), each with a mechanism, one or more symptoms, a matcher, fixtures and cited sources. 376 are active. The other 53 are pending-scanner: they read an attribute that no scanner emits yet (a database server version, the TLS versions a peer offers, a browser name, a source-level key), they are compiled and checked like the rest, and they never match a capture that lacks the attribute. RuleCatalogTest prints the keys they are waiting for.

A rule file has schema: 1, an id equal to the file name, a title, applies (dimension ids), difference, symptoms, mechanism (one sentence), detect, severity (low, medium, high), provenance, fixtures and status (active or pending-scanner). A symptom has a basis: verified when the cited source states the symptom, inferred when it only states the mechanism, range when it lists a compatible or tested range and states no failure. The matcher is a small language over a passing side a and a failing side b: eq, ne, in, has, starts, ver (version ranges), present, changed (with a trailing *), and all, any, not, to a depth of 8. This is a rule from the catalog:

id: node-localhost-ipv6-first
applies:
  - runtime
  - network
detect:
  all:
    - b: tool.node.version
      ver: '>=17.0.0,<18.18.0'
    - a: tool.node.version
      ver:
        - '<17.0.0'
        - '>=18.18.0'
severity: medium

Rule files are YAML because people maintain them by hand. snakeyaml-engine 3.2 parses them at build time under the failsafe schema, so every scalar is a string (1.20, no, 0o17 and ~ stay strings), and embedRules writes the JSON that core reads. Common code never parses YAML. A stray rules/**/*.json fails the build, and the embedded JSON was byte-identical to what the earlier JSON catalog produced when the format changed (32 of 32 rules at that time).

The checker, Constraints.check, runs in the core test suite, so on every build. It validates each rule (id shape, non-empty fields, one-sentence mechanism, dimension ids, provenance, fixtures), requires unique ids, requires every positive fixture to match and every negative fixture not to match, and lists every pending-scanner rule (53 today). A scan-side test (RuleCatalogTest) checks that every attribute path an active rule reads is one that capture or a scanner emits, and that a pending rule reads at least one that none emits.

property count
rules, active / pending-scanner 429: 376 / 53
severity high / medium / low 134 / 240 / 55
fixtures, positive / negative 469 / 1024
cited sources 497 entries, 315 distinct locations
source kinds: measurement / vendor-doc / release-note / spec / report 19 / 207 / 198 / 72 / 1
symptoms, verified / inferred / range 133 / 268 / 52
rules with at least one inferred symptom 265

A matched rule gives a candidate the known tier and a weight by severity, with its mechanism text attached. A rule whose matcher touches no changed attribute is listed as unattached and never dropped.

The rule sources are cited and not reproduced. The fixtures are synthetic pairs written to exercise the matcher. They show that the rule fires on the stated versions and stays quiet on others; they do not show that the failure occurs. The 497 provenance entries name 315 distinct locations: 311 web URLs and 4 fixture: references, which the verifier skips. tools includes a verifier that fetches each distinct web URL and records the HTTP status. Its unit tests use a fake fetcher. A run against the network covered 3 URLs when it was written (all 3 returned HTTP 200). A run over all 311 web URLs is not recorded. An HTTP 200 shows the page exists, not that the quoted section still does. Most symptoms are inferred (268 of 453): the cited page states the mechanism, and the symptom is what that mechanism implies.

Catalog by subject.

folder rules active pending folder rules active pending
angular 4 4 0 network 4 3 1
astro 3 3 0 nextjs 2 2 0
browser 4 3 1 node 18 17 1
clang 5 4 1 npm-native 3 3 0
composer 4 3 1 numpy 6 6 0
dart 1 1 0 nuxt 3 3 0
database 13 0 13 os-image 25 20 5
django 6 6 0 pandas 4 4 0
do-sqlite 6 6 0 php 35 35 0
docker 7 0 7 prisma 2 2 0
drupal 21 13 8 pydantic 4 4 0
elixir 1 1 0 python 30 28 2
expo-react-native 3 3 0 pytorch 3 3 0
fastapi 3 3 0 r 1 1 0
filesystem 4 1 3 react-router 2 2 0
flask 2 2 0 runner 11 11 0
flutter 3 3 0 spring 3 3 0
gcc 8 7 1 symfony 17 17 0
git 2 2 0 teavm 2 2 0
go 3 1 2 tls 4 2 2
gradle 13 13 0 typo3 3 3 0
jdk 35 33 2 tz 5 5 0
julia 1 1 0 vite 2 2 0
kotlin 72 72 0 wordpress 3 3 0
laravel 4 4 0 workerd 3 3 0
libc 6 3 3
total 429 376 53

Three shapes of rule exist. Version-boundary rules read only version keys (ver on tool.php.version and the like) and say that a documented change lies between two versions. Fact rules also read a project or system fact, such as project.composer.require.<name>, runtime.openssl.legacy-provider or tool.sudo.flavor, and match only when the fact is present. Call-site rules (52 of them, the rules that read project.<eco>.calls.*: gradle 5, java 11, js 6, php 12, python 18, counted with grep -rlE 'project\.[a-z0-9_-]+\.calls\.' rules | wc -l) read a call site or import on the failing side. 35 of them are added-API rules (jdk 8, node 4, php 12, python 11): the passing side runs the newer version and the failing side an older one, so they describe code that a newer runtime accepts and an older one rejects, and they fire for a CI runner older than the developer machine and not for an upgrade (see Upgrade Check). The other 17 describe removed or changed behavior and fire in the upgrade direction. The 15 older rules with a single negative fixture were left as written; the compiler allows one.


History and Vacuous Green

A diff of two capsules compares two points. A run history adds which runs passed. History orders the runs of one workflow and job (as given, or by numeric run id) and classifies it over its decisive runs, those that passed or failed; cancelled and unknown runs are skipped without breaking adjacency.

state meaning
EMPTY, SINGLE no decisive run, or one
ALL_GREEN, NEVER_GREEN no red run, or no green run
REGRESSION one flip, green first
RECOVERED one flip, red first
FLAKY two or more flips

The transition is the last green before the first red. In a flaky history it is the first transition only, and state tells a consumer not to trust it.

GreenToRed combines the capsule diff with step changes. Its kind is NO_CAPSULE, UNEXPLAINED, SINGLE or BUNDLE. A bundle counts changed static attributes, differing probes and added or removed steps, and a bundle is never reported as one cause. Volatile differences go to ignored.

A passing run is only evidence if it executed the step that failed. VacuousGreen.assess returns EXECUTED when the matching step succeeded or failed in that run, VACUOUS when the step list lacks the failing step or the step was skipped, UNKNOWN when step data is missing, and NOT_PASS for a run that did not pass. A vacuous or step-less green is excluded from the spectrum evidence and listed under excluded, so it never changes a ranking.

Run is the record a history is made of: id, outcome, ordered steps with conclusion and duration, extracted symptoms, and an optional capsule. SymptomExtractor reads GitHub Actions logs and returns symptoms of kind SIGNATURE, EXIT, SIGNAL, STEP, DURATION and TRANSPORT. A signature is the error message after Signature.normalize: secrets masked, temporary paths, timestamps, UUIDs, hex runs, durations, counters and ports replaced with placeholders, whitespace collapsed, cut at 200 characters. Normalization is character scanning over ASCII classes, with no regular expressions, so it is identical on every target. The extractor never throws; the tests feed it truncated logs, a 50-million-character line and lone surrogates.

History mode runs through the library and its tests. drift diagnose takes two capsules.


Ranking and Bundles

Ranker.rank turns a diff into candidates. There is one candidate per changed attribute that has a dimension. Its score is a sum of log-odds in fixed point: each evidence item is a likelihood ratio in micro-units and the combiner adds ln(ratio).

evidence ratio note
dimension prior 1.5x to 3x runtime and compiler 3x; build and userland 2.5x; os, kernel, cpu-limit and memory 2x; the other six 1.5x
a probe that differs reads the candidate's attribute 6x the first such probe only; further probes are listed at weight 0
a probe in the same dimension differs but does not read the attribute 1x listed, no log-odds; no ratio beat 1 on the dev data
rule match 6x, 12x, 24x by severity low, medium, high
spectrum (Ochiai) up to 4x 1 + 3 * score * coverage, needs at least 2 included passes and 1 failure

The spectrum score is Ochiai, ef / sqrt((ef + nf) * (ef + ep)), computed in integers as isqrt(ef^2 * 10^12 / d), where ef is the number of failing runs that contain the changed value, nf the failing runs that do not, and ep the passing runs that do. Coverage discounts sparse histories: it is the lowest share of the history's attribute universe present in any included run.

A candidate's tier is the strongest evidence it has.

tier requires
difference the attribute changed
correlated spectrum score of at least 0.5, or a probe in the candidate's dimension that differs but does not read its attribute
plausible a probe that differs and reads the candidate's attribute, or a matched rule with any inferred symptom
known a matched rule whose symptoms are all verified

There is no stronger tier. A candidate with no probe or rule evidence never exceeds correlated; a property test over 60 random histories checks it. The policy is written once, in the KDoc of Ranker.

Probe links. ProbeLinks maps each of the 33 general probes to the attribute path prefixes its output depends on: the numeric, text, collection and duration probes run inside the Drift process and read runtime.platform and tool.java.; the shell probes read tool.coreutils., os.name and os.release.; process.timezone reads env.TZ; resources.locale-env reads env.LANG, env.LC_ and env.TZ; the two cgroup probes read cgroup.cpu. and cgroup.memory.; two probes read nothing. The prefix lists are a reading of the probe sources, not a measurement, and tool.java. for the in-process probes assumes the java on PATH is the JVM that runs Drift. A probe in the candidate's dimension that does not read its attribute no longer counts for the candidate, which removes a rule that supported every changed attribute of a dimension whether or not the probe could have seen it. On the 45 dev scenarios and 32 rules of that time the change moved nothing: every ranking, tier and rate was identical before and after (see Calibration on the Dev Split, which says that this was not repeated on the current catalog). The benefit is by construction, tested with a property test over random capsule pairs, and is not measured.

Bundles. Candidates with the same tier, the same probe and rule evidence and the same Ochiai counts cannot be told apart by the data. They share a bundle id, and no member of a bundle is reported as the cause on its own. Order inside a bundle comes from the dimension prior alone and means nothing.

Probability. The ranking carries a probability only when every candidate rests on attribute evidence alone. It is the candidate's share of the softmax over all scores plus a weight for "none of the listed candidates is the cause" (1.0, not fit), read through the eight-step isotonic map in Weights.map, which gives the rate at which candidates with that share were the cause. The map is fit on all 115 probed dev scenarios and is not held out. The probabilities of a ranking need not add up to one. Where any candidate has probe, rule or spectrum evidence, every probability is null.

Pair mode, the real drangler failure, a passing run against a failing one:

drift diagnose fixtures/drangler/capsules/36702683742.json fixtures/drangler/capsules/36995781138.json --detail summary --case case
wrote 11 files to case
9 changes differ with the same support, and tool.node.version ranks first only by hand-set weights,
so the data does not name one cause.

At --detail full the nine candidates are all tier difference: tool.node.version scores 1.098612 (runtime, 3x) and the other eight score 0.916291 (build or userland, 2.5x). All nine read probability 0.104478, because their shares (0.125 and 0.104) fall in one step of the map, so the readout does not separate the first candidate from the rest. tool.yarn.version is the ninth: it has no scanner dimension of its own and reaches userland through the general tool. prefix, so it was unranked before that mapping existed. The nine probabilities sum to 0.940, a claim of 94% that one of nine changes is the cause. Of the 33 dev rankings that print a probability, 32 hold five or fewer candidates and one holds 15, so the real case is outside the data the map was fit on (see Evaluation and Limits).

History mode over the same case, from RankHistoryTest.theDranglerTransitionIsOneBundleNoCandidateIsTheCause (5 passing runs, 3 failing runs, job Docker E2E): the transition is a BUNDLE with three added steps (Set up Node, Post Set up Node, Dump Host State on Failure); all 9 candidates are tier correlated, basis inferred, in one bundle bundle-1; every candidate has spectrum counts ef 3, nf 0, ep 0, score 1.000000 and coverage 0.862068 (25 of 29 attributes present in each passing run). The data separates nothing: every changed value is present in all three failing runs and in none of the five passing ones. The catalog fires no rule on this pair. The ranking's order within the bundle is the dimension prior.


The Experiment Planner

The planner takes a ranking and proposes experiments. Each experiment sets some attributes of the passing environment to the failing side's values and compares failures against an unchanged control.

Hypotheses and prior. Each candidate is a hypothesis. Candidates that are one change seen twice can be coupled (ci.provisioner.build-date with ci.provisioner.version), and an attribute that comes from a step can couple to that step. The prior is exp(score - max) over the ranking scores, normalized by largest remainder, with a fixed 25% reserved for unknown. In pair mode on the drangler capsules there are 8 hypotheses plus unknown, and the prior entropy is 3.059 bits.

Controllability. Controls.standard is a hand-set table of 16 prefixes saying where an attribute can be set. deps., tool., runtime., env. and browser. can be set locally and in CI; limits., cgroup., memory., network. and os.release. locally; step: only in CI; ci., os., kernel. and cpu. in neither, since the runner provider or the host owns them. An experiment is automatic-ci, automatic-local or manual.

Experiment pool. For each controllable hypothesis as the single culprit, delta debugging (Ddmin) runs over the controllable set with the test "fails exactly when the culprit is in the subset". The union of the tested subsets is the pool: the full set, halves, quarters and singletons. Manual experiments for the uncontrollable hypotheses are added with instructions.

Outcome model. An experiment is reproduced when the treatment arm fails significantly more often than the control. The probability of that outcome is the achieved power of the trial plan when the culprit is in the flipped set, and alpha (0.05) when it is not; both are clamped to a 1% floor. The unknown mass behaves like a non-culprit, so the planner never claims to separate "none of these" from something it cannot flip.

Exact test and trials. The significance test is a one-sided Fisher exact test computed in Long arithmetic (Fisher.oneSided, total trials capped at 60 so C(60,30) * 30 fits). The failure rate is Laplace-smoothed from the failures and runs observed on the red side. The planner picks the smallest trials-per-arm count, at least the Fisher minimum, whose chance of reaching alpha against a control that never fails is at least 0.8, and refuses an experiment that no count up to 30 can reach (status STUCK, reason budget).

Information gain and cost. Expected information gain (EIG) is one-step: prior entropy minus the expected posterior entropy over the two outcomes, in micro-bits (log2 is exact on powers of two, and every entropy term rounds to the nearest micro-bit). Cost is 1.0 per waiting minute, 2.0 per runner minute and 0.5 per trial, plus 5 queue minutes in CI and 120 minutes for a manual experiment. Experiments rank by EIG per cost point, then EIG, then cost, then id.

Next experiment for the drangler pair (defaults --failures 1 --runs 1 --trial-minutes 5, written into plan.json):

drift next case --detail detail
Next experiment (a proposal, nothing has been run):
  e11: drangler-36702683742 with deps.@napi-rs/keyring.version, deps.prettier-plugin-sh.version,
    deps.wrangler.version, tool.node.version, tool.npm.version, tool.yarn.version set to
    drangler-36995781138 values
    automatic-ci; 10 min, 70 runner min, 14 trials
    why: highest information gain per cost among the experiments that can run
    expected information gain 0.499 bits from the hand-set prior (a prediction, not a measurement)
    ...
    outcomes: reproduced 0.490; not-reproduced 0.510

The plan has 11 automatic CI experiments and 2 manual ones. All 11 automatic experiments cost the same (157 points: 10 minutes, 70 runner minutes, 14 trials at 7 per arm), so EIG alone orders them. Seven trials per arm with a clean control and a threshold of 4 failures: the exact one-sided p for 4 of 7 against 0 of 7 is 35/1001 = 0.0350, and for 3 of 7 it is 0.0962. The power at the smoothed rate of 2/3 is 1808/2187 = 0.8267. Both figures reproduce by hand from the formulas above, and so does the EIG: recomputing it from the prior masses, the power and alpha gives 0.49854 bits and P(reproduced) 0.490447 (the plan stores 0.498530 and 0.490, after rounding each entropy term to a micro-bit).

The first experiment flips all six controllable hypotheses together. Part of the mass cannot be flipped at all (unknown 25%, runner image and provisioner about 18%), so asking whether the controllable set reproduces the failure at all gains more than splitting it: 0.499 bits against 0.455 for the best split of three (e10) and 0.350 for a split of two (e7). Over 8 equally likely controllable members, a half split goes first (PlanTest.theFirstExperimentOnEightEquallyLikelyMembersSplitsTheBundleInHalf).

The plan also reports what no experiment separates: here {ci.provisioner.version, ci.runner.image.version, unknown}, because the runner provider owns the image and the provisioner. If the cause is there, the best an automatic experiment can reach is "none of the controllable ones", with the mass moving to that set. Given the red run's steps, the planner drops a step that ran after the failing step: Dump Host State on Failure was added in the drangler window, follows the failing step, and cannot be a cause. The planner proposes experiments and predicted information. It records no result.

The cost weights, queue and manual minutes, the 25% unknown prior, alpha and power are guesses with a written reason each in PlanWeights, and none is calibrated.


Case Directories

drift diagnose --case DIR writes a case: canonical JSON files, LF line endings, no timestamps, with a manifest and a hash chain over the observations.

case.json                   schema, name, frame (sha or environment), observation count, chain head
manifest.json               every other file with its sha256, sorted
environments/passing.json   the two capsules
environments/failing.json
observations/0001.json      hash-chained records
observations/0002.json
hypotheses/ranking.json     the ranking: tiers, bundles, weights note, unattached, excluded runs
hypotheses/prior.json       hypotheses and the prior belief
experiments/plan.json       the full plan: gains, costs, per-outcome posteriors, instructions
results/index.json          {"results":[]}
eliminations/excluded.json  candidates and runs left out, with reasons

An observation record holds its content plus seq, prev and hash, where hash = sha256(prev + "\n" + canonical(record without prev and hash)) and the first prev is 64 zeros. case.json stores the head. The record in observations/0002.json for the failing capsule shows the shape:

{"capsule":"40380c70...","file":"environments/failing.json","hash":"51c3d18e...","kind":"capsule","label":"drangler-36995781138","prev":"e495b6a8...","role":"failing","seq":2}

The reader never throws. A case reads back as Loaded or Failed with a typed problem: MissingFile, MalformedFile (truncated or garbage), UnsupportedSchema (any schema other than 1), and unsafe manifest paths. CaseFile.check() compares every file with the manifest and walks the chain. After replacing drangler-36995781138 with drangler-X in observations/0002.json:

drift case show case
case: drangler-36702683742 to drangler-36995781138
frame: sha, passing drangler-36702683742, failing drangler-36995781138
observations: 2, chain head 51c3d18ef57180a4ee97f29a1afd87d1584bc5f95e8239e0664786ab85621c3b
integrity: 2 problem(s)
  observations/0002.json was changed: sha256 is 99750ce6..., the manifest has 06ad04b2...
  observation chain broken at entry 1 (observations/0002.json): content does not match its hash

The command exits 1, and drift next refuses the case. Without the edit it prints integrity: ok, 10 files match the manifest and the chain holds and a line rule sha256: 8bf205db... with the hash of the preregistered decision rule, the value that ingest expects in each result file as ruleSha256; drift next --detail detail prints the same hash after the experiments. In a diagnose case the chain covers observations/ only and results/ is an empty index; solve fills results/ with records that continue the chain (see The Lab).

Rendering has three levels, summary, detail and full, in plain text wrapped at 100 columns and ASCII only. A live result and a case read back go through the same renderer. The text says "a proposal, nothing has been run" for experiments, "a prediction, not a measurement" for gains, and "none is the cause on its own" for a bundle. The 11 files the macOS release executable wrote for the example above have the sha256 values that CaseCommandTest.CASE_SHAS pins, and that test asserts them on the JVM, macOS Native and Wasm.


Reproduce

drift reproduce <capsule> --run <command> --out <dir> writes Dockerfile, run.sh and manifest.json. It does not run Docker and does not read the network. The three files are a function of the capsule and the command: two runs produce identical bytes.

drift reproduce fixtures/lab/node-alpine.json --run "npm test" --out repro
wrote 3 files to repro
23 attributes: 10 mirrored, 4 partially mirrored, 9 not mirrored; base image node:22.12.0-alpine3.20
This is not the original environment. Attributes that are not mirrored are listed with their reasons
  at --detail detail.
# drift reproduce: capsule 540e5c22...0531; manifest.json says what is not mirrored
FROM node:22.12.0-alpine3.20
RUN apk add --no-cache tzdata
RUN npm install -g [email protected]
WORKDIR /work

run.sh then builds the image, tagged drift-reproduce:<first 12 hex of the Dockerfile hash>, and runs it with --platform linux/arm64, --user "$(id -u):$(id -g)", --volume "$PWD:/work", HOME=/tmp, --memory 512m, --cpus 1.2, --ulimit nofile=2048:2048 and the mirrored LANG and TZ.

What is mirrored. One base image per container: node:, python:, golang:, php:, rust:, gcc: or eclipse-temurin: from the toolchain attribute (chosen by the first word of --run that names a toolchain), else ubuntu, debian or alpine from the OS attributes, else debian:12-slim. Debian codenames come from os.release. --digests pins tags to digests. cgroup.memory.max becomes --memory, cgroup.cpu.max becomes --cpus, limits.<name> becomes --ulimit, os.arch becomes --platform, and tool.npm.version becomes an npm install -g pin on a Node base.

What is not. The manifest gives one status per attribute (mirrored, partial, not-mirrored) and a reason. For the example above the nine not-mirrored attributes include cpu.count ("a container runs on the Docker host's CPU"), kernel.release, deps.vitest.version (installed from the workspace lockfile), tool.libc.version and tool.git.version (come with the base image), env.HOME (host identity), two redacted variables, and env.NODE_ENV ("not allowlisted"). Never mirrored in any capsule: kernel.*, cpu.*, hw.*, ci.* (the runner provider owns the image and provisioner), deps.*, and anything describing the capturing binary (drift.*, kotlin.*, runtime.*).

Identifiers. By default only LANG, LC_* and TZ are copied from env.*. --env NAME adds one variable, and a name the capsule lacks is refused. --env-all adds all of them and prints a warning, because values may hold identifiers and secrets. Home paths (/Users/<name>, /home/<name>, C:\Users\<name>) and the capsule's own account and host names are replaced by user and host in anything written. A redacted or secret-looking value is never written, and the label passes through Redactor too. The redactor has no rule for emails or account ids, so under --env-all those are copied as written.

Overwriting. --out refuses a directory that already holds a reproduction and names the three files. --force replaces those three files and nothing else, and only when each one was written by drift reproduce (its header line, its manifest keys); a user's own Dockerfile makes it refuse. Nothing is deleted. Every command's --help marks required options and shows defaults.

On the real red drangler capsule with --run "npm test": 30 attributes, 3 mirrored (tool.node.version, tool.npm.version, env.REQUIRE_DOCKER) and 27 not, base image node:24.21.0-bookworm-slim. The 7 ci.* attributes, which are what the data points at, are exactly the ones a container cannot mirror.

Container builds from generated files were run by hand on a Mac and on a Linux host (pinned Node and npm versions, locale, timezone, ulimit, memory.max and cpu.max read back inside the container). No test in the tree builds an image. The tests pin the sha256 of the three files for four fixtures on all three targets.


The Lab

The planner proposes experiments. The Lab runs them. drift solve <green> <red> takes the capsules of a passing and a failing run, the failing command (--run) and a failure test (--fail-when, either exit for a nonzero exit code or a pattern that the output of a failing trial matches), then runs counterfactual arms in Docker on the local machine. --budget trials=N,minutes=M caps the whole run (default trials=300,minutes=45), --pilot sets the trials per baseline (default 8), and --timeout cuts one trial off (default 120 seconds). --exit-status makes the exit code carry the verdict (0 CONFIRMED, 10 CONFIRMED EFFECT, 11 NARROWED, 12 STUCK); without it a finished run exits 0, and 1 and 2 stay errors and usage. reproduce supplies every container, so the arms are evidence about containers built from the capsules and not about the original host. The certificate says so.

Interventions and controllability. An intervention is one typed edit of a run configuration: set or unset an environment variable, set the locale, the timezone, a CPU or memory limit, a ulimit, a toolchain version or an operating system release, declare the platform a Kotlin program runs on, set a flag, write a file, toggle a workflow step or pin a dependency (14 types in Intervention.kt). One that cannot apply returns a typed problem (invalid, not applicable in this environment, no effect, or not mirrored into the container) and never runs. Controllability.classify sorts each candidate into automatic-local, automatic-CI or manual. It starts from the planner's Controls table, so a dimension the planner calls manual is never promoted, then demotes what the reproduce manifest says a container cannot mirror. A member that only travels with a base image (tool.libc., tool.coreutils., tool.git.) is manual, because nothing sets it alone. The loop re-plans without any hypothesis its arms cannot set, and never runs an experiment it knows cannot isolate its target.

The bundle rule. An experiment is two arms, a control and a treatment, each a full run configuration with its own Dockerfile, run.sh and manifest. After the arms run, Drift captures a capsule inside each container and diffs the two. If exactly one non-volatile attribute differs and it is the one the experiment set, the effect is isolated. If several differ, or the one that differs is not the target, the verdict is a bundle: every member is listed, with the ones that were not isolated. A swap of the Node image, for example, also changes glibc, coreutils and the OS release, and the diff shows it. If nothing differs, no effect can be attributed. A bundle is never reported as one cause.

Trial plans and the preregistered rule. After the pilot, TrialSizing takes the failure rate seen in the failing baseline and the planner's exact test to pick the smallest trials-per-arm count that reaches power 0.8 against a control that never fails, inside the remaining budget. If no count up to 30 per arm reaches it, or the budget is too small, the run ends STUCK with reason budget and names the shortfall. Before any trial of an experiment, the loop writes experiments/<id>.json: the arms, the trials per arm, and the decision rule (a one-sided Fisher exact test at alpha 0.05 with the threshold count), with the SHA-256 of the rule. The result file records that hash. A result is SUPPORTED when the treatment arm fails significantly more often than the control, REFUTED when a treatment arm that failed at the observed baseline rate would show as few failures as this one with probability at most alpha (a binomial lower tail), and INCONCLUSIVE otherwise. The belief over hypotheses then moves through the planner's outcome table; every posterior is in the certificate.

The closed loop.

  1. The pilot runs a passing baseline and a failing baseline. No failure in the failing baseline ends the run as STUCK (not-reproduced). A passing baseline that fails as often as the failing one ends it as STUCK, uncontrollable when the planner lists manual candidates and not-reproduced otherwise. The pilot is not evidence; it sizes the arms and stops early.
  2. Each round takes the best executable experiment that was not tried, writes its preregistered spec and runs control and treatment trials alternately. A docker error or a timeout is an infrastructure problem, retried once and then STUCK (executor).
  3. The first SUPPORTED experiment starts the minimal-set search. Delta debugging runs over the flipped hypotheses, and each tested subset is a real preregistered experiment. A subset that is INCONCLUSIVE, or one that the budget stops, is unresolved.
  4. A reverse arm takes the failing capsule and moves the minimal set back to the passing values; the unchanged failing arm must fail more. When the reverse arms cannot be built, a replication of the forward arms takes their place.
  5. The measured arm diff and the bundle rule give the verdict.

The loop assumes a single cause: the first supported experiment ends it.

verdict meaning
CONFIRMED a minimal set of attributes, all set directly, reproduces the failure forward and removes it in reverse, with no unresolved subset
CONFIRMED EFFECT (bundle) the same, but the arms differ in attributes nobody set; every member is named and the unisolated ones are listed
CONFIRMED EFFECT (isolation not measured) the same agreement, but the effect diff or the agreeing diff was predicted from the interventions because an arm had no capsule from its container; the certificate records isolation: predicted
NARROWED at least one experiment was supported or stayed inconclusive, and the conditions of the verdicts above are not met
STUCK no experiment was supported; the reason is not-reproduced, uncontrollable, no-candidates, budget or executor

The mechanism field comes from re-ranking the two captured arms. A rule match or a differing probe on a member records it, and otherwise the certificate says unexplained.

Four rules protect the verdict from the way the arms are run. A treatment capsule that does not show the requested value of an edited attribute voids its experiment: the run is recorded with diff source void, moves no belief, and ends the solve as STUCK with reason executor. The certificate states isolation (measured or predicted) and isolatedAmong, the number of captured attributes the isolation holds over. Trials run in a counterbalanced order: blocks of two indices put each arm first once, a hash bit of a seed picks which arm opens a block, and the seed (from the two capsule hashes and the experiment id) and the order are recorded for verify to re-derive. The pilot runs at least --pilot trials per arm and goes on until the failing baseline fails once or a cap is reached (30, or a quarter of the trial budget); a pilot with no failure reports the rule-of-three bound, "below 3/n of trials at 95%" in whole percent. Each forward experiment is tested at the plan's alpha divided by the family size, the round limit; because the search stops at its first rejection, that is Holm's first threshold for every test, and the certificate's family states the size and a false CONFIRMED bound per search. verify checks each of these from the recorded data. Certificates written before these fields existed still verify: it reads their isolation from the recorded diffs, and a bundle with predicted diffs (the sealed INC-13 archive is one) is not rejected.

Ingest. drift ingest <case> <results> adds result files that someone else produced, for example from a CI workflow variant that drift next described. Every file must name a known experiment, carry that experiment's rule hash and the preregistered trial count, and hold consistent counts; otherwise it is refused with a typed reason, and a batch with one bad file applies nothing. A result narrows the case or rules a hypothesis out. ingest cannot confirm, because confirmation needs the minimal-set and reverse experiments, which are separate preregistered runs. ingest <case> <dir> --template writes one skeleton result file per open experiment, each carrying the rule hash and the trial count, with the failure counts left empty; an unfilled file is refused and an existing file is never overwritten. After an ingest, drift next names the experiments that already have a result and proposes the best of the others. It does not re-plan from the updated beliefs, and its output says so; --exit-status works on ingest the same way as on solve.

The case directory. A solved case holds the files of a diagnose case plus experiments/<id>.json, arms/<id>-control and -treatment (Dockerfile, run.sh, manifest.json and the captured capsule.json), results/NNNN.json and certificate.json. The result records continue the hash chain of the observations, each with every trial's exit code and last line of output. The certificate holds the verdict and its conditions, the posteriors, the counts and exact p of each experiment, the arm diffs, the rules with their hashes, the chain heads, the list of what the containers do not mirror and five fixed sentences of honesty, one of which says that verify does not re-run experiments.

The .driftcase archive. drift pack <case-dir> --out FILE writes a USTAR tar: names sorted, mode 0644, uid and gid 0, modification time 0, empty owner names, no compression. arms/ becomes reproduce/, confirmed/minimal-set.json is added for the two CONFIRMED verdicts, and report.html and a rebuilt manifest.json go in. The reader returns a typed problem for a truncated archive, a bad checksum or magic, an unsafe path (.., absolute, backslash, control or non-ASCII byte), a duplicate name, an entry over 16 MiB, a total over 64 MiB, more than 4096 entries, a non-regular file and text that is not UTF-8. After parsing, the reader writes the entries back and requires the same bytes, so it accepts only what the writer produces. pack refuses a case with no certificate or one that fails its own integrity check, and verify and report refuse a case directory and say to pack it first. System tar reads the result:

tar -tvf fixtures/lab/cases/locale-tz-date-2.driftcase | head -4
-rw-r--r--  0 0      0         212 Dec 31  1969 case.json
-rw-r--r--  0 0      0        5895 Dec 31  1969 certificate.json
-rw-r--r--  0 0      0         436 Dec 31  1969 confirmed/minimal-set.json
-rw-r--r--  0 0      0          27 Dec 31  1969 eliminations/excluded.json

The report. drift report FILE --out report.html renders the page that pack stored. It is static: the archive's copy has no script, link, image or external resource, only ASCII, no timestamp, escaped text, and eight <details> elements, two of them open. For the CONFIRMED fixture it is 7,052 bytes. verify regenerates it from the certificate and compares.

Verify. drift verify FILE runs 14 checks in a fixed order, and a failing check never stops the later ones.

check re-derives
tar the archive is canonical (archives only)
manifest every listed file is present with its SHA-256, and no file is unlisted
observation-chain, results-chain both hash chains, and the certificate's chain heads
capsules each capsule file hashes to what its observation records
counts each arm's failures, passes and errors equal the tally of its recorded trials and the certificate
fisher every exact p, as a fraction, from those counts
rules each preregistered spec and rule hashes to the recorded hash, alpha and threshold match the plan, each arm ran the planned trials
decision each outcome follows from the counts under the rule
posteriors each belief, by Bayes over the recorded outcomes from the prior in the case
ranking, plan the ranking and the plan, from the two capsules (pair cases only)
verdict the verdict follows from the recorded experiments, and its statement quotes the recorded p
report report.html and confirmed/minimal-set.json equal what the certificate generates

The output is one PASS, FAIL or SKIP line per check, the verdict, the sentence experiments are not re-run; the recorded results are taken as given, and verify: ok or verify: failed at <first failure>. The exit code is 1 when any check fails, and --json prints the same as canonical JSON. SKIP appears only for report on a plain directory, ranking and plan on history cases, and posteriors when an ingested result is present.

drift verify fixtures/lab/cases/runtime-node-api-1.driftcase | tail -4 | cut -c1-200
PASS report: report.html and the confirmed record are what the certificate generates
verdict: CONFIRMED EFFECT (bundle): the arms differ in 2 attributes, so the effect is the bundle's and not one member's: env.NODE_VERSION, tool.node.version. Setting tool.node.version moved the failur
experiments are not re-run; the recorded results are taken as given
verify: ok

verify shows that an archive agrees with itself. It does not show that the experiments happened. Someone who rewrites every file, both chains, the hashes, the certificate and the manifest consistently produces an archive that verifies. Preregistration is bound by hash, not by time. The rules that the verdict check applies are the closed loop's own conditions read back from the record, so a verdict that the recorded experiments cannot produce fails, and one they can produce passes.

Evidence. Flipping one trial's recorded status in a fixture breaks it at the first check that sees it. The one-line edit replaces the first "status":"fail" of the archive, which sits in results/0001.json, with "status":"pass" (same length, so the tar stays canonical):

python3 -c "
d = open('fixtures/lab/cases/locale-tz-date-2.driftcase', 'rb').read()
open('bt.driftcase', 'wb').write(d.replace(b'\"status\":\"fail\"', b'\"status\":\"pass\"', 1))"
drift verify bt.driftcase 2>/dev/null | grep '^FAIL\|^verify' | cut -c1-110
FAIL manifest: results/0001.json was changed; expected 5f6997c647d89b96cd77b038f0ce6679a32ae6f68a4cdeceaae5320
FAIL results-chain: results chain broken at entry 0 (results/0001.json): content does not match its hash; expe
FAIL counts: experiment p0 treatment failures; expected 7, recorded 8
FAIL fisher: experiment p0 exact one-sided p; expected 8/11440 (699), recorded 1/12870 (77)
verify: failed at manifest: results/0001.json was changed; expected 5f6997c647d89b96cd77b038f0ce6679a32ae6f68a

The command exits 1, with identical standard output from the JVM and the macOS executable. A second edit, made with a script that is not in the repository, flipped one trial in results/0002.json and also resealed the results chain, the certificate's chain head and the manifest. It still failed at counts and fisher (and at verdict and report), because the tally of the recorded trials no longer equals the recorded count; again the JVM and native outputs were byte-identical.

The four archives in fixtures/lab/cases are real certificates from the dev evaluation below, one per verdict class (CONFIRMED, CONFIRMED EFFECT (bundle), NARROWED and STUCK). For each, the archive that pack writes from the extracted directory is byte-identical to the committed file, report writes the same report.html, and verify prints the same output with exit 0, on the JVM and on the macOS release executable. After the probability readout changed, the four archives were repacked with a ranking re-derived by the current ranker (only hypotheses/ranking.json and the manifest differ); the repacked files were verified on the JVM and the Linux lane, and the macOS executable has not been rerun on them.

case verdict bytes archive sha256 (first 16) report.html sha256 (first 16)
locale-tz-date-2 CONFIRMED 50,176 50fb8ab1675f74b7 29dfc13b8f4884e3
runtime-node-api-1 CONFIRMED EFFECT (bundle) 58,880 d1a07e0b8418c629 12ed7c1a194b82fa
env-retries-flaky-plain-1 NARROWED 43,008 46d99976364e8eda 16668d3caa233db3
limits-nofile-1 STUCK 32,256 a6464b019102d135 56eb1a79bd35ab2b

tar -xf extracts the 19 entries of runtime-node-api-1, and the SHA-256 of each of the 18 files that manifest.json lists equals the manifest value. The 45 case directories of the dev evaluation all pack and verify with the macOS release executable (45 of 45, exit 0).

The repeated runs described under The Lab on the Dev Split give 180 more archives. They were packed on Linux x64 and each verified with the Linux x64, Linux arm64 (in an arm64 container) and macOS arm64 executables: 180 of 180 verify: ok on each. The 45 original cases packed by the Linux arm64 executable are byte-identical to the ones the Linux x64 executable packed (45 of 45).

The four fixtures hold no reproduce/ directory: the arms/ files of those four were not copied from the Linux host when the fixtures were made. The 45 case directories of the evaluation do have them: packed, locale-tz-date-2 is a 43-entry archive with 24 entries under reproduce/. The core tests cover the arms/ to reproduce/ move with a fake executor.


CI Commands, Contracts and the Action

drift run [--tools] [--project] -- COMMAND runs a command, keeps the environment around it as a capsule, writes the exit code and the tail of the log into a capsule directory, and passes the exit code through. drift import builds the same directory offline from a saved GitHub Actions log (and the JSON of gh run view) or a GitLab trace with its pipeline and job JSON. drift diagnose --against BASELINE compares a saved passing run with the current one, and drift bisect plan and drift bisect apply narrow a green and red pair to the attributes that matter, one test at a time. None of them opens a network connection. drift contract create writes what a program needs from an environment: the values that every known passing capsule shares, reduced by delta debugging against the failing capsules to a smallest set that separates them (on the drangler runs, 1 assertion separates 5 green capsules from 3 red ones), and drift contract verify checks another capsule against it and exits 1 on a break. The contract file is canonical JSON, because common code does not parse YAML. ContractTest (20 tests) and ContractCommandTest (14) run on the JVM, macOS and Wasm, and the golden contract hashes the same on all three and on the release executable.

Run-identity variables (GITHUB_RUN_ID, GITHUB_SHA, CI_JOB_ID and the like, listed in CiVolatile) differ on every run of a job. They stay in the capsule, and the diff, the history evidence and the matcher skip them. Contract.constants did not, and a contract made from greens of one commit named env.GITHUB_SHA as the value that separates them from a red run of another commit; it now skips them, with ContractTest.aRunIdentityVariableNeverSeparatesARedFromTheGreens, which failed before the change. The same check was made for the Lab's arm diff (Arms.kt): it reads only the manifest entries that change with a base image (operating system release, libc, coreutils), which are never run-identity variables, so it needs no filter.

The GitHub Action (action.yml) and the GitLab component (templates/drift.yml) run these steps in a pipeline with the modes run, diagnose, capture and triage. The baseline comes from a run of the default branch pushed by a trusted event, and an artifact uploaded by a fork is refused (the fork case has a test). The triage mode comments on a new issue, patches its own comment when the issue is edited, treats a hostile body as data, and has an optional Slack webhook step whose URL comes from a secret. The shell tests (.github/action/test-drift.sh, test-triage.sh, test-gitlab.sh) pass with stubs for gh and the artifact API, and actionlint and shellcheck are clean. A real GitHub run, the Windows branch of the Action's script and a run on gitlab.com have not happened.


Upgrade Check

drift upgrade-check [CAPSULE] --to php=8.3,drupal=11 asks what a move to other versions breaks, from one capsule and no second environment. It copies the capsule, sets the target keys on the copy, and evaluates every rule against that synthetic pair with the same engine that diagnose uses. A target name is one of 19 aliases for an attribute path (agp, composer, docker, drupal, git, go, gradle, java, jdk, kotlin, node, npm, openssl, php, python, ruby, symfony, twig, tzdata), or a dotted path. Without a capsule the command captures this machine with --tools --project.

The result has five groups (four, plus the group below that is printed only when it has findings), taken from the three-valued verdicts of the matcher and not from a new score. Will break is a rule that matches on a fact beyond the target version keys, such as a package in the lockfile or a call in the source; the finding prints that fact on an evidence: line. Version boundary crossed is a rule that matches on the target versions alone: a documented change lies between the two versions, and nothing in the capsule shows that the project meets it. May break is a rule whose verdict is unknown because another key it reads is missing from the capsule or is one no scanner emits yet, and the output names the key (and says when the rule is pending-scanner). Outside the tested range is a rule that matches and whose symptoms all have the basis range: a compatibility table lists the supported versions (Kotlin Multiplatform against Gradle and AGP, AGP against Gradle and the maximum API level) and says nothing about a failure outside them, so the move is untested and not known to break. Unknown is a target whose current value the capsule lacks. A rule is listed only when its verdict for the target differs from its verdict for the capsule alone. The order inside a group is severity, verified symptoms before inferred ones, then rule id, and the JSON form is canonical, schema 2.

On the real capture fixtures/upgrade-check/drupal-10-php-8-1.json (the Composer fixture inside php:8.1-cli-bookworm: PHP 8.1.34, Drupal 10.3.6, Symfony 6.4.12), --to php=8.3,drupal=11,symfony=7.1 gives 0 will break, 3 may break, 2 version boundary and 0 unknown, and 84 of the 429 rules read a target key. The output is byte-identical on the JVM and macOS builds, and UpgradeCheckTest (19 tests) and UpgradeCheckCommandTest (9) run on all three Gradle targets.

What it got wrong. The first version put every version-only match under Will break. Case D (below) ran it before a PHP 7.4 to 8.3 move: 11 will-break findings and 1 may-break, and none of them was a failure in the project's own test suite (0 true positives, 12 false positives). The suite passed on PHP 8.3. What did break the move was not predicted: a locked package with a PHP upper bound stopped the install, and a locked package that lacks a PHP 8.1 tentative return type died with a fatal error. No rule covers either. The group split and the evidence requirement are the response, and the same capture now gives 0 will break, 1 may break, 11 version boundary and 0 unknown. That is a change of wording, not a better prediction: the 11 are still not failures, and the frozen prediction in fixtures/cases/d is unchanged.

A second check. On the four migration scenarios of DriftBench, moving the green arm to the red arm's version put a will-break finding on the injected version key in 3 of 4 scenarios, and in 2 of 4 in the natural direction (the red arm to the new version). Both counts are weak: any finding on the version key counts, and at that time none named the injected API (0 of 4). Added-API rules (36 when this check ran, one per call such as json_validate, Array.fromAsync or List.getFirst) then raised the API-level hits to 4 of 4, but only in the direction where side b has the older runtime, which is the CI-older-than-local shape; an upgrade runs the other way, and those rules do not fire for it.

Limits. A quiet result does not show that a move is safe: the catalog holds documented changes, 53 of its 429 rules wait for a key no scanner emits, and a project that uses a removed API without a rule for it is invisible. The direction of a rule is the direction of its text (a is the passing side), and a rule written for a downgrade can match an upgrade only by accident. --learned DIR adds the rules published by drift triage (see Triage and the Corpus).


Triage and the Corpus

drift triage ISSUE_FILE reads the text of an issue offline, with no model, and says what it can about the reporter's environment: facts as Drift attribute keys, each with a confidence, the rules that apply, statements that contradict what Drift knows, and a draft rule when the report suggests one. The same text gives the same bytes on the JVM, Native and Wasm builds, since the arithmetic is integer and the scanning has no regular expressions. A fact has a side (passing or failing) taken from the verdict word nearest the value, a path that says how it was found, and a confidence. The paths start at section 0.950 (the first line under an issue-form heading), tool-output 0.900, pattern 0.700 (0.800 under an environment heading) and key-value 0.490, and the agreement of two spans raises a value while a conflict or a hedge lowers it. Rules that match are listed with the facts they read. A draft rule needs a symptom signature, one version key stated on both sides with the passing version below the failing one, and no rule that already holds on that key. It is published, not only drafted, when --publish-threshold is set, its confidence reaches it, --date names the day the report was read, and the rule passes the rule compiler and its own fixtures. Published drafts go into a learned-rule store (--learned DIR, validated by the rule compiler on every read, never a shipped id) that diagnose, upgrade-check and triage read. Secrets are masked first with the capture redactor, and an @login mention becomes a hash.

The corpus. triage/corpus.yml lists 43 repositories on GitHub and GitLab, 15 large and 28 small (50 to 1,000 stars), each with its issue-template policy, and pins the corpus file by SHA-256 and size. ./gradlew :tools:buildCorpus pages each repository newest first through gh or glab (at most 25,000 issues per repository, none newer than the manifest cutoff), redacts titles and bodies, replaces every login with u and twelve hex digits of an HMAC-SHA256, sorts by repository and number, and writes one JSON file and its zstd -19 copy. The build gave 222,707 issues, 595,230,604 bytes as JSON and 78,765,569 as zstd, SHA-256 987d2c9b...5687. The file is not in the repository: the manifest points at a release asset that does not exist until it is uploaded, and ./gradlew :tools:downloadCorpus refuses a file that does not match the pinned hash. The build ran on the Mac with the logged-in gh and glab; no GitHub credential was copied to another machine.

Scoring. The truth comes from issue forms: a body that starts with ### Label sections states its environment under labelled headings, those facts are the truth, the sections that gave them are cut out, and the extractor runs again on what is left. Only failing-side facts on keys the form labelled are scored, and 8.1 agrees with 8.1.0. Issues split into a dev half and a held-out half by a seeded hash; the path confidences were set on the dev half and are reported on the held-out half. ./gradlew :tools:evalTriage is not part of build. It was run on the final tree for this report (6 minutes, 3 GB heap):

held-out half facts precision Brier note
all 1,376 0.783 0.1486 mean confidence 0.726, recall 0.154
pattern path 1,323 0.782 0.1488
tool-output path 36 0.889 0.1137
key-value path 9 0.444 0.2350 too few to read
section path 8 0.750 0.1753
forms with blank issues allowed 512 0.854 0.1301
forms only 864 0.741 0.1595
large repositories 1,367 0.786 0.1480
small repositories 9 0.333 0.2374 too few to read

Recall is low by construction (15%): it counts form values found again outside their own sections. Reliability on the held-out half, in bins of confidence: 0.4 holds 165 facts at accuracy 0.412, 0.5 holds 177 at 0.661, 0.6 holds 47 at 0.936, 0.7 holds 528 at 0.799, 0.8 holds 51 at 0.941 and 0.9 holds 404 at 0.933, so the 0.5 and 0.6 bins are underconfident and the 0.9 bin slightly overconfident. A fact was found in 75% of the issues of large form-only repositories, 63% of large form repositories with blank issues allowed, 60% and 40% of large Markdown-template repositories (strict, blank), 36% and 49% of small form repositories and 21% of small repositories with no template. Small repositories are worse on every measure that has a number, and 9 scored small facts support no precision figure. The scored sample is large trackers, mostly PHP, Node, .NET and Python.

Earlier in the work the key-value path read PHP Fatal error: ... on line 23 as PHP version 23 (123 of its 142 held-out misses) and the claim check fired on any sentence that mentioned a library without a negation. The first is fixed: a version must sit next to the tool name or its label. Before that fix the held-out precision was 0.731 and the Brier score 0.1645 over 1,500 facts; after it, the table above. The fix changed the fact set, so the two figures are not a paired comparison.

Fixtures. fixtures/triage holds 78 invented issue bodies with expectation files (strict forms, forms with blank issues, free text, sparse or wrong beginner reports, mixed phrasing, 18 for false positives and claim wording, 8 for the readers of logs and configuration). TriageTest scores the extractor on them: 207 facts, 205 true positives, 2 false positives, 4 false negatives, precision 0.990, recall 0.980, Brier 0.0848, and the test fails below 0.95, 0.93 or above 0.12. These bodies were written to exercise the extractor, so the scores are an upper bound for real issues. drift triage calibrate --corpus FILE --out TABLE repeats the dev-half recalibration on a corpus of the user's own issues (it reads the whole file into memory, and a .json.zst through the user's zstd), and --confidence TABLE applies it. On the real corpus (222,707 issues, 595 MB of JSON) the macOS executable took 7.5 minutes at a peak resident size of 6.09 GB; keeping only the form bodies instead of every parsed issue brought it to 6.0 minutes and 3.20 GB with an identical table (pattern 700 to 760, held-out Brier 0.1450 to 0.1408 over 1,434 facts). The remaining memory is the decompressed text itself.

Readers added against real issues. The first corpus-id run on the 164 labelled dev threads named the labelled cause on 2 of the 21 environment-cause items that carry a named attribute. The extractor and the rule catalog were then changed against 127 of those threads, a tune part drawn by SHA-256 of the thread id (first byte under 192), and checked on the other 37. The validate part holds three named environment-cause items, so it shows regressions and false claims and cannot show a gain. Each reader needs a shape that only one kind of text has (see Facts From Logs and Configuration in ADVANCED_USAGE.md): a requirement against the version found, tool names the text runs, pip output, container images, environment blocks, binding names such as PyQt6, server platforms in logs and jar names, docker version blocks and errno lines. Each has hand-written positive and negative cases in TriageTest, and eight fixture bodies. On the dev half the Linux executable (sha256 86b2a0f122df185dfc78233a7eec27296add289d8e01820ba10c986683aff492; the JVM output equals it on all 164 bodies) prints a fact for the labelled family on 10 of the 21 items (3 before), names the attribute exactly on 9 (3), a rule or draft names it on 4 (2), and a stated value agrees on 7 (2). Precision of the cause on items with a non-empty cause set is 4 of 12 (2 of 9 before). The null baseline did not move: 5 of the 143 items without a named attribute carry a rule or a draft before and after (3.5%), while 84 of them carry any fact against 78. On the 6,253 Tier A bodies a rule or draft is printed for 108 (1.7%) against 103 (1.6%). Of the two new hits one comes from the Composer rule and one from a draft for a changed container image, which describes one report that nobody has reproduced; the PyQt rule matches its item but reads a different key than the label, so it does not count. Five of the 18 named tune items name a tool or a version that is not in the issue text (a Tasmota firmware version, PowerShell, Electron, OpenSSL, a Skopeo version), and no reader can find those. These are dev-half numbers; the held-out half was not read.

Rules written and held back. Two rules came from documentation read in full: composer-2-platform-check-php-requirement (the Composer platform-check setting) and pyqt-6-removes-pyqt5-api (the PyQt6 differences page). Both read keys only triage emits, so they are pending-scanner and leave the benchmark data as it was. Three more PHP 8.0 rules (float to string casting independent of the locale, the removed Standard functions, curl_init() returning an object) are sourced from the migration page but read tool.php.version, and with them in the catalog the dev calibration fit, evaluation.json, upgrade.json and Weights.map all change (the isotonic map goes from eight steps to ten), so they sit in .claude/batch/results/p7-t2-held-rules/ until the benchmark data is regenerated on purpose.

The ask and the not-environment reading. When a report leaves a fact out, triage and drift why ask for it instead of saying nothing: a fixed table maps a tool the text names (a version command that matches Scanners.all and ToolRegistry), a hit open-file limit or full disk, a TLS library, PHP extensions, a named environment variable, a Bukkit-family server, a system without a release, a kernel, a time zone, a locale and a "works elsewhere" statement to one command, at most three per report and only when no rule, draft or program-side reading answers. drift why writes the session to .drift/why/session.json and drift why --reply reads the output of the asked command from standard input, parses it with the scanner that runs the same command and answers again; see docs/why.md. The corpus scorer counts an ask as correct on environment-cause and unclear items, and a not-environment call as correct only on code-defect and user-error items. On the 164 dev threads, with no held-out data read: four-class accuracy is 89 of 164 (0.543; Wilson 0.466 to 0.617), equal to the 89 of 164 that the amendment's ceiling gives a tool that always asks (it can be right on at most the environment-cause and unclear items). The unclear items are right on 60 of 61 and the environment-cause items on 10 of 28. The not-environment reading fires on 19 of the 75 code-defect or user-error items and on none of the 28 environment-cause items. That is 17 of 56 on the tune part, where the mechanisms were chosen, and 2 of 19 on the validate part, which each mechanism was run on once after it was fixed; the validate recall of 0.11 is the out-of-sample figure. The Wilson lower bound of 19 of 19 is 0.83, above the 0.80 bar, but 2 of 2 on validate is 0.34 and the held-out half has about 39 such items, so the bar needs at least 16 calls and a recall of 0.41 there, which these mechanisms do not reach. The six mechanisms added here are statements that the program's own logic or text is wrong (false positives or "incorrectly", a null dereference, an index out of range, a memory leak, an output that does not appear, a typo or line N of FILE in the report), found by reading the 56 tune code-defect and user-error bodies as a set and counting each candidate against the tune unclear and environment-cause items; they need a report with no story of change (no passing side, required version, limit hit or "works on" phrase) and they may stand beside tool versions. Candidates dropped on tune for firing on unclear items or environment-cause items: hangs and infinite loops (2 correct, 3 unclear), internal assertions (1 and 1), native crashes (1 and 1 unclear, 1 environment), a reproduction with a code block and no tool word, expected and actual values under the relaxed gate (1 environment), and project stack frames under it (5 unclear). The ceiling is set by the labels: by my reading at least ten of the 47 tune unclear items are program bugs by their text (a Safari regex that breaks table rendering, an autolink that drops part of a URL, a parser assertion, a compiler crash, a hang) that the thread simply never explained, and no text mechanism can tell them from a code defect that a maintainer named. A second loop measure, not a bar, answers each ask through the real drift why --reply with a made-up reply from a template per family: stage 1 (a text fact for the labelled family or an ask whose family equals it) holds for 13 of the 21 named environment-cause items (12 of 18 tune, 1 of 3 validate), and stage 2 (the session then states that family) for the same 13. It measures ask coverage and the parse round trip, not diagnosis. Eight of the 21 named attributes (the Tasmota firmware, PowerShell and Electron versions that the text never names, an eslint glob, SLSKD_UMASK, a CI runner label stated in a later comment, a disk quota whose text I did not read, and one item whose ask a matched rule suppresses) are out of reach of any command the text implies, so the exact-attribute and facts bars (above 0.65 and 0.70) top out at 13 or 14 of 21 (0.62 or 0.67), and 14 only if an ask were allowed beside a wrong rule match.

The clean-run check. "Not environment" is the highest bar and the text alone reaches about 0.1 recall out of sample, so Drift can also ask for a check: the report's own command run in a clean, contained container of the official image for its toolchain (docker run --rm --user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 1g [--network none] -v "$PWD":/src:ro --tmpfs /w:exec,mode=1777 -w /w IMAGE sh -c 'cp -R /src/. /w/ && COMMAND' 2>&1 | drift why --reply). Drift prints it and never runs it, and only COMMAND comes from the report: it is quoted by one function that refuses a control or non-ASCII character, a backtick, $(, a masked secret, a trailing backslash and more than 400 characters. The network stays on only when the report shows network use or the command is a package or build manager. The ask (family clean-run, fields family, command, piped as for every ask) needs a failure signature in the text (an error line read by SymptomExtractor, or the run:signature fact), a command (a $, > or % line in a code block, the one command of a block before the error block, or an inline code span after a run verb with a known program) and a toolchain with an official image. The image tag comes from the pinned tag list behind ImageTable; a test keeps the default flavor equal to what the ImageTable rows choose. A report with a command and no signature gets the error-text ask, whose reply is appended to the report. The clean-run reply is compared with the failure in the report after the normaliser of Signature has replaced numbers, times and temporary paths and a token with a slash reads as a path. The same failure ends in the not-environment reading (signal clean-run), an output with no error line is a pass and leads to the capture ask, and any other error keeps the ask open. Docker's own errors and binary output are refused. The session keeps only run:clean = same or works. The reading is deterministic and the same on the JVM, linuxX64 and wasmJsNode.

On the 164 dev threads the widened forms still reach nothing in the classes the bar is about: 0 of 75 code-defect and user-error items (Wilson 0 to 0.049) and 0 of 28 environment-cause items (0 to 0.121), on tune and on validate. Of the 75, 37 carry neither a failure signature nor a command-looking line, 23 are answered by a rule, a draft or a program-side reading before the ask applies, 7 show a command-looking line and no signature, 5 a signature and no command-looking line and 3 both (two deno reports and one dotnet report, whose toolchains have no image). Of the 28, 14 carry neither, 8 are answered earlier, 4 a signature only and 2 a command-looking line only. The ask fires on 1 of the 61 unclear items (a npm install report) and the error-text ask on 2 more (both Rust reports that run rustc --version). The command-looking count is a regex over the body (a prompt line, or a run verb and a code span) and overstates: of the 9 items with one and no signature, none is a command Drift can run (a Gradle task line, quoted logs and SQL, docker and skopeo sessions whose toolchains have no image, a prose sentence with a code span). Issue reports mostly describe the command in prose without a runnable span. The round trip through the real binary with the synthetic replies (replay.py prepare-clean, clean-reply, replay-lane.sh b same and works, table "Clean Run Loop" in score.py) therefore has one corpus item, the unclear one: its same-failure reply, built from the run:signature fact, ends in the not-environment reading and its works reply in the capture ask, never in the not-environment reading. Three hand-written reports through the lane binary behave the same, the environment-cause one included: a same-failure reply on an environment-cause report is the correct reading when a clean machine reproduces the failure, so it is counted, not hidden, and a works reply never ends in the not-environment reading (a unit test on a report with a passing side checks both). The four-class accuracy, the not-environment precision and the other ratified numbers are unchanged by this item (89 of 164 and 19 of 19 on dev). The stage measures ask coverage and the parse round trip, not diagnosis: nobody ran the command on a clean machine.

Runtime from error vocabulary. A reporter with one machine often pastes only an error line (ModuleNotFoundError: No module named 'distutils') that names no tool, so the ask table's name matching found nothing to ask. Asks.runtimes now maps each runtime's own error vocabulary to the same family ask as if the text had named it (Python, Node, PHP, JVM, Go, Rust, Ruby, .NET), and a shell or runtime "not found" error asks command -v NAME; a path, a generic word, a name with a dot or a slash and a tool whose version is stated never ask. drift why --error alone assumes the program works elsewhere (TriageOptions.worksElsewhere): no program-side reading from the text alone and the capture as the second ask. Through the real code on the six cold-start briefs 9 to 14 (drift why --error LINE): 9 asks php -v, 10 python3 --version, 12 command -v git, 13 ulimit -n; 11 (AssertionError: expected 4 lines, got 1) and 14 (AssertionError: importer cannot write: album mode is 755) name no runtime or tool, so they get the capture ask only, which is the one way their time zone and umask differences can surface; an earlier build called both "not an environment difference" from the expected-and-actual lines. On the 164 dev threads no ratified number moved (four-class 89 of 164, not environment 19 of 19, null baseline 138 of 143, cause precision 4 of 12) and the clean-run reach is unchanged (0 of 75, 0 of 28; 1 of 61 unclear). Two items changed: a Deno report now asks for Node (the report has is not a function with at frames) in place of the system version, an inference a Deno report makes wrong, and a PHP locale report matches a rule that the lane's rule directory holds and the earlier outputs did not.

Comparing one fact. The ask and reply loop settled one machine at a time: a reader with PHP 7.4.33 on the failing machine got a verdict that no recorded rule explained it (the sentence now says "I have no recorded rule that explains this failure from PHP version 7.4.33 alone, so I cannot say whether it matters yet") and had to bring their own knowledge. drift why --reply --works closes the loop: the session keeps the parsed facts of each ask on two sides (reply for the failing machine, additive works), and when an ask has both, compareFlow builds a works and a failing capsule from them and calls the same Explain.run that --there uses, so ranking, error-text pointers and verdict wording are shared (PHP version: 8.3.12 where it works, 7.4.33 where it fails, "most likely"). Equal facts say they do not differ and the next check follows. The works step is placed before the other checks and the capture, and either side may be given first. Replay on the dev half (21 named environment-cause items; 3 get an ask with a template): all 3 works-side replies, built from the label's before value or a placeholder, end with the fact named as the difference (2 of 2 tune, 1 of 1 validate); the stage measures the comparison path and the parse round trip, not diagnosis, and no triage output changed, so no ratified number moved. A saved capture of this very machine now says "Nothing I can read differs between this machine and the saved capture of itself; run this on the machine where it works" instead of the generic no-difference text.

Held-out result, corpus-id, Drift final6. The held-out half holds 141 labelled threads (13 environment cause, 6 of them with a named attribute; 32 code defect; 7 user error; 89 unclear). It was scored once, with the Linux x64 executable of sha256 ddeb143b... and the ratified bars of 2026-10-27, and nothing was tuned on it. The full record is experiments/results/corpus-id/heldout-final6.md.

bar held-out ratified result
exact attribute named 2 of 6 above 0.65 miss
facts for the labelled family 2 of 6 above 0.70 miss
null baseline, no rule or draft on non-named items 131 of 135 at least 0.50 pass
not environment, precision on its calls 8 of 24 (Wilson lower 0.18), 2 environment-cause items called point at least 0.95, lower above 0.80, no environment-cause call miss
cause precision 0 of 1 0.85, lower 0.65 miss
four-class accuracy 84 of 141 (0.596; Wilson 0.513 to 0.673) 0.80, lower 0.72 miss

Five of six bars are missed. On the dev half the not-environment reading was 19 of 19; on held-out it is 8 of 24, with 14 of its 24 calls on threads labelled unclear and two on environment-cause threads (fabiospampinato/vscode-todo-plus#276 and nodejs/node#8545). The signals were chosen on the dev tune items, and 17 of their 19 dev hits were those items, so the dev figure measured memorization of the tune set more than a mechanism. The per-signal table is in the record: located fired 10 times with 4 correct, crash 5 times with 2 correct. A tool that always asks would score 0.723 on the four-class measure (102 of 141); Drift scores 0.596, because it says not environment on unclear threads, which the ratified table counts as wrong. The loop tables (ask and reply, clean run) were not run on this half.

The claim check is weak. It flags a sentence that contradicts something Drift knows: nine statements, each tied to a rule or to two Atlas columns that print different results (for example, that the Nashorn engine is available in JDK 21, which the rules say JEP 372 removed). It needs the facts and a sentence that affirms the claim inside one clause; questions and "removed in" sentences are rejected. A labelled set of 67 maintainer-correction threads from 13 repositories (triage/claim-labels.yml: 6 corrections, 55 not corrections, 6 unclear) was built to measure it, and the search heuristic that found the candidates has a precision of 6 of 61 (0.098; Wilson 0.05 to 0.20). The engine flags none of the 6 corrections. The 67 threads were split by sha256("<repo>#<issue>") (first four bytes, odd is held-out) before the second fix: 30 held-out (3 corrections, 23 not corrections, 4 unclear) and 37 dev. The engine flags 0 of the 30 held-out threads, so its precision there is undefined and its recall is 0 of 3 (Wilson 0 to 0.56). On the whole corpus the fix pass (assertion shape in the first pass; hedge, deprecation and imperative cues in the second) took it from 12 hits to 6 to 1 in 222,707 bodies; the one left is distutils in a parenthetical in a CPython issue and is not a claim. By the rule fixed for this check (held-out precision below 0.5 removes the feature from the headline claims), the claim check is documented as experimental in README.md and ADVANCED_USAGE.md. The claim list covers nine Kotlin, JDK and Python statements, none of which the 13 repositories of the labelled set discuss, so a larger set would need claims written for it. The claim-check set also holds too few corrections (6) for a recall figure.


Studio Screens

Studio is one Compose Multiplatform codebase for the desktop, the browser and the mobile builds. Its Home tab offers Choose a Case File, Show a Sample, Open the Atlas, and one entry each for the Upgrade, Contract and Bisect screens, each with a one-line description. A .driftcase opens from the file picker (desktop and web), from the launch argument, from a macOS open-file event, and from the operating system through a file association for application/x-driftcase; the Case tab shows the sentence summary and the verdict, and for a tampered archive it shows the check that failed. drift open starts Studio for a file, or writes report.html beside it when Studio is not installed. The Atlas and Investigation tabs described below remain. The Upgrade screen takes a capsule and name=version pairs and shows the groups of Upgrade Check as one sentence per group count, then each finding with its rule, boundary and source; the Contract screen takes a contract and a capsule and lists requirements that break, could not be checked and hold; the Bisect screen takes a passing and a failing capsule and shows the settings that differ, the first test in words and the drift bisect commands to run, and runs nothing itself. Summaries of a certificate and a ranking use fixed wording per verdict and tier (tested on all four archives in fixtures/lab/cases), technical output sits behind Show Technical Details, a term is glossed once where it first appears, and controls are at least 44 dp. The palette is a muted purple (accent text #6b3fd4 on light and #b99cff on dark, flat, no gradients) shared with the documentation site template, defined once in StudioTheme.kt; StudioThemeTest asserts contrast of at least 4.59 to 1 for text and 3.41 to 1 for marks. The screens were checked at 390 px wide in the web build (scrollWidth equals innerWidth on the Upgrade and Bisect screens). Studio has 114 JVM tests and 69 browser tests. The Wasm build bundles JetBrains Mono (OFL) so that the monospace panels render the same everywhere. The wording rules follow brand-voice conventions; no measured effect on comprehension is claimed.


The Kotlin Portability Atlas

The Atlas answers one question per probe: does this Kotlin program print the same thing on every target? It is a set of 38 probes in the kotlin family, each a short function with a stated question, its source text, a classification and notes. The areas are char, collections, double, exceptions, float, int, long, math, numbers, random, regex, string and time.

Commands.

command does
atlas record --out DIR [--target LABEL] runs the probes on this target and writes DIR/<target>.txt
atlas build FILE... --out FILE aggregates transcripts into a canonical JSON dataset
atlas compare DATASET TRANSCRIPT [--detail ...] shows where a device matches each recorded column
atlas show DATASET [--probe ID] prints the matrix, or a repro draft for one probe

A transcript starts with # kotlin.version = ... and # kotlin.target = ..., then one @@ <probe id> section per probe. A cell of the dataset is a (target, Kotlin version) pair, and divergence is judged inside one version only, so a version column never merges into another. build refuses a transcript that lacks a probe of the catalog, names an unknown probe, or duplicates a column.

atlas show --probe prints a draft that begins GENERATED DRAFT, NOT REVIEWED, says a person must read it, rerun the source and rewrite it, and that Drift does not file, post or contact anyone. It then lists the classification and its basis, the quoted references, the linked issues with the state read on the research date, what was searched, the path of the repro draft, the columns measured, the source, the differing lines per version group and a checklist.

Classification. The classification lives in atlas/classification.yml, one hand-maintained entry per probe, and the build embeds it as JSON so the dataset, atlas show and Studio read one source. A probe is documented (pages that the author fetched and read in full state the behavior, for every differing line), platform-defined (a platform specification delegates it: a JDK Javadoc page, an ECMAScript section or the Unicode version a Character page names), or unclassified (no explanation found; this does not call the behavior a defect). Each entry records its references with a short quote, the KT issue ids with the state read on the research date, the cause classes, and for an unclassified probe what was searched. Of 38 probes, 10 are documented, 4 platform-defined and 24 unclassified. Among the 32 divergent probes on the 16 columns of 2.4.20, 4 of 10 documented, 4 of 4 platform-defined and 24 of 24 unclassified diverge. Thirteen entries were downgraded to unclassified (eleven from documented, two from platform-defined) when the page read did not cover every differing line. Each cause letter on an entry is one of: a documented difference, b platform-defined, c Kotlin issue, d hardware, e JDK version, f build tool, g unexplained. An issue outside the Kotlin tracker (a TeaVM issue) carries its own url.

The method has limits. The tracker was read through YouTrack's REST API, read only: the entries list their queries, and where an entry says so only the first 8 results of each query were read, so a relevant issue ranked lower was not seen. Anything not named in an entry's references was not read. Quoted passages are short and not reproduced. The classification says what the sources named explain and does not say why a target differs. drift atlas show --probe prints these fields and a draft for human review that begins GENERATED DRAFT, NOT REVIEWED; the draft files under atlas/repros/ (one per divergent unclassified probe, 24) are never sent anywhere.

Results, Kotlin 2.4.20 (16 columns: JVM on JDK 21 on a Mac and on JDK 17, 21 and 25 on x86-64 Linux, Native on macOS arm64, Linux x64 and Linux arm64, Wasm on Node and in three browsers, Kotlin/JS IR on Node, Android, iOS, and two Cloudflare Workers columns, Wasm in workerd and a JVM build through TeaVM):

quantity value
probes identical on all 16 columns 6
probes that differ on at least one 32
probe output lines per column 734 (703 on js-ir, where 1 probe does not compile; 641 on cloudflare-jvm, where 4 probes are unavailable)
lines that differ between JVM, macOS and Wasm on Node 107
lines that differ between any of the 16 columns 340
dataset, 16 columns, canonical JSON sha256 27de9a7f...78cd
dataset, 21 columns (adds the 5 at 2.5.0-Beta1), schema 2 688,560 bytes on disk, sha256 8364183f...aae5 of the body

Adding the ten columns beyond JVM 21, macOS and Wasm on Node made no identical probe divergent: the divergent count stayed at 23. The two Cloudflare columns then made six more divergent (char.case-special, int.division-modulo, int.overflow, numbers.type-checks, regex.anchors-lines, regex.groups-replace), all on cloudflare-jvm, which runs TeaVM's class library; cloudflare-wasm is byte-identical to the wasm column apart from the target line. The js-ir column (the standalone Kotlin/JS IR compiler, run on Node, 37 of 38 probes) made three more divergent, collections.equality-hashing, double.equality-order and string.hashcode, which gives 32. It is the column furthest from the others: 11 of 38 probes agree with macOS. For example, list-add inside a loop over the same list throws ConcurrentModificationException on every other column and returns [1, 2, 3, 4] on js-ir. The two Cloudflare Workers columns were also deployed once on a free account with drift probe cloudflare --deploy: the deployed output matched the local wrangler dev columns except for the sign bit of NaN results (0/0-raw printed fff8000000000000 deployed against 7ff8000000000000 locally on both Workers, and sqrt-neg-raw the same on the JVM Worker), which is the x86-64 default NaN that the CPU-dependent probe below describes; the deployed CPU was not checked, so that cause is inferred. Both Workers were deleted afterwards and the account listing matched its baseline.

A fresh drift atlas record on the macOS release executable wrote a transcript byte-identical to the committed fixtures/atlas/macos.txt. Compared against the 21-column dataset, this Mac matches its own column on 38 of 38 probes, the iOS column on 38, the Linux arm64 column on 37, Linux x64 on 36, JDK 21 on 21, the Wasm column on 25 and the js-ir column on 11.

The divergences are specific and small. Six lines of a 14-line probe, with the js-ir column the one that differs most:

drift atlas show atlas.json --probe kotlin.collections.iterator-modification | sed -n '/^kotlin 2.4.20/,/^controls/p'
kotlin 2.4.20: 4 different results
differing lines: 6 of 14
  line 1
    android, cloudflare-jvm: list-add = err:ConcurrentModificationException
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: list-add = err:ConcurrentModificationException
    js-ir: list-add = ok:[1, 2, 3, 4]
    jvm, jvm-17, jvm-21, jvm-25: list-add = err:ConcurrentModificationException
  line 2
    android, cloudflare-jvm: list-remove-first = err:ConcurrentModificationException
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: list-remove-first = err:ConcurrentModificationException
    js-ir: list-remove-first = ok:[2, 3]
    jvm, jvm-17, jvm-21, jvm-25: list-remove-first = err:ConcurrentModificationException
  line 3
    android, cloudflare-jvm: list-remove-second = err:ConcurrentModificationException
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: list-remove-second = ok:[1, 3]
    js-ir: list-remove-second = ok:[1, 3]
    jvm, jvm-17, jvm-21, jvm-25: list-remove-second = ok:[1, 3]
  line 4
    android, cloudflare-jvm: list-remove-last = ok:[1, 2]
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: list-remove-last = ok:[1, 2]
    js-ir: list-remove-last = ok:[1, 2]
    jvm, jvm-17, jvm-21, jvm-25: list-remove-last = err:ConcurrentModificationException
  line 8
    android, cloudflare-jvm: sublist-after-add = err:ConcurrentModificationException
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: sublist-after-add = err:ConcurrentModificationException
    js-ir: sublist-after-add = ok:[1, 2]
    jvm, jvm-17, jvm-21, jvm-25: sublist-after-add = err:ConcurrentModificationException
  line 9
    android, cloudflare-jvm: sublist-size-after-add = err:ConcurrentModificationException
    cloudflare-wasm, ios, linux, linux-arm64, macos, wasm, wasm-chromium, wasm-firefox, wasm-webkit: sublist-size-after-add = err:ConcurrentModificationException
    js-ir: sublist-size-after-add = ok:2
    jvm, jvm-17, jvm-21, jvm-25: sublist-size-after-add = err:ConcurrentModificationException
controls: 8 lines are the same on every target

Others, each in fixtures/atlas/*.txt:

Kotlin 2.5.0-Beta1. The same 38 probes were recorded on five targets (JVM, macOS arm64 Native, Wasm on Node, an Android emulator and an iOS simulator) with the compiler pinned to 2.5.0-Beta1 in a copy of the repository, once each. Divergent probes fall from 23 (the same five targets at 2.4.20) to 18. Five probes became identical on all five targets: double.extremes, double.parse, double.tostring-boundaries, float.tostring and numbers.float-conversions, all by the Wasm column moving to match the others (and, for the last, the macOS and iOS columns too). Five more changed without becoming identical, all in the Wasm column: collections.tostring-forms, double.shortest-digits (three groups to two; Android still differs from the rest), exceptions.messages, math.pow-special-cases and math.rounding. The probe notes cite KT-88414 for the Double and Float formatting; the tracker lists it as Fixed, resolved 2026-08-30. The data shows what changed. It does not show why, and the compiler changes behind it were not read. The kotlin.version header of a Beta transcript keeps the qualifier, because the scan module embeds the version from the build's version catalog (embedKotlinVersion).

Against the 21-column dataset, this Mac matches [email protected] on 38 probes and [email protected] on 37.

Version ladder. To see how the same probes behave on older compilers, tools/ladder compiles each probe's displayed source with a standalone compiler per version and per target. Probe files that do not compile are dropped and recorded as UNAVAILABLE with the first compiler error. The transcripts are in fixtures/atlas-ladder/<version>/. The cells below are the number of the 38 probes that ran, with - where no run exists.

version jvm macos linux js-legacy js-ir wasm
1.3.72 17 - 15 16 - -
1.4.32 26 - 23 25 25 -
1.5.32 31 - 29 30 30 -
1.6.21 33 31 31 32 32 -
1.7.22 35 34 34 35 35 -
1.8.22 37 35 35 36 36 -
1.9.25 38 37 37 - 37 -
2.0.21 38 37 37 - 37 -
2.1.21 38 38 38 - 37 -
2.2.21 38 38 38 - 37 -
2.3.21 38 38 38 - 37 -
2.4.0 38 38 - - 37 -
2.4.20 38 38 38 - 37 38
2.4.21 38 38 - - 37 -
2.5.0-Beta1 38 38 - - 37 38

The 2.5.0-Beta1 row also has android and ios columns (38 probes each), left out of the table, and 2.4.20 has the 16 columns described above. atlas build over the 16 columns of fixtures/atlas, the 5 of fixtures/atlas/2.5.0-beta1 and the ladder transcripts gives 74 columns, 2,812 cells, 34 divergent probes and 4 identical (sha256 69b785d4...36c2). The ladder's own 2.4.20/linux.txt and 2.4.20/js-ir.txt are left out of that build because fixtures/atlas/linux.txt and fixtures/atlas/js-ir.txt are the same columns (Linux Native and Kotlin/JS IR at 2.4.20); with the divergent probes of each version, 2.4.20 has 16 columns and 32 divergent probes and 2.5.0-Beta1 has 6 columns and 30. A probe counts as divergent when its output or its availability differs between columns of one version, so these counts include APIs that do not exist yet in old versions. Value changes, as opposed to appearing APIs, are fewer. On the JVM, no probe changes value anywhere from 1.3.72 to 2.4.21, apart from one class name printed by kotlin.exceptions.tostring-cause, which differs because the ladder compiles the displayed source and the committed 2.4.20 column ran the real code. Changes on the other targets:

Version diff. drift atlas diff DATASET --from X --to Y [--target T] compares two versions of one dataset on the targets recorded for both and lists each probe whose transcript differs, with the differing lines; a probe that stopped compiling or started to is reported as unavailable or available instead of as a value change. ./gradlew atlasLadderDataset builds the 74-column dataset, and atlasSite writes it to data/ladder.json and writes versions.html, the change between each pair of consecutive versions per target. The diff from 2.4.20 to 2.5.0-Beta1 reproduces the Beta findings above: 10 of 38 probes change in all, 10 on wasm, 1 on macos (kotlin.numbers.float-conversions, ulong-max-double), 1 on ios, 1 on js-ir (kotlin.exceptions.messages, the Long division by zero message) and none on jvm or android. The tests read the fixtures a second way (AtlasTranscript.parse per probe) and compare that with the command's output.

What did not run, and why:

Studio. The Atlas is the first tab of Drift Studio. It shows the matrix of 16 columns at 2.4.20 and 5 at 2.5.0-Beta1 with a version selector, the columns grouped by family (JVM, Native, Wasm, JS, Cloudflare, Mobile, Device) under one horizontal scroll, a divergent-only filter, three detail levels, a column for the device the app runs on, and a predict-then-reveal mode that shows the question and the source, asks which output a target gives, and keeps the score in memory. A tap on a row shows the source, each target's full output, the basis and quoted references, the issues with the state read, what was searched, and the notes. A probe with no explanation reads "unclassified: no explanation found", and the footer states the limits of the data. The app has an Investigation tab that runs StudioState.of over the drangler capsules (funnel, candidates, drill-in, three detail levels). Studio builds for JVM Desktop and for the browser as Wasm; the Android and iOS builds replay the embedded transcripts and add the live probes of the device. The JVM tests (114) and the browser tests (69, in Chrome through wasmJsBrowserTest) pass; the Studio screens beyond the Atlas are described in Studio Screens above.

The site. ./gradlew atlasSite writes a static site to build/atlas-site: the Studio web build at the root, data/dataset.json (688,560 bytes), data/classification.json, data/ladder.json (1,990,747 bytes), the 17 transcript files, 404.html, versions.html and one page per probe under probe/ (38 pages and an index). It has 79 files and 18,548 KiB, of which the two Wasm files are about 12 MB. The probe pages are static HTML with no script and no external resource, and the text is escaped. A unit test builds the site twice from two temporary directories and compares the bytes; a second full build and a hash of every file was done on 2026-10-07 (78 files then) and not repeated. Every page under probe/ links to ../../ and the 404 page to ./../, the directory above the site root, which is the front page of the documentation site. docsSite places this site under atlas/ of the documentation site and the Dokka site under engine/. The deploy steps ran against a local bare repository before docsSite existed (one commit of 2,907 files, then "No changes to deploy"); the steps now copy build/site, and that run was not repeated. No GitHub run exists, and the sticky name column and horizontal scroll of the matrix were not looked at in a browser.

The documentation site. ./gradlew docsSite renders README.md, MANUAL.md, ADVANCED_USAGE.md, TECHNICAL_REPORT.md and the seven guides in docs/*.md into assets/site-template.html with commonmark-java (tables, heading ids and autolinks) and writes them with the Atlas site and the Dokka site to build/site: 3,969 files and 68,944 KiB on 2026-10-07, before the guides under docs/ were added (not rebuilt since). The render fails on a relative link to a path that is not in the repository and on a link to a heading that does not exist; the current pages pass the render (lane a4, 2026-10-26). checkSiteLinks found no unresolved relative href or src outside engine/ (lane a4, 2026-10-26); the unresolved links inside engine/ are Dokka links to pages it does not write. The front page and the report page were loaded at 390 by 844 with Chrome device emulation: one column, scrollWidth equal to innerWidth, and no table wider than the viewport outside a scroll container. Chromium through Playwright loaded the front page, both documents, atlas/, a probe page and engine/ from a local server with no console error, no failed request and no HTTP error status, started Studio under atlas/, and followed every nav item and back. GitHub Pages was not involved, and no other browser loaded the site.

What the Atlas does not cover. There is no Safari column. The mingw-wine transcript in fixtures/atlas-extra comes from Wine and is not a Windows column, and no Windows JVM column exists. Android is an emulator on API 37 and iOS is a simulator; no device ran. There is no macOS x64 column, and the js-ir column comes from the standalone compiler on Node and not from a Gradle build. .github/workflows/atlas.yml defines the jobs that would record Linux x64, Linux arm64, macOS, Windows, three JDKs, and the browsers; it has not been run. Every cell is a single run, and the hardware and build host change some cells (see Platform and CPU Findings and The build host).


Platform and CPU Findings

The same Kotlin code printed different bytes depending on the machine around it. Each finding below names the probe, the columns and what was and was not isolated.

Linux, mingw and mobile runs. The Linux x64 and Linux arm64 test binaries were cross-linked on the Mac and run natively, x64 in a Debian container on a Linux host and arm64 in an arm64 Debian container on the Mac's Docker; each ran 730 tests with 0 failures (core 492, scan 80, host 8, cli 74, bench 76, the counts of the tree at that run, before the installer, server and Atlas additions). A later x64 run in a Temurin 21 container (--memory 10g --cpus 8, 0 failures) passed core 492, scan 94, host 14, cli 220 and bench 76. A Kotlin/Native build on an arm64 Linux host was not run. The linuxArm64 test binaries ran on arm64 Linux in a Debian container on the Mac's Docker (native, no emulation) on a tree some hours older than the final build: scan 116 of 116, cli 276 of 276, host 31 of 34, core 556 of 560 and bench 73 of 76. Every failing test name failed identically on macOS at the same snapshot, so none was an arm64 divergence, and all five modules pass in the current Gradle runs on three targets; the arm64 binaries were not rerun on the final tree. The mingw test binaries ran under Wine 9.0 on Linux. Wine is not Windows, so these runs show that the binaries start and the logic runs and show nothing about Windows behavior. In the first run 79 of 80 scan tests passed: CaptureTest.selfDiffOnTheRealHostIsEmpty failed with "no env captured on mingw", because NativeHost.env() ran cmd /c set through _popen, and under Wine _popen failed with errno 2 for every command string tried; getenv("COMSPEC") and getenv("PATH") were both null in the test process, so the C runtime could not find a shell. Real Windows always sets both, so this cause is inferred not to apply there, and that inference was not run. The fix reads the Win32 environment block with GetEnvironmentStringsW (EnvBlock.kt and the mingw SystemHost, six parser tests and one real-host test). After it, scan passed 80 of 80 and host 15 of 15 under Wine, and capture under Wine recorded 21 env.* attributes and cpu.count. Under Wine every other run() call still returns null, so facts that come from commands stay absent. A later cli run under Wine passed 205 of 206 tests; the failure was NativeInstallTest.filesAndDirectoriesBehaveLikeTheFakeDoes. The mingw test binary of the current tree passes under Wine 9.0 in six runs, 263 of 263 tests each (NativeInstallTest 4 of 4; one Unicode file-name test is skipped under a POSIX locale and runs under LC_ALL=C.UTF-8), so that failure did not reproduce and its cause stays unknown: the binary that failed was not kept. Wine aborts with free(): invalid pointer when TMPDIR, TEMP or TMP is set to /tmp, which was not investigated. On Android, the three host device tests passed on the emulator; on iOS, host, core and scan simulator tests passed at the counts of that run (11, 484 and 79). No device ran either.

Browsers. tools/browsers/run.mjs loads the Studio web build in Playwright 1.63.0 and writes the this-device transcript the page produces. On one x86-64 Linux host:

engine version first contentful paint, 4 runs this-device probes, 4 runs
Chromium (V8) 153.0.8010.12 288 to 332 ms 40 to 42 ms
Firefox (SpiderMonkey) 155.0 314 to 372 ms 41 to 45 ms
WebKit (JavaScriptCore) 26.6, Playwright's Linux build 343 to 372 ms 44 to 46 ms

Four full runs per engine wrote byte-identical transcripts, which are the committed wasm-chromium.txt, wasm-firefox.txt and wasm-webkit.txt. The three engines agree on all 38 probes. Karma ran studio:wasmJsBrowserTest in Firefox ESR 153.4.0 in a container: 26 tests, 0 failures at that run (the Chrome run now has 69). Safari was not measured: its automation needs "Allow remote automation" in Safari's Developer settings, which was off and was not changed. Playwright's WebKit is a Linux build and says nothing about Safari. Two engine findings: headless Firefox in the container could not create the WebGL context that Compose needs, so the runs use headed Firefox under xvfb; and WebKit under xvfb with no locale reported navigator.language as C, and Compose threw RangeError: invalid language tag, so the runner sets the locale to en-US. Studio does not survive a browser that reports an invalid language tag.

Browser layout. tools/browsers/layout.mjs captures computed layout for one page in the same three engines at 390x844, 768x1024 and 1280x800 (34 static browser.* attributes per capsule: engine and viewport, CSS.supports for five declarations, matchMedia for seven queries, and the boxes of the elements marked data-drift). fixtures/sites/viewport is a page with one known break: a paused height: 0 to height: auto animation under interpolate-size: allow-keywords. At 1280x800 the panel is 45 px tall in Chromium 153.0.8010.12 and 0 px in Firefox 155.0 and WebKit 26.6, and the footer below it is at y 221 against 176. CSS.supports says interpolate-size is true in Chromium only; corner-shape is true in Chromium and WebKit, which Browser Compat Data does not predict for Safari (Playwright's WebKit is not a Safari release), and position-anchor, field-sizing and text-box-trim are true in all three. Header and grid sizes follow the viewport and agree across the engines. Two rules (browser-css-interpolate-size-support-divergence, browser-viewport-width-changes-layout) match on browser.css.supports.interpolate-size and browser.viewport.width. Over the 72 ordered pairs of the nine capsules, drift diagnose ranked exactly the expected causes known in 72 (same engine with another viewport 18 of 18; Chromium against another engine at one viewport 12 of 12, and at another viewport 24 of 24; Firefox against WebKit at one viewport, which has identical boxes and no expected cause, 6 of 6, and at another viewport 12 of 12). Without the rules the Chromium and Firefox pair printed that the data does not name one cause. The expected cause per pair is set by construction (which engines and viewports a pair holds), so this measures that the rules and the ranker agree with the construction, not that diagnose finds an unknown cause in a page it has not seen. The captures are from macOS arm64; the Linux capture step in the browser CI job has not run.

A CPU-dependent probe. kotlin.double.nan-bits records the raw bits of 0.0 / 0.0, sqrt(-1.0) and inf - inf. On x86-64 0/0 has the sign bit set (fff8000000000000) and on Apple Silicon and arm64 Linux it does not (7ff8000000000000). Every browser column differs from wasm (Node on a Mac) on that probe only, and the three browsers agree with each other. A hand-assembled WebAssembly module doing f64.div 0 0 and reinterpreting the result printed 7ff8... on Node 26.8.2 on arm64 and fff8... on Node 22.23.3 on x64, so in this data the cell follows the CPU and not the engine. That script is not in the repository. The Node column was never recorded on x86-64, so the wasm column and the browser columns stay confounded. The same probe splits the JVM columns by CPU: jvm (JDK 21, Mac) and jvm-21 (JDK 21, x86-64 Linux) differ on double.nan-bits, math.pow-special-cases and math.transcendental-bits, and a JDK 21 run on arm64 Linux is byte-identical to jvm. Linux x64 against Linux arm64 differs on double.nan-bits only.

The JDK. With the same classes (compiled for Java 17) on one x86-64 host, the JVM column changes with the JDK. JDK 17 against 21 differs in 6 probes: char.bmp-case-mapping and char.bmp-categories (the Character Javadocs of 17, 21 and 25 name Unicode 13.0, 15.0 and 16.0, and the probe's letter counts differ: 48,909, 48,965 and 48,973), double.shortest-digits (17 prints 2e23 as 1.9999999999999998E23, 21 prints 2.0E23; the JDK 19 release note JDK-4511638 describes the Double.toString change), exceptions.messages, numbers.float-conversions and regex.unicode-matching. JDK 21 against 25 differs in the two Character probes. One line of the 17 column is a harness effect: removeFirst on an empty list reports NoSuchMethodError because the probe was compiled against the JDK 21 API. The classification entries cite those pages; which Unicode release each table corresponds to was not checked beyond what the Javadocs name.

R8 on Android. Turning on isMinifyEnabled with proguard-android-optimize.txt shrank the release APK from 7,203,057 to 1,435,574 bytes and the app still launched, but the on-device Atlas summary changed from "38 of 38 probes match" to "35 of 38" and "matches no column: 3". R8 changed the measured result of 3 probes, which defeats the app, so minification stays off in androidApp/build.gradle.kts. The three probes were not identified, because a release APK cannot be read with run-as. This was measured once during packaging and not repeated for this report.

The build host. Eight cli executables were built from the same sources (x64 and arm64, linked on macOS or on Ubuntu, release or debug) and run in Debian containers. Only the macOS-linked release build prints ...a5 for tan1 and ...7365 for asin-half and acos-half in kotlin.math.transcendental-bits; the other seven print ...a6 and ...7366. The macOS-linked release transcripts are the committed linux and linux-arm64 columns (sha256 equal), and fixtures/atlas-variants holds the Ubuntu-linked ones. A throwaway program (linuxX64, release, linked on macOS) printed tan(1.0), asin(0.5) and acos(0.5) as ...a5 and ...7365 when written as constants and as ...a6 and ...7366 when read from the command line, so one executable disagreed with itself: the constants are Apple's libm values and the run-time values are glibc's. Linked on Ubuntu, or built as debug on macOS, it printed the glibc values for both. Ubuntu 24.04.5 and 26.04.1 builds gave identical transcripts. A run of one executable on glibc 2.36 and on glibc 2.39 printed identical output on all 38 probes (fixtures/atlas-extra/linux-ubuntu), so the runtime library does not explain it. The ladder's 2.4.20 Linux transcript, compiled with the standalone Kotlin/Native compiler on x86-64 Linux, prints the ...a6 and ...7366 values (diff of fixtures/atlas-ladder/2.4.20/linux.txt and fixtures/atlas/linux.txt shows those three lines and the two class-name lines). Gradle's native test binaries are debug builds, so no test binary can reproduce the committed linux column; KotlinProbeTest and AtlasCommandTest compare a Linux test binary with the variant transcript instead. The mechanism inside the compiler is an inference: that an optimizing build folds tan, asin and acos on constants with the libm of the machine that runs the compiler, and that a debug build does not. Nothing inspected the compiler. The same throwaway program for linuxArm64, linked on macOS, did not show the fold, though the real cli arm64 executable does; that is unexplained, so the arm64 evidence is the four builds, not the throwaway. The committed Linux columns are the macOS-linked release builds, and a run of atlas.yml, which links on Ubuntu runners, would record the ...a6 and ...7366 values. A Wine run of the mingw executable differs from Linux x64 on math.transcendental-bits only, so its NaN bits follow the x86-64 host and its last-bit results follow the C library it uses.


Serving Studio Locally

drift serve serves the Studio web build to a browser on the same machine, so the Atlas viewer needs no hosting step. Its options are --dir, --port, --tls with --cert and --key, --mkcert, --domain (repeatable), --mdns, --open, --unsafe-bind-all and --quiet. It answers GET and HEAD for files under one directory and nothing else. The server core, request parser and static file rules are common code; the sockets are a JVM actual, a POSIX actual for macOS and Linux and a Winsock actual for mingw.

The loopback, Host, path, header and exit checks below were run on the macOS release executable of 2026-10-07 (port 18771). They were not repeated on the build of this report, and the serve tests in cli run on every build. Further variants named in them were tried earlier on the same executable.

Where the build comes from. --dir names a directory with an index.html. Without it drift serve looks for studio beside the executable, then for ../share/drift/studio, and exits 1 with both paths if neither exists. ./gradlew :cli:stageStudio runs the web distribution and copies it, without source maps, to cli/build/serve/studio (12 MB). drift install does not copy it. Embedding the build in the executable was measured and rejected: a gzip of the 12.4 MB build stored as a base64 Kotlin string grew a trivial macOS arm64 executable from 493,368 to 12,588,056 bytes (+12.1 MB), and the raw build as base64 grew it to 33,913,192 bytes (+33.4 MB), because Kotlin/Native stores strings as UTF-16. Drift is 6.7 MB without the build. That closes one mechanism. Embedding the bytes without string overhead (a linker section, or a C array through cinterop) was not tried and would be per-operating-system work; no size is claimed for it.

TLS. Browsers treat http://localhost as a secure context and Studio loads over it in Chromium, Firefox and WebKit, so HTTPS is optional. The JVM build uses javax.net.ssl.SSLContext with an unencrypted PKCS#8 PEM key. The macOS and Linux native builds dlopen the system OpenSSL at run time (3 or 1.1; macOS's own LibreSSL is not used) through 17 libssl and libcrypto symbols, with TLS 1.2 as the minimum and no link-time dependency, so nothing is bundled and the executable does not grow. A host with no usable OpenSSL gets "this build cannot serve https" and exit 1, never a silent downgrade to HTTP; that path is covered by a test with a fake system, not by running without OpenSSL. The Windows build has no TLS library, refuses --tls and --cert with exit 1 (checked under Wine), and refuses --mdns with exit 2. Ktor's server on Kotlin/Native cannot do TLS without a reverse proxy according to its documentation (read through a fetch summary, not in full), so it was not built or added.

With a scratch certificate authority, curl --cacert returned 200 with verification passing on the JVM, on macOS Native (OpenSSL from Homebrew) and on Linux x64 Native (Ubuntu 24.04, OpenSSL 3.0.13); without the CA curl failed, a TLS 1.1-only client failed, TLS 1.2 passed, and plain HTTP to the TLS port failed. In Playwright on Linux, Chromium and WebKit loaded https://localhost with the CA in the system store and no ignore flag; Firefox trusted it only through a profile cert9.db holding the CA, not through the system store or a policies.json. Chromium and WebKit also loaded Studio over https://drift.studio:8443, resolved by a container's /etc/hosts.

mkcert and names. --mkcert runs mkcert -CAROOT and continues only if that directory already holds a CA. It then writes a leaf certificate for localhost, 127.0.0.1, ::1 and each --domain into Drift's configuration directory (key mode 0600, directory 0700). It never runs mkcert -install, because that edits the system trust store; with no CA installed it prints the commands and serves plain HTTP. mkcert 1.4.4 exits without a certificate when JAVA_HOME points at a JDK without lib/security/cacerts, so on Unix Drift starts it through env -u JAVA_HOME. The path with the CA installed was not run on a real machine; a test covers it with a fake mkcert. --domain prints, for each name, whether it resolves to loopback, the exact /etc/hosts line and the mkcert command, and never edits /etc/hosts. --mdns starts dns-sd (macOS) or avahi-publish for the first .local name: on the Mac dns-sd -G v4 drift.local returned 127.0.0.1 and curl got 200, and after SIGINT no process and no record remained. While it runs, other devices on the network can see a drift.local record that points at their own loopback.

Linux test runs. Running the Linux native tests in a container found two bugs in the native serve code, both fixed. kill(pid, 0) succeeds for a zombie, so a child whose program failed to exec, under a PID 1 that never reaps, counted as alive and stop waited the full 2 s; PosixServe.kt now also reads the state in /proc/<pid>/stat (processAlive). And when an untrusted client hung up during the handshake, the write to the closed socket raised SIGPIPE and killed the test process (exit 141); listen now ignores SIGPIPE, which only the CLI command did before. The https test runs for real when a libssl, openssl and curl are present (it ran with OpenSSL 3.0.13 and 3.5.5), and prints its reason and returns when one is absent. Not verified: that the GitHub runner image ships libssl.so.3 by name; its readme lists OpenSSL 3.0.13 (read through a fetch summary), and a 24.04.5 container with that OpenSSL passed.

Tests and gaps. The serve tests (test classes with Serve in their name) number 51 on the JVM, 50 on macOS Native and 37 on Wasm. Under Wine, drift.exe serve --dir served / and the Wasm file with the right MIME type, refused traversal and a bad Host, and printed stopped on SIGINT. Not run: real Windows, Safari, --open with a real browser launch (a fake runner only), the Linux arm64 and macOS x64 executables, and a Linux or macOS host with no OpenSSL.


Distribution

CLI archives. .github/release/stage.sh archive packs an executable with LICENSE, THIRD-PARTY-NOTICES.txt and a README.txt into drift-<version>-<os>-<arch>.tar.gz (.zip for Windows), flat, with a .sha256 sidecar; stage.sh sums checks every sidecar and writes SHA256SUMS. Run on the current executables for this report:

archive bytes
drift-1.0.0-macos-arm64.tar.gz 2,645,407
drift-1.0.0-linux-x64.tar.gz 2,793,616
drift-1.0.0-linux-arm64.tar.gz 2,458,436
drift-1.0.0-windows-x64.zip 2,781,268

shasum -a 256 -c SHA256SUMS passed for all four, and the macOS archive extracted and printed drift version 1.0.0 (macos). There is no macOS x64 archive: the build has no macosX64 target.

drift install and drift uninstall. drift install [--user | --global] [--dir PATH] [--no-modify-path] [--dry-run] copies the running executable (native builds only; the JVM and Wasm builds exit 2) and records what it changed in a receipt, install-receipt.json. The default scope is --user unless the process is privileged. User directories are $XDG_BIN_HOME (only if absolute) or ~/.local/bin, and %LOCALAPPDATA%\Programs\Drift on Windows; global directories are /usr/local/bin and %ProgramFiles%\Drift. --global never calls sudo: when the target is not writable it exits 1 and prints the exact sudo command. The binary is written to <target>.new and renamed, and identical bytes are not rewritten. On Unix, a user-scope install whose directory is not on PATH appends a marked # >>> drift >>> block to the profile files that exist (.profile, .bash_profile, .bashrc, a zsh file, or a fish conf.d file); on Windows it appends the directory to the Path value in HKCU\Environment (or the machine key for --global), keeping the value's type, without expanding variables, and broadcasts WM_SETTINGCHANGE. drift uninstall reverses the recorded edits in reverse order, removes the binary and the directories it created if empty, and then the receipt; with no receipt it says so and removes nothing. --dry-run prints every action and changes nothing, and two dry runs on the Mac were byte-identical.

Run for this report, on the macOS release executable in a scratch home directory (tools/test-installers.sh): 33 checks passed and none failed. They cover install, a second install that changes nothing, uninstall restoring every byte of the home directory (profile checksums equal the "before" values), a corrupted archive refused as a checksum mismatch with nothing changed, no network, --no-modify-path, and conflicting flags exiting 2. The same script passed 33 of 33 in a Debian 12 container as a non-root user. install.sh (POSIX sh) maps uname to an asset, refuses musl (on Alpine 3.21 it exits 1 with "built for glibc and this system uses musl"), checks the SHA-256 sidecar, runs --version before handing over to drift install, and passes shellcheck clean; InstallScriptTest runs it hermetically (19 tests, with a fake binary and a file:// release). install.ps1 parses with 0 errors under PowerShell 7.4.2, has no PSScriptAnalyzer findings including the Windows PowerShell 5.1 syntax rule, and a mocked run passed 16 of 16. The Windows registry logic ran under Wine (not Windows): 10 of 10 comparisons of the registry and file tree before and after matched, including a REG_EXPAND_SZ value with unexpanded variables, a missing Path, and an uninstall of a running binary.

Not verified: real Windows (GetModuleFileNameW, the elevation check, the effect of the broadcast on Explorer, a detached cleanup after exit), Windows PowerShell 5.1 execution, a real download from GitHub or the /releases/latest redirect against github.com (a local server stood in), macOS Intel and Rosetta, and a read-only or full disk. Clikt's own parse errors exit 1 here as in every other command, and Drift's own usage errors exit 2.

Studio installers. studio/build.gradle.kts configures jpackage through the Compose plugin; version, app name and icons come from the build.

asset bytes built on tested
Drift-1.0.0.dmg 74,624,330 this Mac mounted read-only; a copy launched and stayed alive 20 s
Drift-1.0.0.pkg 70,015,031 this Mac expanded and listed, never installed
drift_1.0.0_amd64.deb 58,793,038 Linux container dpkg -i, desktop file validated, ran 18 s under xvfb, dpkg -r
drift-1.0.0-linux-x64.rpm 68,836,245 Linux container dnf install, rpm -V, rpm -e in Fedora
drift-1.0.0-linux-x64.AppImage 66,243,064 Linux container extract-and-run 18 s under xvfb
drift-1.0.0-android.apk 7,203,057 this Mac installed and launched on an emulator
.msi, .exe not built configured only; need WiX on a Windows runner

These sizes are from the builds of 2026-10-07 and were not rebuilt for this tree. The Studio sources have changed since (the design tokens, the Home and Case tabs, the Upgrade, Contract and Bisect screens, the third-party notices), so the packages would differ. Nothing is signed or notarized. The macOS app has an ad-hoc signature and Gatekeeper's spctl reports it as rejected; the APK is signed with the build machine's debug key, so a sideloaded update over a build from another machine needs an uninstall first. The deb and rpm are desktop packages: in a bare container dpkg -i and dnf install failed in jpackage's postinstall script until the desktop and MIME directories existed, as they do on a desktop. The .driftcase file association is configured and Studio shows an opened case on its Case tab; a double click was run on macOS from the createDistributable app only (see Open in ADVANCED_USAGE.md). The iOS app is a simulator build; no .ipa is made. Windows installers, per-machine install with a UAC prompt, upgrade UUID behavior and SmartScreen were not tested.

Homebrew and Chocolatey. packaging/ holds the templates; ./gradlew renderPackaging -Pdrift.sums=SHA256SUMS copies them to build/packaging and fills @version@, @sha256:<suffix>@ (the digest of drift-<version>-<suffix> in SHA256SUMS) and @license@, and fails when a token names an asset missing from the sums file. Ten build-core tests cover it, and a render on the Mac and one in an Ubuntu container were byte-identical.

The release workflow. release.yml builds the four CLI archives, the Studio packages and the APK, adds the sidecars, aggregates SHA256SUMS, attests each build job's files, publishes the GitHub release, and then runs the homebrew and chocolatey jobs when no suffix is given. They need two repository secrets, HOMEBREW_TAP_TOKEN and CHOCOLATEY_API_KEY, and fail early when one is empty on a real run. dry-run builds and checks everything and uploads the results as artifacts without attesting or pushing. stage.sh, the asset wait script and the render task were exercised by hand, and the steps of the Homebrew job ran from a fresh clone on the Mac and in a container. No job has run on GitHub, and no Windows machine was used for any step.


Performance

tools/perf measures start-up, capture, diagnose, peak memory and size. A manifest (tools/perf/manifest.yml, 13 steps) fixes the workloads: drift --version, drift capture --label perf and drift diagnose on the two drangler capsules. perf.sh runs each workload 3 times to warm up and then 15 times, and measure.py reads wall time and peak resident memory from os.wait4 rather than /usr/bin/time; summarize.py writes the table. The numbers below were recorded on 2026-10-12 and are the median with the minimum and maximum over the 15 runs, in milliseconds. They are in tools/perf/results, and they were not repeated on the build of this report. That build is larger: the macOS executable measured at 7.23 MB then is 9,866,776 bytes now, because the catalog, the scanners and the commands grew, so start-up times on the current build are not known.

host build --version capture diagnose peak RSS (diagnose) executable load1 at start
Mac JVM 346.6 (330.6-707.5) 163.5 (155.0-180.0) 179.7 (171.7-223.0) 79.6 MB 0.29 MB launcher 5.7
Mac Native 7.5 (6.9-9.6) 24.3 (22.2-27.0) 9.0 (8.0-10.2) 13.0 MB 7.23 MB 5.7
Mac Wasm n/a n/a n/a n/a 12.07 MB wasm, 4.40 MB gzip 5.9
paisley-park, 8 CPUs JVM 135.4 (133.5-142.0) 100.1 (98.8-105.0) 120.1 (118.2-135.6) 73.0 MB 0.29 MB launcher 0.2
paisley-park, 8 CPUs Native, built there 1.6 (1.6-2.0) 2.7 (2.6-3.3) 2.5 (2.5-3.6) 16.8 MB 7.69 MB 0.8
paisley-park, 8 CPUs Native, cross-built on the Mac 1.7 (1.6-2.1) 2.8 (2.6-3.2) 2.6 (2.6-3.1) 17.4 MB 7.70 MB 0.1
paisley-park, 2 CPUs JVM 135.0 (131.9-321.6) 101.2 (99.8-104.5) 124.9 (122.2-139.3) 71.4 MB 0.29 MB launcher 0.3
paisley-park, 2 CPUs Native, built there 1.7 (1.6-2.7) 2.7 (2.6-3.2) 2.7 (2.6-3.6) 16.9 MB 7.69 MB 0.7
paisley-park, 2 CPUs Native, cross-built on the Mac 1.7 (1.6-2.6) 2.7 (2.6-3.4) 2.6 (2.6-3.2) 17.4 MB 7.70 MB 0.2

Machines and load. The Mac is an Apple M2 Pro with 16 GB and JDK 25 (Temurin). Its runs started at a one-minute load of 5.7 to 5.9 from other builds and applications, so they are not quiet-machine numbers; an earlier pass at load 8.8 gave a JVM --version median of 343.8 ms against 346.6 here, which is within the spread. paisley-park is an x86-64 Linux host with 24 CPUs. Its runs were in containers capped at 8 CPUs and at 2 CPUs (--memory 10g), at a load below 1. The Mac and paisley-park rows compare configurations and not hardware: a container on a different CPU family and operating system measures nothing about the Mac.

Cross-built against native-built. The Linux x64 executable linked on the Mac and the one built inside a paisley-park container run within 0.1 ms of each other on every workload, at 8 and at 2 CPUs. The two binaries differ by 776 bytes and are not byte-identical. Two CPUs make no difference to the native build, which is single-threaded. The JVM column is dominated by JVM start-up, about 135 ms for --version on paisley-park; the native build is about 85 times faster there and 46 times faster on the Mac.

Wasm. The CLI has no Wasm executable, so Wasm is measured by size only: the Studio bundle's .wasm is 12.07 MB, 4.40 MB gzipped. No Wasm timing was taken.

Not measured. Windows and macOS Intel executables, a Linux arm64 executable on arm64 hardware, a cold file cache, Studio's load time and memory, the Lab's container build time, and the time of triage and upgrade-check (added after the sweep). Every cell is one session on one machine, so the min-max shows noise within a session and not between days.


DriftBench

DriftBench scores a diagnosis against a known cause. A scenario is a pair of containers, green and red, running one small program. The red arm fails at a declared rate and the green arm passes. The injected cause is ground truth by construction, because the scenario was built around it, so the benchmark measures how well a ranking recovers a cause that was put there. It is synthetic and every scenario and result file says so ("synthetic": true).

Scenarios. 135 template ids in 16 YAML files (bench/scenarios/*.yml, one per dimension and migration.yml with four templates for runtime upgrades) produce 272 scenarios. The first set had 80 scenarios from 43 templates; the dev measurements labelled 45 or 49 scenarios come from that set and its earlier versions. A template names a dimension, a program in sh, python3, node or java, the failure signature, the run configuration of each arm (image, environment, memory, CPUs, cpuset, ulimits, network, files), and the cause with optional bundle members and decoys. A seeded SplitMix64 generator (seed 20261006) picks variants and draws values. The class of a scenario is derived: control (no cause), intermittent (red rate under 1,000 permille), bundle, decoy, else single. The 272 split into single 111, bundle 92, decoy 28, intermittent 21 and control 20.

Intermittency. The harness arms the fault on trial i when a fixed integer hash of i and the scenario salt is under the gate, and the program fails only when the fault is armed and the injected cause is present. The failing trials are therefore known before Docker runs, and a flaky scenario is a deterministic pattern with a declared rate. No scenario contains a real race.

Causes a container cannot vary. Kernel release, clocksource, CPU model and browser engine run one configuration in both arms; the cause values exist as capsule-overrides written into the saved capsules, marked bench-synthetic. 10 scenarios are of this kind (controllable: false). Separately, 15 scenarios mark the cause visible: false, because the capsule has no attribute for it yet (open file descriptor limit, file size limit, cpu count under a cpuset, hosts file, lockfile pins, shell flavor, npm version). Those scenarios measure whether a diagnoser abstains.

Validation. A scenario is valid when both arms finish, the green arm passes, every red failure carries the declared signature (text or exit code), the failing trials equal the prediction from the hash gate, each arm's failure count passes an exact two-sided binomial test against the declared rate (each tail at least 0.0005, BigInteger), and the cause path appears in the capsule diff exactly when visible says so. A scenario that fails is listed in data/dropped.json and left out; its program is fixed, and no tolerance is loosened.

The 49 dev scenarios then in use ran in Docker on a Linux host, capsules captured with the Linux release executable (capture --tools) inside each arm:

quantity value
dev scenarios validated 49 of 49; data/dropped.json is []
trials run, both arms 2,580
red-arm failures 978
green-arm failures 0 in every scenario
scenarios whose failing trials equal the prediction 49
scenarios with an overridden capsule value 5
scenarios with an invisible cause 11

The four migration dev scenarios (PHP 7.4 and 8.3, Node 18 and 22, JDK 17 and 21, Python 3.11 and 3.13) ran on a Mac under Docker Desktop (linux/arm64) with the Linux arm64 executable inside each arm; the others ran on a Linux host. The first run had one drop, build-npm-sbom-plain-1, whose program sent npm's error message to /dev/null, so the red arm failed without the declared signature (green 0 of 20, red 20 of 20). The program was fixed and the batch rerun; the final run has no drops.

Sealed test split. The split in bench/data/split.json has 122 dev and 150 test scenarios, 272 in all. A scenario is dev when the first four bytes of sha256(id), read as an unsigned integer, mod 100 are under 60; a template outside LEGACY_TEMPLATES whose own id fails that test is held out with test instances only (the rule field). The test list is sealed: the SHA-256 of the sorted test ids, each followed by a newline, is aea538143b9ca524864692d92e64c0ae95e0c5c42415cf4864be0236469f8b13, committed in data/split.json and pinned in CommittedDataTest. The earlier 31-scenario list had the hash 457097f8...; the split was regenerated on 2026-10-15 (commit 9bb18240). prepare refuses a test id, the calibration harness refuses it (SealedException), and a test asserts no result or capsule file exists for a test id. The test scenarios were never executed, run through the ranker or scored. They are validated only through the dev sibling instances of the same templates. The test-list hash was recomputed from bench/data/scenarios.json for this report and matches; the per-id rule alone does not reproduce the split (see Reproducing the Numbers).

Template ids were renamed (suffixes such as -basic) until every template had at least one dev instance and the dev share was near 60%; it was 59.2% (45 of 76) before the four migration scenarios, which all fall in dev by the hash rule, and was 61.3% (49 of 80) on the 80-scenario set and is 44.9% (122 of 272) in the current split. The sealed list changed when the split was regenerated (see Sealed test split). Only the hash rule was used, and no measurement.

Sealed-run protocol. The test set is run once. bench freeze records the engine commit, the SHA-256 of the embedded rule catalog, fit.json, evaluation.json, scenarios.json and split.json, and the held-out template list in bench/data/freeze.json. bench unseal runs only when each recorded value matches the working tree and the ranker still reproduces both calibration files; otherwise it exits 1 and names the fields that differ. A successful unseal appends one line (timestamp, commit, operator) to bench/data/unseal.log, after which freeze and a second unseal refuse; prepare --test and a Lab opened with the unsealed ids re-check the freeze. The check does not hash engine source, so an engine change that leaves the dev ranking and the calibration files byte-identical passes, and the logged commit is the record of what ran. Templates added after the first 43 are held out when sha256(template id) falls in the test bucket and then have test instances only, which gives three strata: dev, test-sibling (a test scenario whose template has dev instances) and test-novel (a held-out template that never ran). A template has at most three instances and the analysis clusters by template. Template authors do not read rules/ and rule authors do not read bench/scenarios/; that separation is an instruction to the writers, not something the build checks. The result is the single run at the frozen commit, reported as it comes out; a null result is the result, and later fixes are reported beside it. UnsealTest and FreezeTest exercise the protocol on a scratch copy of bench/data. No freeze has run on the current split: bench/data/freeze.json and bench/data/unseal.log are absent from the tree.


Evaluation and Limits

Calibration on the Dev Split

The bench harness (Observe.kt, Evaluate.kt) runs the real Ranker on the capsule pair of each dev scenario and compares the result with the injected cause. The figures in this paragraph are from the 49 dev scenarios then in use; the current split has 122 (see Complete dev set). The input that matters is the probed pair: the passing and the failing baseline that solve captured inside its containers with capture --probes (71 probes each, stored in bench/data/probed/). 45 scenarios have a cause (4 are controls). On the probed pairs the ranker produced 99 candidates, 29 of them the cause. In 14 of the 49 scenarios a probe differs between the two sides, which links 20 candidates to a probe that reads their attribute. Only four probes ever differ in these pairs (resources.locale-env in 7 scenarios, resources.cgroup-memory in 6, process.timezone in 5, resources.cgroup-cpu in 4), and each reads exactly the attribute that changed (env.LANG, cgroup.memory.max, env.TZ, cgroup.cpu.max). Seven scenarios now match a catalog rule (runtime-java-virtual-basic-1, compiler-c2x-plain-2, os-distro-family-basic-1, os-glibc-floor-1 and three of the four migration scenarios). None matched one when the catalog held 32 rules, so an earlier version of this report said that no dev scenario matches a rule; that sentence is false for the current catalog of 296. Two comparison inputs share the harness: the same pairs with the probes removed, which gives a paired measurement of what probes do, and the original capsules the scenarios were validated with (no probes, 106 candidates, 33 of them the cause). The probed pairs come from the reproduce containers, so the five scenarios whose cause a container cannot set (kernel, CPU model, browser engine) have no candidates in them; the original capsules keep those.

Folds are the first four bytes of sha256("fold:" + id) mod 5. A model replaces the shipped one only if its out-of-fold Brier score is at least 2% lower and its out-of-fold MRR is not lower, and the Brier score is taken over the rankings that print a probability (those whose candidates all rest on attribute changes, 79 candidates in 33 rankings).

The calibration files were regenerated on 2026-10-13 after the dev split grew from 45 to 49 scenarios, and again on 2026-10-15 (commit 9bb18240) when the split became 122 dev and 150 test (four migration scenarios, see DriftBench) and the catalog from 32 to 296 rules. The weights table did not change, and selection.chosen is still hand-set+softmax-none. evaluation.json is 4effe497...e374 and fit.json is fbbb6f45...4a31; CalibrationTest regenerates both and compares bytes.

Complete dev set. The committed calibration files now cover 115 of the 122 dev scenarios, the ones that have probed captures; 105 of the 115 have a cause and 10 are controls. The other seven (build-checksum-pin-1 and -2, build-version-pin-1 and -2, network-hosts-stale-1 and -2, network-nsswitch-order-1) read a file the harness mounts, which a container built from a capsule does not have, so solve cannot capture them. Lab.UNPROBED lists them and the probed lab leaves them out of every section, including original. evaluation.json is dfdddafb...283b and fit.json is a050d59c...97a6, and two regenerations gave identical bytes. The paragraphs from here to the end of this section describe the earlier run on 49 scenarios, so their figures differ from the ones below.

order, probed input, 105 scenarios with a cause top-1 top-3 MRR P, top-1 / top-3
random (expected) 36.2 (0.344) 57.0 (0.543) 0.457
diff order, alphabetical 43 of 105 58 of 105 0.499
dimension prior 50 of 105 67 of 105 0.553
rules only 48 of 105 65 of 105 0.543
hand-set (shipped) 46 of 105 (0.35 to 0.53) 67 of 105 (0.54 to 0.72) 0.532 0.0012 / 0.00002
hand-set, probes removed 48 of 105 67 of 105 0.543 0.0001 / 0.00002
hand-set, original capsules 72 of 105 97 of 105 0.801 0.00039 / 0.00003

The shipped order does not beat the dimension prior. In the template-clustered bootstrap (10,000 resamples, seed 20261006, 77 templates), shipped minus dimension prior is -0.038 at top-1 (-0.101 to 0.019), 0.000 at top-3 (-0.028 to 0.029) and -0.021 in MRR (-0.060 to 0.014). Against alphabetical order the top-1 gain is 0.029 (-0.110 to 0.165), so its interval includes zero, and the top-3 gain is 0.086 (0.000 to 0.180). Probes still help no scenario: the same two scenarios move down (env-pool-size-decoy-2, migration-python-copy-1), and top-1 is 48 without probes and 46 with them.

Tiers of the shipped ranker: difference holds 271 candidates and 39 are the cause (0.14; 0.11 to 0.19), plausible 62 and 22 (0.35; 0.25 to 0.48), known 13 and 10 (0.77; 0.50 to 0.92). A plausible or known top candidate is wrong in 25 of 115 scenarios (0.15 to 0.30), 43 of 75 confident candidates are wrong (0.46 to 0.68), and 4 of the 10 controls get a confident candidate. Of 27 bundle scenarios with a ranked cause, 15 put it inside a bundle (0.37 to 0.72).

The selection rule now picks hand-set+softmax+isotonic. Out of fold, on the 79 candidates of rankings that print a probability, it scores Brier 0.170 against 0.184 for the previous hand-set+softmax-none readout and 0.173 for a uniform guess. The gain over the previous readout is 7.9% with equal MRR, which passes the 2% rule. The previous readout reads worse than a uniform guess on these rankings, where on 49 scenarios it read better (0.166 against 0.179). core now ships the chosen readout: Weights.NONE is 1.0, because the model fits no none weight (the previous readout would fit 0.5 on the 115), and Weights.map holds the eight steps of fit.json calibration. The order is untouched, and top-1, top-3 and MRR are the same for every model that keeps the hand-set order.

Evidence classes on the complete set (evidence section of evaluation.json, labelled complete, which counts the 115 scenarios with captures): the evidence ranker gets top-1 48 of 105 (0.457), top-3 67 and MRR 0.542, against 50, 67 and 0.553 for the dimension prior, so it does not beat the prior. Shipped minus evidence ranker at top-1 is -0.019 (-0.061 to 0.000). plausible is right for 11 of 12 candidates (0.92; 0.65 to 0.99), known is empty, and correlated holds 63 candidates of which 21 are the cause (0.33). One scenario of 115 has a confident wrong top candidate (shipped: 25), and none of the 10 controls has a confident candidate. The top-1 gains are memory-oom-decoy-1 and -2, with no losses. Trials to a verdict stay pending, because the repository holds no dev solve certificates.

Order of the hand-set ranker on the 45 scenarios with a cause (Wilson 95% intervals; "P" is the exact chance that a random order does at least as well, one-sided, not corrected for the three inputs):

input order top-1 top-3 MRR P, top-1 / top-3
original, no probes random (expected) 0.440 0.665 0.554
diff order, alphabetical 19 of 45 (0.29 to 0.57) 29 of 45 (0.50 to 0.77) 0.549
hand-set 26 of 45 (0.43 to 0.71) 33 of 45 (0.59 to 0.84) 0.652 0.003 / 0.012
probes removed random (expected) 0.362 0.575 0.470
diff order 15 of 45 (0.21 to 0.48) 24 of 45 (0.39 to 0.67) 0.449
hand-set 23 of 45 (0.37 to 0.65) 29 of 45 (0.50 to 0.77) 0.574 0.001 / 0.009
probed random (expected) 0.362 0.575 0.470
diff order 15 of 45 (0.21 to 0.48) 24 of 45 (0.39 to 0.67) 0.449
hand-set (shipped) 21 of 45 (0.33 to 0.61) 29 of 45 (0.50 to 0.77) 0.548 0.018 / 0.009

The control arm of this evaluation is the three baselines (random order, alphabetical diff order, the logistic reading of the score). The ranker is deterministic, so the only spread is the fold split. The previous measurement, on 41 scenarios and 32 rules, found 19 of 41 at top-1 (P = 0.034) and 24 of 41 at top-3 against 23.1 expected (P = 0.37), and this report concluded that the order at top-3 could not be told from chance. That conclusion is refuted by the regenerated data: 29 hits against 25.9 expected at top-3 (P = 0.009), and 21 against 16.3 at top-1 (P = 0.018). The top of the order is better than a random order, and the gain over alphabetical order is 6 scenarios at top-1 (21 against 15) and 5 at top-3 (29 against 24). The change comes from the data and not from the weights: four scenarios were added, the catalog grew, and seven scenarios now carry a rule match. Measured on the 45 scenarios with a cause, the shipped order is still no better with probes (21 at top-1) than without them (23).

Probes. Probes change the order of two scenarios, both for the worse. In env-pool-size-decoy-2 the cause env.DB_POOL_SIZE ranks first without probes and third with them, because its two decoys env.LANG and env.TZ really changed, the locale probes that read them differ, and so the decoys reach plausible while the cause stays at difference. In migration-python-copy-1 the cause ranks first without probes and second with them. Probes help no scenario. A probe says that an attribute it reads behaved differently. It cannot say that the attribute caused this failure, and the dev scenarios inject decoys that are real differences, so a probe agrees with them as much as with the cause. In a scenario with one candidate there is nothing to reorder. The tier is compared before the score, so no probe weight can change the order (noProbeRatioChangesTheOrderBecauseTheTierComesFirst).

Probes were first linked to candidates through their dimension. They are now linked through the attribute path they read (ProbeLinks, see Ranking and Bundles). On the 45 scenarios and 32 rules then in use the two rules gave the same dev results: every ranking, tier, top-1, top-3, MRR and false-confidence rate was identical before and after (84 model blocks of evaluation.json compared, fit.json byte-identical), and the new rule demoted none of the 19 probe-linked candidates. That comparison was not repeated on the current catalog and the committed files have since changed, so it stands as a record of the earlier measurement. The change removes an unsound rule, and the tier counts below were measured with it in place.

False confidence. With neither probes nor rules, nothing reaches plausible or known, so the rate of a confident wrong answer is 0 by construction. With them it is not 0.

quantity value
candidates by tier difference 71, plausible 25, known 3
candidates that are the cause, by tier difference 15 of 71 (0.21), plausible 11 of 25 (0.44), known 3 of 3
scenarios whose top candidate is plausible or known and wrong 10 of 49 (Wilson 0.11 to 0.34)
confident candidates (plausible or known) that are wrong 14 of 28 (0.33 to 0.67); 14 of 25 plausible candidates
controls with a confident candidate 1 of 4 (control-locale-noise-fixed-1, whose decoy TZ does differ and the timezone probe sees it)

The three known candidates come from a matched catalog rule, and the cause rate of 3 of 3 says only that those rules agreed with the injected cause. Their interval, 0.44 to 1.00, is wide. The intervals for difference and plausible overlap, so the data does not show that plausible is better evidence than difference. In plain terms, the plausible tier is not reliable on this benchmark. More than half of what it names is wrong, and a user reading "plausible" as "probably right" would be misled about as often as not. The wrong plausible candidates are decoys that changed, and the probe is right about them: it separates attributes that behaved differently from attributes that did not, and does not separate a cause from a bystander. Only a link through the symptom (the probe whose output relates to the failure) could, and the Evidence Classes paragraph below measures it. The ranking still prints the tier and the mechanism that earned it, and prints no probability for it.

Re-weighting is refuted. The probe, rule and spectrum weights were refit out of fold and none replaced the hand-set value.

The weights stay hand-set. This refutes one mechanism, that a fitted weight improves the order or the printed probability on this benchmark: the one probability gain, an isotonic map, sits on the hand-set weights. It does not close the objective of weighing probe evidence. A probe linked through the symptom it relates to is measured under Evidence Classes. The data above says only that probes linked through the attribute they read carry nothing beyond the attribute change.

Evidence classes. Ranker.rank takes an optional observed failure (the symptoms read from a log). With one, a probe or a rule match is mechanism-level only when its symptom matches it: a probe through the phrase table in ProbeSymptoms, a rule when a distinctive token of one of its symptoms appears in the failure. Anything else is observational, carries ratio 1 and lifts a candidate to correlated at most. Without a failure the output is unchanged, and no command passes one yet. On the 49 dev scenarios that have probed captures (partial: 73 of the 122 dev ids have none yet), with the red arm's last failure line as the failure, top-1 moves from 21 to 23 of 45 (paired gain 0.044, percentile interval 0 to 0.133) and top-3 stays at 29, against 23 and 28 for the dimension prior alone. That is a tie with the prior at top-1 and one scenario at top-3, so the result does not beat the prior. plausible is right for 9 of 10 candidates (shipped: 11 of 25) and 1 of 49 scenarios has a confident wrong top candidate (shipped: 10). The three known candidates fall to correlated: their rules describe a date-time parse failure and a loose string comparison, which are not the failures observed, so those three hits were a version crossing and not a mechanism. Both top-1 gains (memory-oom-decoy-1 and -2) come from exit code 137 reading as the memory probe's symptom, and the phrase table was written after the dev signatures had been seen, so the numbers are not blind. Prose-only rule symptoms never match, and no dev scenario has a matching rule symptom, so rule matching is untested. Trials to a verdict from solve are pending: the repository holds no dev solve certificates, and Solve ranks with the unconditioned ranker.

Probability readout. Models on the rankings that print a probability (79 candidates in 33 rankings), Brier score out of fold unless stated:

model Brier
uniform 1/n 0.173
hand-set, p = logistic(score) 0.341
constant base rate 0.245
softmax of the scores, none weight 1 0.185
softmax and a fitted none weight (previous readout; fit 0.5), in sample 0.181
softmax and a fitted none weight (previous readout) 0.184
softmax and an isotonic map (shipped), in sample 0.159
softmax and an isotonic map (shipped) 0.170
other isotonic and fitted-prior variants 0.173 to 0.264

The shipped readout is 2% below a uniform guess on those rankings (0.170 against 0.173) and 8% below the previous readout (0.184). The Brier score of the chosen model runs from 0.139 to 0.245 across the five folds, so the gap to "one of n" is much smaller than the spread between folds, and this benchmark does not show that the readout beats saying one in n. The logistic reading, the base rate, the isotonic maps over raw log-odds and the softmax with a fitted none weight all lost to it. On all rankings it reads 0.113 out of fold against 0.124 for uniform, where the map is also applied to rankings that print nothing. Reliability, out of fold, in five bins of width 0.2 (candidates, mean predicted, observed causes): 30, 0.135, 2; 35, 0.338, 14; none; 14, 0.681, 11; none. The map has eight steps, so three bins are occupied. Candidates in the lowest bin were the cause less often than predicted (2 of 30 against 0.135) and in the highest occupied bin more often (11 of 14 against 0.681). The map is fit on all 115 dev scenarios, 44 of which have no listed cause (10 controls and 34 whose cause the ranking cannot list), so it says nothing about how often real failures have no listed cause. Of the 33 rankings that print a probability, 32 hold five or fewer candidates and one holds 15. On the drangler pair all nine probabilities read 0.104478 and add up to 0.940, a claim of 94% that one of nine changes is the cause, which the case data does not support. The probability stays attribute-only.

Other facts of the shipped model on the probed pairs, out of fold. Top-1 by class: single 5 of 15, bundle 8 of 12, decoy 3 of 9, intermittent 5 of 9. The cause ranks above every decoy in 3 of 7 decoy scenarios (4 of 7 without probes). In the 11 bundle-class scenarios with a ranked cause, 6 put it inside a bundle (0.28 to 0.79, down from 8 of 8 on the earlier data); in the other classes the cause shares a bundle in 4 of 18. The 5 manual scenarios have no candidates at all in the probed pairs. Reversing green and red on the probed pairs gives 11 candidate rule matches in 5 scenarios, 7 printed known and 24 plausible; no inferred-basis rule printed known, and the reversed pairs have no ground truth, so the accuracy of those tiers is unmeasured.

Fold assignment is by id hash, so sibling instances of one template can land in different folds, which makes the out-of-fold figures optimistic. The shipped map was chosen among ten readouts on the same scenarios and is then fit on all of them, so cross-validation protects each fit but not the choice of readout, and no data was held out for the shipped values. The probed run is a second measurement on the same 49 scenarios, not independent data.

The Lab on the Dev Split

All 45 dev scenarios ran through drift solve on a Linux host with Docker (this first run is described here; three repeats follow), using the Linux release executable inside each arm to capture its capsule. The sealed test split was never run. Per scenario, --run names the scenario's toolchain in its first word and --fail-when is the scenario's signature (or exit). The 45 runs used 1,460 trials in total, from 16 trials for a scenario that stopped at the pilot to 76 for the largest bundle. Truth comes from bench/data/scenarios.json. A CONFIRMED verdict counts as correct when its minimal set is exactly the injected cause. A bundle verdict counts as containing the cause when the true path, or one of the scenario's declared bundle members, is among its members.

class (dev) n CONFIRMED, correct CONFIRMED EFFECT (bundle) NARROWED STUCK
single 15 4 0 0 11: 9 not-reproduced, 2 uncontrollable
bundle 9 0 9 0 0
decoy 9 7 0 0 2 not-reproduced
intermittent 8 3 0 1 4: 2 not-reproduced, 1 budget, 1 uncontrollable
control 4 0 0 0 4 not-reproduced
all 45 14 9 1 21

The result is 14 CONFIRMED, all correct; 9 CONFIRMED EFFECT (bundle), all with the cause inside; 1 NARROWED; 21 STUCK (17 not-reproduced, 3 uncontrollable, 1 budget); 0 CONFIRMED on a wrong cause; 0 on a control. Of the 9 bundles, 8 list the true path among their members. The ninth, build-npm-sbom-plain-1, does not: its cause is the npm version, which no capsule attribute shows, and the arms differ in tool.node.version and two environment variables, which the scenario declares as bundle members.

What the 21 STUCK runs say. Of the 17 not-reproduced, 10 have a cause that the capsule cannot show (limits.*, hosts file, lockfile pins, shell flavor, CPU count under a cpuset), 4 are controls, 2 are scenarios whose cause a container cannot set, and 1 is locale-setlocale-2. The run notes attribute that one to reproduce installing the missing locale in the container, so the failing container does not fail; that was not tested again. In all 5 manual scenarios of the dev split, none reached a CONFIRMED verdict (3 uncontrollable, 2 not-reproduced). The four controls ended STUCK because the same fault gate failed both containers equally (5 of 8 against 5 of 8 for control-locale-noise-fixed-1).

A few single runs, from the certificates:

The limit of the first run is easy to read past: each scenario ran once, with one seed. Its 0 false CONFIRMED is a measurement on 45 synthetic scenarios, with 14 CONFIRMED and 4 controls, which gives no power to see a false rate near 1%. The scenarios are synthetic and the arms are reproduce containers. The repeats below add power, with their own limits. The sealed test split was never run.

Dev set solved once. On the current 122-scenario dev split, drift solve ran once on each of 115 scenarios; the other seven are file-mount scenarios that the Lab cannot solve (build-checksum-pin-1/2, build-version-pin-1/2, network-hosts-stale-1/2, network-nsswitch-order-1). Verdicts: 28 CONFIRMED, 21 CONFIRMED EFFECT (bundle), 1 NARROWED, 65 STUCK. All 10 controls end STUCK. The solver's first line gives four reasons for the STUCK verdicts: 25 where the passing side fails as often as the failing side; 25 where the failure does not reproduce in the container built from the failing capsule; 10 where the experiment is void because the treatment capsule does not show the requested os.release.VERSION_ID; and 2 where no trial count up to 30 reaches the target power. Those reasons add to 62, and the record gives none for the other three. One arm image build failed (runtime-r-newer-base-2). Each scenario ran once. Dev Solves Scored below checks the verdicts against the injected causes.

Repeated runs. The same 45 scenarios ran three more times on a Linux host with the Linux x64 release executable, which with the first run makes 180. The scenario harness draws its fault from the trial index with a fixed salt per scenario, so a literal repeat would reproduce the same trial sequence and measure only Docker noise. The three repeats therefore add 1,000,003 times the repeat number to each scenario's salt, which gives independent draws at the same failure rate and changes nothing else. A repeat with the original salts was not run.

run trials solve time CONFIRMED, correct false CONFIRMED
first 1,460 1,553 s 14 0
repeat 1 1,556 1,829 s 15 0
repeat 2 1,514 1,745 s 15 0
repeat 3 1,498 1,889 s 15 0
all four 6,028 7,016 s 59 0

43 of the 45 scenarios had the same verdict class, and the same minimal set where there is one, in all four runs: the 14 CONFIRMED, the 9 bundle verdicts and 21 STUCK stayed where they were. Two intermittent scenarios changed class: cpu-limit-quota-basic-2 (STUCK budget, CONFIRMED cgroup.cpu.max, STUCK budget, CONFIRMED cgroup.cpu.max) and env-retries-flaky-plain-1 (NARROWED, NARROWED, CONFIRMED env.MAX_RETRIES, STUCK budget). In their certificates the 8 failing-baseline trials of the pilot give a different failure-rate estimate in each run, and that estimate sizes the arms: at some estimates no arm of up to 30 trials reaches the target power (STUCK budget), and at others the experiment is SUPPORTED or stays inconclusive at 13 to 21 trials per arm. That the pilot size drives it is inferred from the records, not from reading the code. No control produced a CONFIRMED or a bundle verdict (0 of 16 control runs), no decoy scenario confirmed the decoy (0 of 36 runs), and no bundle verdict lacked the true path or a declared bundle member.

With 0 false CONFIRMED in 180 runs, the exact one-sided 95% upper bound on the per-run rate is 1 - 0.05^(1/180) = 1.65%. For the 16 control runs alone it is 17.1% and for the 36 decoy runs 8.0%. Read the bound per run and not per kind of failure: the 180 runs are 45 scenarios with four draws each, three of the four draws are reseeded and the first is not, the scenarios are synthetic, the arms are reproduce containers, and solve assumes one cause. The 180 certificates are not in the repository; the four archives in fixtures/lab/cases are four of the first 45.

Migration scenarios. The four migration dev scenarios were solved later, on the Mac, with the default budget: the Python and JDK scenarios ended CONFIRMED EFFECT (bundle), the first with four members (env.LANG, env.PYTHON_SHA256, env.PYTHON_VERSION, tool.python.version) and the second with two (env.JAVA_VERSION, tool.java.version), both moving failures from 0 of 5 to 5 of 5. The PHP scenario ended NARROWED because one experiment image could not be built (php:8.3.35-cli-bullseye does not exist), and the Node scenario ended STUCK because the experiment image build failed ([email protected] needs Node 18.17 or newer, and the image has 18.16.0). Both build failures are in solve's image tables and npm pin, not in the benchmark, and were not fixed. The Python template has an intermittent gate that solve does not reproduce (it has no per-trial gate), so its red arm fails every time. The probed captures of these runs feed the calibration above; their verdicts do not.

Dev Solves Scored

bench solves reads the certificate of each of the 115 solved dev cases and compares its verdict with the scenario's injected cause in bench/data/scenarios.json. The output is bench/data/solves/dev.json. The cases are the final solve of each id: 66 from the re-solve with the fixed binaries and 49 from the older scenarios; no id is in both. The seven file-mount scenarios have no case. The numbers below are dev data that the team tuned the engine against, not held out, and each scenario ran once. The certificates are not in the repository, so the score is regenerated from the archived case directories (experiments/dev-solves).

A CONFIRMED verdict is correct when its minimal set is exactly the injected cause. A bundle verdict holds the cause when the cause is among its members, and names it when the minimal set is the cause. On a control, STUCK is correct and any other verdict is a false confirmation.

class n CONFIRMED correct CONFIRMED wrong bundle holds (minimal set names the cause) NARROWED STUCK
single 42 15 0 5 (5) 0 22
bundle 33 0 0 15 (9) 1, misses the cause 17
decoy 19 11 0 0 0 8
intermittent 11 2 0 1 (1) 0 8
control 10 0 0 0 0 10, all correct
all 115 28 0 21 (15) 1 65

All 28 CONFIRMED verdicts name the injected cause: 28 of 28, Wilson 95% interval 87.9% to 100%. No decoy was confirmed, and none of the 10 controls drew a verdict (10 of 10 STUCK, Wilson 72.2% to 100%). With no wrong CONFIRMED there is no case to list. All 21 bundle verdicts hold the cause among their members. In six of them the minimal set names the settable member that travels with the cause: build-npm-sbom-plain-1 (node version for the npm version), os-glibc-floor-1, os-version-gate-2, userland-coreutils-new-flag-1 and -3 (OS release name for libc or coreutils), and userland-cp-update-2 (OS release ID for coreutils). The one NARROWED verdict, build-npm-subcommand-2, names tool.node.version where the cause is tool.npm.version.

The solver resolves 43 of the 105 scenarios that have a cause (41.0%, Wilson 32.0% to 50.5%), counting a CONFIRMED correct or a bundle whose minimal set names the cause. In 49 of 105 (46.7%, 37.4% to 56.2%) some verdict holds the cause. The 10 controls are outside both denominators. On the same 105 scenarios the shipped ranker puts the cause first in 46 (43.8%, 34.7% to 53.4%) and in the top three in 67 (63.8%, 54.3% to 72.4%); ordering the changed attributes by dimension prior alone gives 50 (47.6%, 38.3% to 57.1%) and 67. The ranker and prior numbers are in-sample on the same dev split. Of the 43 resolved scenarios the ranker had the cause first in 30; of the 62 unresolved ones it had it first in 16. As a guesser the solver is no better than the order it follows. Its value is the precision when it speaks, and it speaks for 28 of 105.

The 65 STUCK verdicts are 55 scenarios with a cause and the 10 controls. By the first clause of the certificate's statement, the 55 split into 25 where the failure does not reproduce in the container built from the failing capsule, 15 where the passing configuration fails as often as the failing one, 10 where the experiment is void because the treatment capsule does not show the requested os.release.VERSION_ID (all bundle scenarios), 3 where no trial count up to 30 reaches the target power, and 2 executor failures (one image build, and env-pip-cache-dir-1, whose archived certificate predates its re-solve; the re-solve ended STUCK with 0 of 30 trials failing). By the certificate's own reason field, STUCK counts 44 not-reproduced, 12 executor, 6 uncontrollable and 3 budget across all 65. The injected causes of the 55 are spread over 30 paths. The most common are tool.coreutils.flavor (7), limits.nofile (5), tool.shell.flavor (4), and env.HTTP_PROXY, os.release.ID and tool.cc.version (3 each); most are attributes a container cannot set or mirror.

Trials per case are the sum over the experiment arms in the certificate. CONFIRMED took a median of 44 (range 44 to 112), bundle verdicts 58 (44 to 128), the NARROWED case 86, and STUCK 30 (0 to 60; the 0 is the failed image build).

Two readings follow, with their limits. The 28 of 28 agrees with the earlier repeated runs (59 CONFIRMED, 0 false in 180 runs), and the interval still allows a wrong rate of up to 12% per CONFIRMED verdict. Coverage is the open problem: 62 of 105 scenarios with a cause ended without a verdict that names it. The mechanisms recorded in the certificates are container limits (causes a container cannot set), the void rule on floating image tags, and a gate that fails both arms equally. Closing those is the objective; this run refutes none of them. The scenarios are synthetic, one cause each, and the sealed test split is not scored here.

Coverage after the Lab fixes. Each of the 55 stuck scenarios was read from its certificate, arm Dockerfiles, run scripts and trial output. Seventeen were Drift defects fixed in this round, four were fixed earlier in the tree (the ruby, r and elixir image rows landed after the scored run), three are one open decision, 18 are scenario defects, and 13 are inherent limits.

class scenarios cause
Drift defect, fixed here 10 alpine the void rule fired because the arm used alpine:3.22 (3.22.7 now) for a capsule that records 3.22.6; the Lab now builds the exact tag when the registry publishes it and keeps the experiment void when it does not
Drift defect, fixed here 5 gcc and make the capsule was taken in the gcc image and has python from the distribution, but the base came from the first row in table order, so the arms were python images with no gcc or make; a row whose official image variable is in the capsule (GCC_VERSION) now wins over table order
Drift defect, fixed here 2 env.XDG_CACHE_HOME the XDG_ prefix counted as host identity and the value ~/xdg counted as a host path, so the hypothesis was uncontrollable; the four XDG base directories and values under ~, /tmp and /var/tmp are mirrored now
Drift defect, fixed before 4 ruby, r and elixir the scored arms were python or gcc images
Drift defect, open decision 3 env.HTTP_PROXY Redactor masks every IPv4 address in an env value, loopback included, so http://127.0.0.1:1 is unusable; masking loopback is a privacy change and is not made
scenario defect 18 the cause is not an attribute of the committed capsules: limits.* (8), the interpreter on the command line (4), network.hosts and network.mode (2), cpu.count (1), a lockfile in the harness image (1), a locale that is not installed (1), and a proxy host already masked in the capsule (1)
inherent limit 13 browsers (4), the host kernel and CPU (6), and three intermittent scenarios that need more than 30 trials per arm

The 21 scenarios of the first four rows ran again on paisley-park with binaries built from the changed tree. Two now end CONFIRMED, both naming env.XDG_CACHE_HOME; 18 end CONFIRMED EFFECT (bundle) and one NARROWED. The solver resolves 54 of the 105 scenarios with a cause (51.4%, Wilson 42.0% to 60.8%), up from 43 (41.0%); some verdict holds the cause in 68 (64.8%, 55.3% to 73.2%), up from 49; and a verdict of any kind exists for 71 (67.6%, 58.2% to 75.8%), up from 50. CONFIRMED verdicts number 30, all correct (Wilson 88.6% to 100%), so no false confirmation appeared. The 10 controls were not solved again. Two of the new bundle verdicts, build-make-feature-1 and -2, name tool.cc.version and do not hold the injected tool.make.version, which the capsule does not record. The seven alpine userland scenarios hold their cause, tool.coreutils.flavor, among nine members and name os.release.ID, the member that sets it. The score is in bench/data/solves/dev-after.json and the experiment in experiments/dev-solves. The same caveats apply as before: the scenarios are synthetic, tuned against, and each ran once, and the fixes were chosen after reading these 55 certificates, so the after numbers are in-sample.

Resource limit capture. The eight limits-* scenarios in the scenario-defect row above were stuck because no capsule carried a limits.* attribute: capture recorded none, so no arm received a --ulimit flag and both arms behaved alike (0 of 30 trials failed on either side). Capture was added after those dev failures were read, so the eight results are in-sample, like the Lab fixes. Capture now reads /proc/self/limits and records limits.<name> as soft:hard for cpu, fsize, data, stack, core, nproc, nofile, memlock and as, with unlimited kept as a value; the attributes are absent on macOS and Windows. The JVM and Node raise the soft open files limit to the hard one when they start (in drift-cisim:3 the shell reads 1024:2048 and both runtimes read 2048:2048), so those builds leave limits.nofile out instead of recording a soft value the process did not inherit. The limits dimension, reproduce, the set-ulimit intervention and the arm harness already handled the attribute; the set-ulimit intervention now records a single number as N:N, so the requested value equals what a capture inside the arm reports and the void rule can compare the two.

The 16 capsules of the eight scenarios were recorded again on paisley-park with the new Linux executable, and all eight scenarios validate with the cause visible, which is why the seven limits-* templates changed from visible: false to true (only the visible and note fields of 14 entries in scenarios.json changed; the ids and split.json did not). The new capsules differ from the old ones by more than limits.*: three scenarios (limits-core-1, -2 and limits-nofile-pipe-1) had been recorded on a linux/arm64 Docker host with Debian 12, and the current python:3.12-slim is x86_64 Debian 13, and the newer capture masks HOME and the host name. All eight were solved once on paisley-park. Each ends CONFIRMED with the injected cause as the minimal set (limits.core twice, limits.fsize once, limits.nofile five times): 7 of 7 failures against 0 of 7, and back, in 44 trials for the six scenarios that change one attribute and in 72 trials and four experiments for the two decoy scenarios, which also change a memory limit and a log level. With them the solver resolves 62 of the 105 scenarios with a cause (59.0%, Wilson 49.5% to 68.0%), up from 54, and CONFIRMED verdicts number 38, all correct (90.8% to 100%). The score is in bench/data/solves/dev-limits.json and the certificates in experiments/dev-solves/certificates-limits. The archive fixtures/lab/cases/limits-nofile-1.driftcase is the earlier STUCK run and stays as recorded.

Hosts, name resolution, CPU affinity and loopback proxies. The remaining stuck scenarios were read again from their certificates and capsules. The fixes below were chosen after reading the dev certificates, so every number in this paragraph is in-sample. Four capture and Lab changes: network.hosts.names now names every custom /etc/hosts entry, as name@address when the address is loopback and as the bare name otherwise, with the stock aliases and the machine's own name left out (the earlier network.hosts.count is gone, because a count that changes with the names turned every flip into a two-attribute bundle); network.nsswitch.hosts records the hosts: line of /etc/nsswitch.conf; and four interventions set what the arm can carry: --cpuset-cpus from cpu.affinity when it is below cpu.count, --network none from an interface list of lo alone, --add-host from the loopback tokens, and a Dockerfile step that rewrites the hosts: line (an Alpine image reads no such file, so the entry is partial). The three network-proxy scenarios needed no new capsule: their committed red capsules already held http://127.0.0.1:N, and the arm dropped it because the redactor masked loopback; with loopback exempt they solve. Eight committed capsule pairs predated the facts and were recorded again on paisley-park with the new Linux executable: cpu-count-pool-2, locale-setlocale-2, network-hosts-2, network-hosts-stale-1, -2, network-interface-missing-1, network-nsswitch-order-1 and network-proxy-lowercase-1, whose sealed capsule held a masked host. Four templates changed their cause path to the new attribute and flipped visible to true (network.hosts to network.hosts.names, network.mode to network.interfaces, network.nsswitch to network.nsswitch.hosts, cpu.count to cpu.affinity); scenarios.json changes in those fields for 12 entries (the cpu-count-node-parallelism template shares the cause), and the ids and split.json are unchanged. The two committed score files dev.json and dev-after.json changed only in the cause label of three cases.

class cause-bearing resolved before resolved after
single 49 30 (Wilson 47.2% to 73.6%) 38 (64.1% to 87.0%)
bundle 33 16 (32.5% to 64.8%) 16
decoy 19 13 (46.0% to 84.6%) 15 (56.7% to 91.5%)
intermittent 11 3 (9.7% to 56.6%) 4 (15.2% to 64.6%)
all 112 62 (46.1% to 64.2%) 73 (56.0% to 73.4%)

Before is dev.json, dev-after.json and dev-limits.json merged. After adds dev-x1-a.json, the 38 scenarios solved again with the final binaries: the eight scenarios above plus network-proxy-1, -decoy-1 and -decoy-2, all 10 controls and all 19 decoys. The 112 are the 105 scenarios the solver ran and the seven file-mount scenarios it could not; four of those seven have no case yet. CONFIRMED verdicts number 49 and all 49 name the injected cause (Wilson 92.7% to 100%); no control ended with a verdict (10 of 10 STUCK) and no decoy path was confirmed. The eleven scenarios that changed state are cpu-count-pool-2, locale-setlocale-2, network-hosts-2, network-interface-missing-1, network-proxy-1, network-proxy-decoy-1, network-proxy-decoy-2, network-proxy-lowercase-1 (stuck to CONFIRMED) and network-hosts-stale-1, -2 and network-nsswitch-order-1 (no case to CONFIRMED). Each scenario ran once.

The first pass over the controls and decoys with the new capture found a regression, and the second pass fixes it. locale-node-collation-decoy-1 and -2 ended as a CONFIRMED EFFECT bundle of env.LANG and locale.installed: the treatment arm generates the requested locale, so its capture lists a locale the control arm's does not. Solve now treats locale.installed as a consequence of a locale edit and records it among the ignored attributes of the measured diff; SolveTest.aLocaleTheArmGeneratesIsNotAMemberOfTheBundle failed first with the bundle verdict. After the change both decoys end CONFIRMED on env.LANG again. All 38 new case directories pack and pass drift verify.

Shell flavor. userland-bash-only-builtins-2, -3 and userland-shell-bashism-1, -2 stay stuck, and no capture fact would change that. Both arms run sh /bench/harness.sh <arm> in the same debian:12-slim image and take the capsule with the same executable before the program starts; the interpreter appears only in the plan (CMD='bash prog.sh' against CMD='sh prog.sh'), so the two capsules are identical. drift solve takes one failing command and runs it in every arm. What /bin/sh resolves to (dash on Debian, bash 3.2 on macOS) is a real environment difference and would be a fact worth capturing, but it is the same in these arms, so recording it would not make these four rise. They are scenario defects: the cause is the command line. Resolving them needs a passing command on drift solve, which is a change in cli/.

File-mount scenarios. Three of the seven are now solved, because /etc/hosts and /etc/nsswitch.conf are well-known system files with a bounded, privacy-safe reading (loopback addresses and custom names; other addresses are not recorded). The other four read a pin at a path the scenario chose (/etc/app/pins.sha256 for build-checksum-pin-1, -2 and /etc/app/pin for build-version-pin-1, -2), and build-lockfile-pin-2 reads /etc/app/lock.txt. No capture can know such a path, and a project's own pin file sits in the workspace both arms already mount. They stay as classified limits. The three solved scenarios stay out of the probed fit (Lab.UNPROBED): with their probed pairs added, calibrate keeps the chosen model (hand-set+softmax+isotonic) but moves the isotonic step at 600,000 from 666,667 to 729,730 and drops the 847,059 step, and the committed case fixtures pin that map in their ranking hashes (ArchiveTest, CaseTest, RankHistoryTest, VerifyTest and a Studio golden failed). Taking that change means regenerating the fixtures, which is a decision for the maintainer.

Intermittent and budget. Of the 11 intermittent scenarios, four now end CONFIRMED, and seven are stuck: browser-engine-flaky-std-1 (5 of 8 against 5 of 8 in a container), kernel-clocksource-flaky-basic-2 and kernel-overcommit-flaky-1 (3 of 8 against 3 of 8; the kernel is the host's), build-lockfile-pin-2 (0 of 30 on both arms; the lockfile is in the harness image), and three budget cases. For the three, the pilot of eight trials gave cpu-limit-quota-basic-2 1 of 8 (true red rate 0.4), env-retries-flaky-plain-1 2 of 8 (0.3) and migration-python-copy-1 2 of 8 (0.5), and the planner sized the experiment from that Laplace estimate (0.2, 0.3, 0.3). At alpha 0.004166 with a clean control, the exact test at 30 trials per arm has power 0.956 at rate 0.4 and 0.997 at rate 0.5, and 0.719 at rate 0.3 (0.867 at 35 per arm). So two of the three are not limited by the cap of 30: the pilot estimate refused to run an experiment that would very likely have confirmed. Running anyway would change the power rule, and a sequential rule (the e-process of the plan) would need fixed-point log arithmetic, a new certificate field that verify re-derives, and a flag in cli/; neither was done. The default stays, and the open question is whether the pilot should run longer before it sizes the experiment.

Member experiments, inseparable groups and NEEDS. Three changes to drift solve, each chosen after reading dev certificates, so every number here is in-sample, and each scenario ran once.

First, the minimal-set search keeps together the attributes that one intervention sets. The three os.release.* hypotheses are one set-os change, so a subset with any of them is the same arm and the search returned whichever sorted first (os-version-gate-2 reported os.release.PRETTY_NAME where the cause is os.release.VERSION_ID). The minimal set is now the group.

Second, a bundle verdict tests its members. Each member that its own intervention can set gets an experiment of its own, up to six arms per solve and inside the stated budget. Setting only that member on the passing side and reproducing the failure makes it sufficient. Otherwise reverting only it on the failing side and removing the failure makes it necessary. A member counts only when the capture inside the arm shows that member changed and nothing else, and its value is read from the arms' captures when the capsule does not record it (neither build-npm-sbom-plain-1 capsule records tool.npm.version; the containers do). The new set-userland intervention installs the GNU coreutils, findutils, grep, sed and diffutils on an Alpine base, so tool.coreutils.flavor can be reverted alone. Members that no arm sets without the rest of the image (tool.libc.version, tool.coreutils.version, tz.database.version) are listed as not tested alone, each with the reason, and the verdict never names them. The certificate holds the arms under conditions.removal, the verdict holds necessary and sufficient, verify checks that each listed member has a supported experiment, and bench solves counts a bundle as naming the cause when the cause is in its minimal set or in either list. The first real run of this change failed: the Reproduce step that installs the GNU tools keyed on the capsule's tool.coreutils.flavor=gnu, so the forward set-os alpine arm (green capsule, GNU flavor) received GNU tools and passed, and the seven Alpine scenarios ended NEEDS. It now installs them only for an attribute that an intervention set.

Third, NEEDS replaces STUCK as the outcome of a solve that could not settle the cause. The certificate lists steps, each with kind, text, command and reply: capture the failing machine (the capture ask of drift why: drift why --save there.json, with the piped form drift why --save there.json && drift why --reply < there.json, which drift why prints too), name what a container does not mirror, run the pilot at its 30 trials per arm, run the manual experiment (drift next CASE) for what no container can set, double the budget, or rerun after the failing executor step. STUCK stays only for a certificate with no step to give, and its statement says why. core/.../lab/SolveNext.kt holds the mapping and drift solve --exit-status exits 13 on NEEDS. The solve script that bench prepare writes for an intermittent scenario now passes --pilot 30.

49 scenarios were solved again with Linux x64 and arm64 executables built from the changed tree on paisley-park, run-solve.sh --jobs 2: the 15 bundle scenarios that hold the cause without naming it or ended NARROWED, the 11 intermittent and 10 control scenarios, and the other scenarios that ended STUCK. The score is bench/data/solves/dev-p8.json, regenerated byte for byte from experiments/dev-solves/certificates-p8, and the four earlier score files still regenerate byte for byte. The 69 other cases keep their earlier certificates: 49 of them are CONFIRMED, a path these changes do not reach (no CONFIRMED certificate has forward flips that one intervention sets together, and member experiments run only after a bundle verdict).

class cause-bearing resolved before resolved after
single 49 38 (Wilson 64.1% to 87.0%) 38
bundle 33 16 (32.5% to 64.8%) 25 (59.0% to 87.2%)
decoy 19 15 (56.7% to 91.5%) 15
intermittent 11 4 (15.2% to 64.6%) 5 (21.3% to 72.0%)
all 112 73 (56.0% to 73.4%) 83 (65.3% to 81.3%)

The ratified bar is 90 of 112. After the Lab changes alone, 83 is 74.1%, seven short; the scenario fixes below closed the gap. Ten scenarios moved. userland-date-relative-3, userland-find-printf-1 and -2, userland-gnu-long-options-1, -2 and -3, and userland-grep-perl-1 name tool.coreutils.flavor as necessary (7 of 7 failures with the BusyBox tools, 0 of 7 with the GNU tools installed, in 58 trials). build-npm-sbom-plain-1 names tool.npm.version as sufficient (setting npm alone on the passing side: 7 of 7 against 0 of 7). os-version-gate-2 names its group. migration-python-copy-1 ends CONFIRMED EFFECT (bundle) naming tool.python.version once the pilot runs 30 trials. os-glibc-floor-1, userland-coreutils-new-flag-1, -3 and userland-cp-update-2 hold tool.libc.version or tool.coreutils.version, which no arm sets without the image, so they stay at "holds without naming". build-npm-subcommand-2 and runtime-elixir-newer-stdlib-2 stay NARROWED, and build-make-feature-1 and -2 still miss tool.make.version, which no capsule records.

Over the 118 cases, the verdicts went from 49 CONFIRMED, 39 CONFIRMED EFFECT (bundle), 2 NARROWED and 28 STUCK to 49, 40, 2, 25 NEEDS and 2 STUCK. All 49 CONFIRMED name the injected cause (Wilson 92.7% to 100%), none of the 10 controls drew a verdict that names a cause (10 of 10 NEEDS), and no decoy path was named. The two genuine STUCK verdicts are cpu-limit-quota-basic-2 and env-retries-flaky-plain-1: the pilot already runs the 30 trials per arm that the exact test allows, the failure rate it saw (0.3125 and 0.3) leaves the power under 0.8, and no option changes that. The default power stays.

Twenty-nine scenarios remained at that point: browser (4) and kernel or CPU (6) need an arm that a container cannot give; the four shell scenarios and the five pin scenarios were scenario defects (below); the two power cases above; and the eight bundle scenarios named above.

Scenario defects fixed, 92 of 112. Nine dev scenarios could not be solved because the scenario hid its cause from the capture and from the arms, so the solver had nothing to flip. They were changed, their capsules recorded again on paisley-park with the current Linux executable, and solved once. The count of 92 was reached partly by fixing these defects, and the fixes were chosen after reading the dev certificates, so it is in-sample.

scenarios defect change
userland-bash-only-builtins-2, -3, userland-shell-bashism-1, -2 both arms ran the same image and the same capture; only the command line (bash prog.sh against sh prog.sh) differed, which no capsule records the arms run sh prog.sh on debian:12-slim; the green image links /bin/sh to bash and the red image keeps dash. The capture already records tool.sh.flavor (the link target). The cause path is tool.sh.flavor. Reproduce mirrors bash with RUN ln -sf bash /bin/sh, and the set-shell intervention sets the flavor.
build-version-pin-1, -2 the pin was a file at /etc/app/pin inside the image, a path no capture reads the pin is .nvmrc or .python-version in the workspace. capture --project records it as project.pins.node or project.pins.python.
build-lockfile-pin-2 the lockfile was /etc/app/lock.txt inside the image the lockfile is the workspace's requirements.txt, which the project capture already reads as project.python.<name>; the cause path changed from deps.schemakit.version to project.python.schemakit.
build-checksum-pin-1, -2 the pinned checksum was /etc/app/pins.sha256, and the vendored file existed only in the harness image the pin is the workspace's SHA256SUMS, which the project capture records as project.checksums.<path>, and the vendored file is a workspace file in both arms.

The arms carry these pins as workspace files. Reproduce bakes the pin files into the image under /pins, mounts the workspace read-only at /src and a tmpfs at /work, and copies both into /work before the command (cp -R /src/. /work/ && cp -R /pins/. /work/ && ...), so a pin changes without touching the caller's workspace. The set-pin intervention sets project.pins.*, project.python.<name> and project.checksums.<path>, and drift solve adds --project to the capture inside an arm that carries pins. The scenario template gained two arm keys, shell and workspace (written only when set), and the harness stages the workspace files and records with --project for an arm that has them. All nine solve CONFIRMED on the injected cause alone (44 trials each, 128 for the intermittent lockfile scenario).

class cause-bearing resolved before the p8 work resolved after
single 49 38 (Wilson 64.1% to 87.0%) 46 (83.5% to 97.9%)
bundle 33 16 (32.5% to 64.8%) 25 (59.0% to 87.2%)
decoy 19 15 (56.7% to 91.5%) 15
intermittent 11 4 (15.2% to 64.6%) 6 (28.0% to 78.7%)
all 112 73 (56.0% to 73.4%) 92 (74.0% to 88.1%)

92 of 112 is 82.1%, above the 0.80 bar. Over the 122 solved cases the verdicts are 58 CONFIRMED (all name the injected cause, Wilson 93.8% to 100%), 40 CONFIRMED EFFECT (bundle), 2 NARROWED, 20 NEEDS and 2 STUCK; all 10 controls end NEEDS and name no cause, and no decoy was named. build-make-feature-1 and -2 were not changed: the capture registry already knows make, but tool.make.version comes with the gcc image like tool.coreutils.version, so no arm sets it alone and a recorded value would leave them at "holds without naming". The re-recording did not move the calibration fit: calibrate gives the same output with the old and the new capsules.

Where the 39 unresolved scenarios stand. Browsers (4) and the host kernel or CPU (6) are inherent limits of a container. Budget (3), shell flavor (4), file pins (4) and the lockfile (1) are above. The other 17 are bundle scenarios whose minimal set does not name the cause: 13 hold it among the members, one is NARROWED to a set that holds it, two miss it (build-make-feature-1 and -2, whose tool.make.version the capsule does not record) and one is NARROWED to a set that misses it.

Flagship Case B: Kotlin on Native and Wasm

Case B asks whether the Lab can isolate a Kotlin divergence that the Atlas found. The shipped archive is fixtures/cases/b/resume-offset.driftcase (395,776 bytes, 43 entries, sha256 d7f50515...2e3b81), and the program is in fixtures/cases/b/program.

Selection rule. Written down before any outcome was read: among the probes of atlas/classification.yml that are unclassified and have a repro draft, keep those whose 2.4.20 columns jvm, macos and wasm are not all in one group, and take the smallest draft by byte count, ties by probe id. Three smaller drafts (numbers.type-checks, int.division-modulo, regex.groups-replace) differ only on cloudflare-jvm, so the rule passes over them. It picks kotlin.string.split-replace-lines, whose one differing line is indexOf-empty: "abc".indexOf("", 10) is 3 on the JVM, Native and Android and -1 on Wasm, unchanged at 2.5.0-Beta1. Its classification entry and repro draft already exist; the cited Javadoc covers String.indexOf(String, int) on the JDK and the Kotlin KDoc gives no rule for a start index past the end. That Javadoc block contradicts itself for an empty str past the end: its formula, the smallest k with k >= Math.min(fromIndex, this.length()) && this.startsWith(str, k), gives 3, and its API note says the method "returns -1 when fromIndex is larger than the length of the string" (JDK 21 String, read 2026-10-13). The JVM, Native and Android follow the formula; Wasm and cloudflare-jvm follow the note.

The program. resume(text, offset, separator) is text.indexOf(separator, offset), called with a saved offset of 40 into a 17-character text and an empty separator, which stands for "to the end". The program prints cursor= and a FAIL line when the cursor is outside the text. Built with Kotlin 2.4.20 it prints cursor=17 on the JVM and on Native (linuxArm64, in a container) and cursor=-1 with the FAIL line on Wasm (Node 24.21.0). The program was written to hit the divergence; nobody met it in the wild.

Mapping to the Lab. The environment is a node:24-bookworm-slim container on arm64 Docker Desktop. run.mjs, the test command, runs the Native build or, when DRIFT_RUNTIME_PLATFORM=wasm, the Wasm build. The green capsule and the red capsule were captured inside that container with the Linux arm64 executable, the red one with the variable set, and differ in runtime.platform only (linux, wasm). The failure is a line of output (--fail-when "FAIL cursor"). Each platform is a different compiler backend, standard library and runtime, so the platform is one dimension of the capsule and several things in fact; the capsule has no attribute for the rest.

What the unmodified engine did. Run on the capsules before any change (the red capsule then also carried env.DRIFT_RUNTIME_PLATFORM), drift solve returned CONFIRMED on env.DRIFT_RUNTIME_PLATFORM. The hypothesis runtime.platform, which held half of the prior, was blocked with "no typed intervention changes runtime.platform" and its experiment skipped. The Lab had confirmed the carrier and could not test the platform. Three changes fix that: a set-platform intervention that declares runtime.platform and that run.sh passes as DRIFT_RUNTIME_PLATFORM, reproduce mirroring a declared (not host-derived) runtime.platform, and capture no longer repeating the declaration as an env. attribute. Tests are in InterventionTest, SolveTest and CaptureTest, and the archive is pinned in VerifyTest.

drift solve passing.json failing.json --run "node run.mjs" --fail-when "FAIL cursor" \
  --workspace stage --drift-binary drift-linuxArm64.kexe --case case --name resume-offset
CONFIRMED: this dimension causes the failure: runtime.platform. Setting it to the failing values
  moved the failure count from 0 of 5 to 5 of 5 (exact one-sided p 0.003968) and back from 5 of 5 to
  0 of 5 when the failing side was set to the passing values (p 0.003968)
1 candidate changes, 2 experiments run in containers (36 trials, 36 seconds), not on the original
  host.
  p0 pilot: passing baseline 0 of 8, failing baseline 8 of 8, p 0.000077
Mechanism: unexplained (no rule or probe agreed)

The measured arm diff, from a capsule captured in each arm, holds runtime.platform and nothing else, and the belief in runtime.platform went from 0.75 to 0.98. The trial records hold each output line: cursor=17 length=17 in every passing trial and FAIL cursor outside the text in every failing one. drift pack, drift verify, drift report and drift verify --json gave byte-identical output on the JVM and on the macOS release executable (cmp), and verify printed verify: ok with 14 checks passing.

A counterfactual outside the certificate. Replacing the call with text.indexOf(separator, offset.coerceAtMost(text.length)), rebuilding the Wasm module and running it in the same container 8 times gave 0 failures, against 8 of 8 for the original Wasm build. This was run by hand and is not in the archive.

What it does not show.

Flagship Case A: Drangler on the Same Commit

The reported symptom is a Docker end-to-end suite that passes on a developer machine and fails on the GitHub runner. The case compares a local run with a CI run of one commit, df6dce4, the head of the three red runs, and not the green runs at an earlier commit. The protocol is bun run test:e2e:coverage under drift run --tools, in a copy of the tree with the compose images pinned by digest, Node 24.21.0 (the runner's) and bun 1.4.2, on an x86-64 Ubuntu 24.04 host with the suite pinned to 4 CPUs. The CI side is the three frozen red logs. A non-gating Runner Diagnostic job in the drangler e2e.yml runs the same suite under the Drift action and uploads the capsule and a host probe (CPU steal, cgroup throttling, clocksource, a loopback heartbeat under stall). It needs a Drift release and a push, so it has only been linted with actionlint.

The first local run set the premise back. The suite does not pass locally. Over 9 full runs, the two to-worker.spec.ts tests that fail in every CI run failed in 6, with the CI signature (TypeError: fetch failed, write EPIPE, /rows answered 500, at the same source lines). They failed when preview.spec.ts ran earlier in the same process, as it does on a fresh checkout, and in the one two-file run; they passed in 3 full runs and in 2 runs of to-worker.spec.ts alone. The modify tests, which fail in CI, passed in all 9. preview.spec.ts failed in every local run on a different assertion than in CI. The probe beside three runs saw no CPU steal, no throttling, clocksource tsc, a worst event-loop lag of 2 ms and no loopback stall, and those runs failed 3 of 3 against 3 of 6 without it. After preview.spec.ts alone no wrangler or workerd process remained, so a leaked process was not what was seen. The macOS run tested nothing: the Docker Desktop disk was full and mariadb stopped with No space left on device.

The vacuous-green check, from the frozen logs, shows the two to-worker tests passed in 4 and 5 of the 5 green runs. The two modify tests and the preview file are in no green log. ruled-out.yml lists what the logs exclude: no kernel kill, memory, disk or install failure in the three red runs, each with the log line. drift diagnose --frame environment finds 61 attribute differences, 1 by value (tool.git.version) and 60 present on one side only, because the CI capsule is read from a log and the local one is captured. It ranks 56 and names no cause. The planner's top experiment, e89, sets 47 attributes and needs a compose stack its containers cannot start, so it was named and not run.

A Mac run, taken once Docker Desktop had disk again, shows the other half. On an Apple M2 Pro (arm64, Node 24.21.0, bun 1.4.0, the committed df6dce4 tree, results cache cleared before each run) three full runs failed the modify test requires simple_oauth, uploads simple_oauth_21 and enables it twice (Error: Network connection lost.), as the runner does in 3 of 3 red runs, and preview in 3 of 3; the to-worker pair passed in all three full runs and in two runs of the file alone. So each local machine reproduces a different half of the CI failure: paisley-park shows to-worker (6 of 9 full runs) and not modify (0 of 9), and the Mac shows modify (2 of 3) and not to-worker (0 of 3). Three and nine runs measure no rate. drift diagnose of the paisley capture against one Mac capture (both captured, so the diff is two-sided) finds 98 changes, all one bundle with the same support, and names no cause: runtime.platform ranks first only through the hand-set weights, because the pair is two operating systems and nearly every attribute differs. That ranking cannot isolate a network or Docker difference, and it gives no support to any experiment. The Mac capsule is from the mac-r2 run and is not committed.

Verdict: STUCK on the cause. By hand, the premise is narrowed: the failure is not CI-only, and on one host it follows the file order. The cause of the interaction with preview.spec.ts is unknown. This does not show why modify fails only on the runner, any result on the runner image, or that preview.spec.ts causes the failure (ten runs on one host, one of them a two-file run). The fixtures are in fixtures/cases/a, and a test pins the case directory.

The Drangler Case

The drangler case is a CI-versus-local divergence: the reported symptom is that an end-to-end suite passes on a developer machine and fails on the GitHub runner. That report is not recorded in the fixtures. The fixtures are 8 sanitized runs of the Docker end-to-end workflow, all scheduled: 5 passing runs at commit 558d2a6 and 3 failing runs at a later commit, df6dce4. The change between the last green commit and the first failing one touches bun.lock and package.json, and its diff lists, among others, wrangler ^4.123.0 to ^4.146.0, prettier-plugin-sh ^0.19.0 to ^0.20.0, and a new dependency @napi-rs/keyring.

What the data shows: the hosted runner image moved from 20260920.314.1 to 20260927.320.1 and the provisioner from 20260828.587 to 20260901.588, a new Set up Node step prints Node and npm versions, and three dependency entries changed (@napi-rs/keyring added, prettier-plugin-sh and wrangler raised), all between the last green and the first red. All eight candidate changes are present in all three red runs and absent from all five green ones. The data cannot separate them.

What the logs add: the first red run reports 3 failed test files and 4 failed tests. Two failed tests are in to-worker.spec.ts, which ran with the same 8 tests in every green and passed there; in the reds it ran 8 tests with 2 failed in each. Two are in a pinned production modules group of modify.spec.ts, a group the greens do not contain (modify.spec.ts ran 3 tests in the greens and 8 in the reds, with 2, 1 and 2 failures). The third failed file is preview.spec.ts, absent from the greens and present in the reds with its 3 tests skipped. So the greens executed the failing step and ran one of the failing files in full, and did not run the other failing tests, which exist only on the later commit. The fixtures compare the runner at two commits, so they cannot test the reported divergence, which is the same commit on the runner against a developer machine. Only that comparison would separate an environment cause from the new tests. The same-commit comparison is in Flagship Case A above.

The pair diff has 9 static changes. The tool ranks all 9 in one bundle and names none. The order inside the bundle comes from the dimension prior: tool.node.version leads because runtime has the highest weight, and it is an added attribute from a new step, not a version change. tool.yarn.version is also an added attribute; it is ranked because the general tool. prefix now maps it to userland (before that mapping it had no dimension and the bundle undercounted the changes by one). ci.provisioner.version and ci.provisioner.build-date are one change counted twice; the planner merges them. Over the run history the same transition is a bundle of the 9 changed attributes and 3 added steps (Set up Node, Post Set up Node and Dump Host State on Failure), and the data does not separate any member of it from the rest: each is present in all three failing runs and in none of the five passing ones.

Flagship Case C: The Blind List

Case C is built so that nobody chooses it. The candidates are the 14 labelled incidents of the incident survey (.claude/batch/research/incidents/incidents.md): public issues or pull requests where one environment passed and another failed and the thread names the difference. The list, the rule and the order were written to fixtures/cases/c/sealed.json before Drift ran on any candidate, and its SHA-256 was logged first:

d92de5f5462780491b0e30cf4e6c5d0ae34e613c12e6565eb0b0543a4786f408  sealed.json

The rule: every labelled incident is a candidate, none is dropped or added; candidates are ordered by ascending SHA-256 of their URL; they run in that order and none is skipped; a candidate that cannot be built is recorded as not reconstructible, with the reason; the flagship is the first reconstructible candidate in the order, whatever verdict solve returns. A candidate counts as reconstructible when both sides build in Linux containers from pinned public images or package versions and the failing command exits nonzero on side b and zero on side a. No engine, rule, weight or probe was changed after the seal. fixtures/cases/c/scripts/ holds the sealer, the capture and solve scripts and the generator of results.json; the per-candidate commands, images and hand checks are in outcomes.json.

Each reconstructed candidate ran as follows. Both sides were built by hand and the failing command was run on each. drift capture --tools --probes ran inside both images with the Linux release executable, and drift diagnose --frame environment and drift solve (default budget, --fail-when the thread's own signature or exit) ran on the host. solve builds its arms from the two capsules, not from the images, so Drift never saw the pinned tags. results.json has one row per candidate, and the six archives that reached solve are in the same directory.

# candidate cause key (a to b) outcome
1 INC-13 prisma on Alpine 3.21 os.release.VERSION_ID (3.20 to 3.21) CONFIRMED EFFECT (bundle), 12 members, 36 trials
2 INC-07 webpack on Node 17 tool.node.version (16.13.0 to 17.0.0) STUCK executor (no such image tag)
3 INC-06 Flutter on JDK 21 tool.java.version not reconstructible
4 INC-03 Node on Windows tool.node.version (22.11.0 to 22.12.0) not reconstructible
5 INC-10 AdoptOpenJDK on Alpine os.release.ID not reconstructible
6 INC-02 pip on Ubuntu 24.04 os.release.VERSION_ID (22.04 to 24.04) STUCK not-reproduced
7 INC-11 Lombok on JDK 21 tool.java.version (17 to 21) CONFIRMED EFFECT (bundle), 2 members, 36 trials
8 INC-09 .NET on a Windows image ci.runner.image.version not reconstructible
9 INC-08 Shopkeepers, Turkish locale jvm.user.language not reconstructible
10 INC-01 JerryScript, time zone env.TZ not reconstructible
11 INC-04 devcontainers/ci, skopeo tool.skopeo.version not reconstructible
12 INC-12 prisma on bookworm-slim os.release.VERSION_ID (11 to 12) CONFIRMED EFFECT (bundle), 4 members, 46 trials
13 INC-05 ubuntu-latest move os.release.VERSION_ID not reconstructible
14 INC-14 iodine on Alpine 3.21 tool.cc.version STUCK executor (no such image tag)

Of 14, six were reconstructed and run: three CONFIRMED EFFECT (bundle), three STUCK. Eight were not reconstructible. In all three bundle results the sealed cause key is among the members (ranked 2 of 2, 1 of 2 and 3 of 4 by the diagnosis), and none was CONFIRMED on one attribute. The reasons for the eight, from outcomes.json: two Windows machines (INC-03, INC-09); a thread that announces an image move and names no single failing command (INC-05); a registry push through buildx that a container pair cannot repeat (INC-04); a macOS Android Studio runtime and the Android SDK (INC-06); a Minecraft server that needs the EULA accepted (INC-08); two cases where the hand check found no failing side (INC-10: both pinned AdoptOpenJDK tags throw the same NullPointerException; INC-01: JerryScript built from the parent commit passes all 306 scripts in four zones, and the run-tests.py option sets and test262 were not run).

The flagship is INC-13, the first entry in the order: [email protected] on node:22.12.0-alpine3.20 against node:22.12.0-alpine3.21, from prisma/orm#25817. The command is node node_modules/prisma/build/index.js -v and the failure is a nonzero exit. By hand, the 3.20 side prints linux-musl-arm64-openssl-3.0.x and exits 0, and the 3.21 side prints the libssl detection warning and cannot load the query engine. solve finished in 73 seconds with CONFIRMED EFFECT (bundle): the arms differ in 12 attributes (os.release.PRETTY_NAME, os.release.VERSION_ID, tool.libc.version and nine component rows for musl, OpenSSL, BusyBox, certificates, tzdata and the bundled npm and corepack), setting the OS release moved failures from 0 of 5 to 5 of 5 (exact one-sided p 0.003968) and back in reverse, and the mechanism field reads unexplained. Only os.release.VERSION_ID can be set on its own, so no arm splits the bundle. The archive INC-13.driftcase (180,224 bytes, SHA-256 60bd8ea19526c496a17cbd7e0c3665b5e93e284eb095ef05efa2f35f4f6a890b) was packed by the JVM build and by the macOS executable with identical bytes, and verify and report output is identical on both. VerifyTest pins the hash, verifies the archive and checks that repacking reproduces it, and pins sealed.json and the ordering rule.

What the flagship does not show. The verdict says an OS release change is enough, not why. The thread proposes that libssl moved from /lib to /usr/lib; no arm tests that. The arms were not captured (the Linux binary is glibc and cannot run on Alpine), so the 12 members come from the list of what the base image changes, not from a measured arm diff. Capture inside the Alpine images needed apk add gcompat, so the two capsules describe the images plus that package. The pin of [email protected] is the latest release on the issue date and the Node version is held at 22.12.0 on both sides; neither is stated in the thread.

The isolation check. drift verify requires a measured diff from both arms for a bundle, as it does for a CONFIRMED verdict. INC-13 fails that check and nothing else: its arms ran on Alpine without the Linux binary, so both diffs are predicted from the base image's change list (expected measured, recorded predicted and predicted). The other 13 checks pass and the sealed archive is unchanged. INC-11 and INC-12 measured their diffs and pass all 14 checks, and INC-02, INC-07 and INC-14 end STUCK and pass.

The losses. Three results are failures of reconstruction by solve, not of the diagnosis, and each is a defect in the engine that this protocol did not fix:

Two limits apply to all six runs: the arms are containers built from capsules, not the original hosts, and the trials ran on a Mac under Docker Desktop (linux/arm64) for everything except the paisley-park checks of INC-10 and INC-01. The sealed order was not used to tune anything; the flagship was fixed by the rule before a result existed.

Post-fix rerun. This is not part of the sealed protocol (fixtures/cases/c/post-fix.json, with sealed.json and the sealed archives untouched). After the image-selection fixes (Debian 10 maps to a buster tag, GCC tags are pinned to major and minor, Alpine arms install gcompat) and the manifest record of the packages an arm adds, INC-13 was rerun from its sealed capsules and reproduced the sealed verdict: CONFIRMED EFFECT (bundle), the same 12 members, 36 trials, exit 10, with solve output identical except for the timing line. INC-07 now builds both arms (node:16.13.0-buster-slim and node:17.0.0-buster-slim) and ends CONFIRMED EFFECT (bundle) with two members, env.NODE_VERSION and tool.node.version: failures went from 0 of 5 to 5 of 5 (exact one-sided p 0.003968) and back from 5 of 5 to 0 of 5 in reverse, in 36 trials. INC-14 now resolves its base to gcc:13.2 (linux/arm64) and the arms build, but the passing configuration fails as often as the failing one in the container (8 of 8 against 8 of 8), so the verdict is STUCK with 3 candidate changes and no experiment run. None of these rows replaces the sealed flagship result, INC-13.

Flagship Case D: A Migration Predicted, Then Run

Case D uses Drift the way a team would before a migration: capture the project on the old stack, ask drift upgrade-check what the move to the new stack breaks, freeze the answer, run the project's own tests on the new stack, and score the answer against the failures. The project was chosen by a rule, not by hand. The rule, a pool of 12 libraries from the first 60 results of the GitHub search language:php stars:1000..8000 archived:false fork:false, the pin (the latest stable tag before 2021-07-01) and the order (ascending SHA-256 of the repository URL) are in fixtures/cases/d/selection.json, whose SHA-256 was logged before any capture or test ran:

24d17eff3383eb6c3ee5dada258620edefa909bd19a34866c8118602f097846d  selection.json

The first candidate, sebastianbergmann/comparator 3.0.3, is not reconstructible: on PHP 7.4 the newest phpunit/phpunit 8.5 requires sebastian/comparator 3.0.7 or newer and the root package is 3.0.3. The second, lcobucci/jwt 4.1.4 (71cf170), installs from its committed lock and passes 280 tests (522 assertions) on PHP 7.4.33, so it is the case. Composer 2.10.3 refuses the 2021 lock without an allow-plugins block, so both stacks retry once with --no-plugins.

drift capture --project --tools ran in a PHP 7.4.33 container with the Linux arm64 executable, and drift upgrade-check <capsule> --to php=8.3 ran with the macOS executable. The prediction (11 will-break, 1 may-break, 0 unknown), the scoring protocol with its message patterns, the rule tree and both executables were hashed together at 08:52 UTC (prediction.sha256) before the PHP 8.3 container ran. The protocol fixed the install order on the new stack: the lock, then composer update, then --ignore-platform-reqs.

measure result
Predicted 11 will break, 1 may break
True positives 0
False positives 12
Misses (new failing tests unattributed) 0
PHP 7.4.33 baseline 280 tests, 522 assertions, pass
PHP 8.3.35, install step 2 (composer update) 280 tests, 522 assertions, pass; PHPUnit 9.5.0 became 9.6.38
Same suite with deprecations, notices and warnings made fatal pass
Supplementary, lock held with --ignore-platform-reqs (not scored) fatal error after 63 of 280 tests

The score is a loss for the catalog, and the table understates why. All 12 rules fire on the version crossing alone; none reads a source fact, and the capture had none of the APIs they name (create_function, each, implode with reversed arguments, mysqli, PDO). What actually broke the move was not predicted. composer install from the lock stopped on phpspec/prophecy 1.12.2, which caps PHP below 8.1. With the lock held, mikey179/vfsstream 1.6.8 stopped the run with a fatal error because getIterator() lacks the PHP 8.1 tentative return type. composer update hid that second break by moving 60 packages. No rule in the frozen catalog covered a locked package with a PHP upper bound or a tentative return type, although the capture held both package versions. The scored run is therefore a result for "PHP 8.3 with whatever Composer resolves", not for the dependency set of the pin, and a suite that passes there does not show that the old dependencies would.

The group split that followed (see Upgrade Check) rerun on the same capture gives 0 will break, 1 may break and 11 version boundary, recorded in fixtures/cases/d/post-fix.json; it changes the wording of the 11 and not whether they were failures. Limits: one project, one outcome, so no precision or recall figure holds; the pass is the PHPUnit exit status of the default configuration, and the deprecation scan only covers what PHPUnit's handler sees; a deprecation that PHPUnit swallows would not count as a failure under the protocol; Docker Desktop on one Mac ran every stack (linux/arm64). Rules suggested by the result are in fixtures/cases/d/follow-up-rules.md; none was applied, and rules/ did not change between the frozen prediction and the score. VerifyTest pins the selection hash, checks the order and the score against the prediction. After the score, php-8-1-vfsstream-below-1-6-10-tentative-return was written from the vfsStream source at each tag (the attribute arrives in 1.6.10). A labelled rerun with it, fixtures/cases/d/post-fix-p3-rules.json, gives 1 will break (vfsStream), 2 may break and 11 version boundary. The rule came after the outcome, so the rerun reproduces the break rather than predicting it; the frozen score above is unchanged. The phpspec/prophecy upper bound still has no rule.

Threats to Validity

Tests

Counts from the test result files of the last run of each task (*/build/test-results/*/*.xml), read on 2026-10-13 after the final clean build:

module JVM macOS arm64 Native Wasm on Node
core 639 639 639
scan 152 152 152
bench 108 76 76
cli 335 311 290
host 37 40 31
studio 114 - 69 in Chrome
tools 82 - -
build-core 101 - -

Every count has 0 failures and 0 skipped. The platform columns differ for host and cli because some tests exist only for the targets that can write files, run commands or open sockets, and for bench because the YAML, Docker and binomial tests are JVM-only. The Lab's tests run in common code on all three targets: SolveTest 29, IngestTest 10, ArchiveTest 18 and VerifyTest 23 in core, and SolveCommandTest 9, ArchiveCommandTest 12 and DockerExecutorTest 7 in cli. Other new domains: TriageTest 39, LearnedTest 6, CalibrateTest 7, UpgradeCheckTest 19, ContractTest 21 in core; TriageCommandTest 16, UpgradeCheckCommandTest 9, ContractCommandTest 14 in cli; ProjectScannerTest 33 in scan; CommandLineTest 14 and HostRunTest 7 in host. They run against a fake executor; no test builds an image or starts a container. The installer tests (classes with Install in the name) number 64 on the JVM, 49 on macOS Native and 43 on Wasm, and the serve tests 51, 50 and 37.


Reproducing the Numbers

Build and gates, from the repository root with JDK 25 (the build's toolchain here is Temurin 25):

./gradlew build spotlessCheck checkLineLength
./gradlew jvmJacocoAggregateReport verifyCoverage coverageSummary
./gradlew sharedRatio
./gradlew :cli:linkReleaseExecutableMacosArm64
./gradlew atlasSite

coverageSummary writes build/coverage/summary.txt, which holds the coverage table and the per-module test counts; sharedRatio prints the shared-code table. The CLI examples above run the linked executable, cli/build/bin/macosArm64/releaseExecutable/drift.kexe. A JVM run is ./gradlew :cli:jvmRun --args="...". The figures here were read on 2026-10-13 on a Mac from the executables linked by the final ./gradlew build.

A scenario is dev when the first four bytes of sha256(id), read as an unsigned integer, mod 100 are under 60, unless its template is held out: a template outside SplitRule.LEGACY_TEMPLATES (the 43 templates that predate the rule) whose own id fails the same test has only test instances. bench split recomputes the split from bench/data/scenarios.json with that rule and compares it with bench/data/split.json:

./gradlew :bench:jvmRun --args="split --root ."

It prints the dev count, the test count, the held-out template count and the SHA-256 of the sorted test ids, then exits 1 with a message if the result differs from split.json. On the committed data it prints:

dev 122, test 150, held-out templates 36, test_sha256 aea538143b9ca524864692d92e64c0ae95e0c5c42415cf4864be0236469f8b13
matches data/split.json
figure value command or file
core tests per target 639, 639, 639 sum of tests in core/build/test-results/<task>/*.xml
other module test counts table in Tests same files, one directory per task
shared-code ratio 91.1% (27,626 and 2,697 lines); host 423 and 545 (43.7%) ./gradlew sharedRatio
JVM line coverage 96.0%, 20,448 of 21,293 ./gradlew jvmJacocoAggregateReport verifyCoverage coverageSummary, then build/coverage/summary.txt
no Double or Float in common code 1 (a string literal in triage/Claims.kt) grep -rn "Double|Float" core/src/common/main bench/src/common/main
fixed-point and hash goldens table in Design Principles core/src/common/test/.../GoldenTest.kt
drift diagnose hash 99f637b2...38f7 drift diagnose fixtures/drangler/capsules/36702683742.json fixtures/drangler/capsules/37114464625.json | shasum -a 256; DriftCommandTest.DIAGNOSE_SHA
capsule hash in reproduce 540e5c22...0531 drift reproduce fixtures/lab/node-alpine.json --run "npm test" --out out; equals the sha256 of the file without its final newline
case files match the pinned hashes 11 of 11 drift diagnose ... --case case, then shasum -a 256 per file against CaseCommandTest.CASE_SHAS
rule sha256 of the drangler case 8bf205db...4559 drift case show case, drift next case --detail detail
CLI size and commands 9,866,776 bytes (macOS arm64, built 2026-10-15, before drift why; 28 subcommands then, 29 now); Linux x64 of the current tree 15,616,968 bytes ls -l cli/build/bin/macosArm64/releaseExecutable/drift.kexe, drift --help
capture counts (90, 117, 71) this Mac drift capture, drift capture --tools, drift capture --probes --tools
masked and identifier-bearing env attributes 13 masked of 81; with --keep-identifiers 22 hold the account name and 20 the home path; by default 0 and 0 drift capture and drift capture --keep-identifiers, then count env.* values equal to <redacted>, containing the account name, containing $HOME
capture stdout bytes strict JSON parse succeeds; 1 raw U+0085 and 1 raw U+2028 drift capture --probes --tools > c.json; python3 -c "import json; json.load(open('c.json'))"; count the UTF-8 bytes of both code points in c.json
rules, status, fixtures, sources, severities 429; 376 active and 53 pending-scanner; 1493 (469/1024); 497 entries, 315 distinct; 134/240/55 find rules -name '*.yml' | wc -l; grep -rh '^status:' rules --include='*.yml' | sort | uniq -c; grep -rh '^severity:' rules --include='*.yml' | sort | uniq -c; - name: under fixtures: and kind: under provenance: per file
rule symptom basis 133 verified, 265 inferred, 52 range (450 symptoms) grep -rh "basis:" rules --include="*.yml"
transcript fixtures 77 files, 23 tool folders find fixtures/transcripts -type f; ls fixtures/transcripts
probes 71 = 33 + 38 drift capture --probes
probe links 33 entries core/src/common/main/.../rank/ProbeLinks.kt; ProbeTest.everyCatalogProbeHasALinkEntryNamingRealAttributes
Atlas, 16 columns 38 probes, 32 divergent, 6 identical, sha256 27de9a7f...78cd drift atlas build fixtures/atlas/*.txt --out atlas16.json
Atlas, 21 columns 32 divergent (2.4.20) and 18 (2.5.0-Beta1), 688,560 bytes, 8364183f...aae5 drift atlas build fixtures/atlas/*.txt fixtures/atlas/2.5.0-beta1/*.txt --out atlas.json
Atlas, 3 columns 23 divergent, 734 lines per column, 107 differing lines drift atlas build fixtures/atlas/jvm.txt fixtures/atlas/macos.txt fixtures/atlas/wasm.txt --out atlas3.json, then count cells[].lines that differ across columns; 340 over the 16 columns of atlas16.json
Atlas ladder 74 columns, 2,812 cells, 34 divergent, 4 identical, 69b785d4...36c2 ./gradlew atlasLadderDataset, or drift atlas build fixtures/atlas/*.txt fixtures/atlas/2.5.0-beta1/*.txt $(ls fixtures/atlas-ladder/*/*.txt | grep -v -e 2.4.20/linux.txt -e 2.4.20/js-ir.txt) --out ladder.json
classification 10 / 4 / 24; 4 / 4 / 24 among the 32 divergent on 2.4.20 summary.byClassification in atlas.json; drift atlas show atlas.json
ladder matrix table in The Kotlin Portability Atlas count ok cells per (version, target) in ladder.json
Beta convergence 5 probes identical; 5 more changed in the Wasm column compare cell hashes per probe between 2.4.20 and 2.5.0-Beta1 in atlas.json
this device against the dataset 38 own, 38 iOS, 37 Linux arm64, 36 Linux x64, 21 JVM, 25 Wasm, 11 js-ir; 37 for [email protected] drift atlas record --out t; drift atlas compare atlas.json t/macos.txt
macOS transcript equals fixture identical cmp t/macos.txt fixtures/atlas/macos.txt
column pairs linux and linux-ubuntu 0 probes; linux and macos 2; linux and linux-arm64 1; jvm and jvm-21 3; jvm-17 and jvm-21 6; jvm-21 and jvm-25 2; each browser and wasm 1 compare the @@ <probe id> sections of the files in fixtures/atlas and fixtures/atlas-extra
value changes on the ladder hello 604317365 to 99162322; boolean 1 to 1231; JS IR Long diff of the probe sections of fixtures/atlas-ladder/1.4.32/linux.txt and 1.5.32/linux.txt, 1.8.22 and 1.9.25, js-ir 1.5.32 and 1.6.21
Atlas site 79 files, 18,548 KiB, 39 probe pages, no <script> on a probe page ./gradlew atlasSite; find build/atlas-site -type f | wc -l; du -sk build/atlas-site; shasum -a 256 of every file, then ./gradlew atlasSite --rerun and again
Dokka site 3,827 pages; 132 unresolved relative links inside engine/ ./gradlew :dokkaGenerate; count *.html and unresolved relative href targets in build/dokka/html
Documentation site 11 rendered pages; no unresolved relative link outside engine/ (lane a4, 2026-10-26) ./gradlew docsSite; find build/site -type f | wc -l; du -sk build/site; the checkSiteLinks output
DriftBench 272 scenarios, 135 template ids, 122 dev, 150 test bench/data/scenarios.json, bench/data/split.json
dev validation 49 of 49, 2,580 trials, 978 red failures, 0 green failures bench/data/results/*.json (valid, arms.*.trials, arms.*.failures)
dev solves scored 58 CONFIRMED, all naming the injected cause, none wrong; 92 of 112 resolved (82.1%, in-sample, nine of them after fixing scenario defects; first measured 43 of 105); 10 of 10 controls name no cause bench/data/solves/dev.json; ./gradlew :bench:jvmRun --args="solves --root $PWD/bench --solved <case dirs>" (experiments/dev-solves)
order table, three inputs 46, 48 and 72 of 105 top-1 for hand-set (probed, probes removed, original) bench/data/calibration/evaluation.json (original, probes_removed, probed: models[id=hand-set].in_sample); Wilson 95% intervals computed from the counts
exact tests against a random order P = 0.0012 (probed), 0.0001, 0.00039 at top-1; 0.00002, 0.00002, 0.00003 at top-3 evaluation.json versus_random.top1.p_at_least_micro, .top3
probe effect 2 scenarios move down (env-pool-size-decoy-2, migration-python-copy-1); none improves compare the ranked order of env-pool-size-decoy-2 and every other scenario in probed and probes_removed
calibration files evaluation.json sha256 dfdddafb...283b; fit.json a050d59c...97a6; regenerated and byte-compared by CalibrationTest shasum -a 256 bench/data/calibration/*.json; ./gradlew :bench:jvmRun --args="calibrate --root $PWD/bench"
tiers and false confidence 271 / 62 / 13; 25 of 115; 40 of 62 plausible wrong evaluation.json probed.scenarios[].ranked[].tier, .cause
probe ratio sweep 0.106 at ratio 1 to 0.118 at 6; 0.108 for "one of n" evaluation.json probe_sweep
readout Brier, covered rankings 0.170 shipped (isotonic map); 0.184 shares with a fitted none weight; 0.173 uniform; 0.113 against 0.124 on all rankings evaluation.json probed.models[].cross_validated.covered.brier
none weight, map, fit provenance none weight 1.0 (not fit); 8-step map; seed 20261006 fit.json calibration, provenance; Weights.NONE, Weights.map, Weights.FIT
dev inputs 346 candidates, 71 causes, 41 scenarios with a differing probe, 25 scenarios with a rule match fit.json provenance.inputs.probed
drangler pair ranking 9 candidates, bundle-1, probability 0.104478 for each drift diagnose ... --detail full
drangler history ranking 9 candidates, ef 3, nf 0, ep 0, coverage 862,068 RankHistoryTest.theDranglerTransitionIsOneBundleNoCandidateIsTheCause
failing test files in the logs to-worker 8 tests (greens) and 8 with 2 failed (reds); modify 3 and 8; preview 3 skipped in reds HistoryFixtureTest.theStepRanInEveryGreenButTheFailingTestsAreNotAllInThem; fixtures/drangler/logs/*.log
planner, 11 + 2 experiments, EIG 0.499 bits e11 first, 157 cost points drift next case --detail full; case/experiments/plan.json
Fisher and power 35/1001 = 0.0350; 3 of 7: 0.0962; 1808/2187 = 0.8267 closed forms: C(7,4) / C(14,4), C(7,3) / C(14,3), sum C(7,k) (2/3)^k (1/3)^(7-k) for k = 4 to 7
EIG recomputed 0.49854 bits, P(reproduced) 0.490447 prior masses in plan.json, power 1808/2187, alpha 0.05
reproduce counts 23 attributes: 10, 4, 9 drift reproduce fixtures/lab/node-alpine.json --run "npm test" --out out
reproduce --force replaces three own files; refuses a foreign Dockerfile run the command twice, then once more after printf 'FROM x\n' > out/Dockerfile
tampered case exit 1, chain broken at entry 1 replace drangler-36995781138 with drangler-X in observations/0002.json, then drift case show case
Lab fixtures, archive bytes 4 archives, 50,176 / 58,880 / 43,008 / 32,256 bytes ls -l fixtures/lab/cases; extract each with tar -xf, drift pack <dir> --out x, cmp x <fixture>
Lab, JVM against native identical archives, report.html and verify output ./gradlew :cli:jvmRun --args="pack <abs dir> --out <abs file>" and the native drift pack, then cmp; same for report and verify
verify, 14 checks verify: ok for the four fixtures drift verify fixtures/lab/cases/*.driftcase
tamper detection first failure manifest, then results-chain, counts, fisher the python3 edit in The Lab, then drift verify bt.driftcase
tar interoperability 19 entries; 18 manifest hashes match tar -tf, tar -xf, then sha256 of each file against manifest.json
Lab dev evaluation, first run 14 / 9 / 1 / 21 verdicts; 1,460 trials; 0 wrong CONFIRMED the 45 certificate.json files (not in the repository) from drift solve on a Linux host; truth from bench/data/scenarios.json (expected.path, expected.bundle); the four archives in fixtures/lab/cases are 4 of them
Lab repeats 180 runs, 59 CONFIRMED correct, 0 false CONFIRMED, 6,028 trials, 7,016 s; 2 of 45 scenarios changed class the 135 further certificate.json files (not in the repository), same truth file; the bound is python3 -c "print(1 - 0.05 ** (1 / 180))", 0.016505
45 cases pack and verify 45 of 45 exit 0; 180 of 180 on three executables drift pack <case> --out x.driftcase && drift verify x.driftcase per case directory
--exit-status 0, 10, 11, 12 for the four verdicts drift solve --help, drift ingest --help; SolveCommandTest
CLI release archives 2,645,407 / 2,793,616 / 2,458,436 / 2,781,268 bytes .github/release/stage.sh archive <exe> <name> in an empty directory, then stage.sh sums dist and shasum -a 256 -c SHA256SUMS
installer script checks 33 ok, 0 failed on the Mac sh tools/test-installers.sh cli/build/bin/macosArm64/releaseExecutable/drift.kexe <scratch dir>
Studio installers dmg 74,624,330; pkg 70,015,031; APK 7,203,057 ls -l studio/build/compose/binaries/main/{dmg,pkg} androidApp/build/distributions (Linux packages: ./gradlew :studio:packageDeb :studio:packageRpm :studio:packageAppImage in a Linux container)
serve checks listeners 127.0.0.1 and [::1]; 403, 400, 404, 405 as listed; exit 0 on SIGINT drift serve --dir cli/build/serve/studio --port 18771, then lsof -a -p <pid> -iTCP -sTCP:LISTEN, curl with -H "Host: ..." and --path-as-is
serve embedding sizes 493,368 / 12,588,056 / 33,913,192 bytes executables of a trivial program that embeds the 12.4 MB build as a gzip base64 string and as a raw base64 string; not in the repository
triage, held-out 1,376 facts, precision 0.783, Brier 0.1486; 1 claim hit in 222,707 bodies ./gradlew :tools:evalTriage -Pdrift.corpus=<corpus.json.zst> (needs the corpus file)
triage fixtures 78 bodies, 207 facts, precision 0.990, recall 0.980, Brier 0.0848 TriageTest
upgrade-check 0 / 3 / 2 / 0 on the Drupal capture, 84 of 429 rules; case D 1 / 1 / 11 / 0, 57 of 429 drift upgrade-check fixtures/upgrade-check/drupal-10-php-8-1.json --to php=8.3,drupal=11,symfony=7.1; drift upgrade-check fixtures/cases/d/capture-php-7-4.json --to php=8.3
performance the table in Performance tools/perf/results/SUMMARY.md; python3 tools/perf/summarize.py
Linux and mingw test runs 730 tests each on Linux x64 and arm64; Wine counts as listed ./gradlew :core:linkDebugTestLinuxX64 (and the other modules), then run <module>/build/bin/linuxX64/debugTest/test.kexe in a Debian container; linkDebugTestMingwX64 and wine64 test.exe
browser transcripts byte-identical over 4 runs per engine node tools/browsers/run.mjs --engine chromium --headed --dist studio/build/dist/wasmJs/productionExecutable --out out (under xvfb-run in the Playwright image); cmp between runs
browser layout diagnosis 72 of 72 ordered pairs of the nine viewport capsules name the expected causes node tools/browsers/layout.mjs --dir fixtures/sites/viewport --out out; drift diagnose over each ordered pair of fixtures/sites/viewport/capsules; ViewportDiagnosisTest
studio tests 114 JVM, 69 browser, 0 failed studio/build/test-results/*/*.xml

The Kotlin 2.5.0-Beta1 transcripts were recorded in a copy of the repository with kotlin = "2.5.0-Beta1" in gradle/libs.versions.toml. The repository pin is 2.4.20. To record again, change the pin, run drift atlas record --out DIR --target LABEL on each target, and keep the transcripts under fixtures/atlas/<version>/; the Wasm transcript needs a test-driven recording because the Wasm CLI has no file writer.


References

Tracker metadata was read through the YouTrack REST API on 2026-10-06 (title, state, resolution date and affected versions; descriptions of KT-61028 and KT-44746).

The Atlas classification cites more: each entry in atlas/classification.yml lists the Kotlin API pages, JDK and Android Javadoc pages, ECMAScript and WebAssembly sections, JLS sections, tracker issues (for example KT-58195, KT-69456 and KT-78708) and the JDK 19 release note JDK-4511638 that its authors read, with a short quote and, for an issue, the state read on the research date. The ids are not repeated here.

Rule sources are listed per rule in the provenance of rules/**/*.yml. The Atlas probe notes cite the Kotlin API pages, the JDK 21 javadoc pages and the ECMAScript sections their authors read. The Ochiai formula and the delta debugging algorithm are implemented from their standard definitions; the original papers were not opened for this report.