Uh oh!
There was an error while loading. Please reload this page.
feat(eval): compare three edit contracts on the DeepSeek Harness - #3067
Closed
Astro-Han wants to merge 33 commits into
Closed
feat(eval): compare three edit contracts on the DeepSeek Harness#3067Astro-Han wants to merge 33 commits into
Astro-Han wants to merge 33 commits into
Conversation
The harness ships no patch tool, and an Eval arm that gives the model Codex's patch language needs one composed the same way its other editors are: `inject: ['tools', 'fs']`, one `ctx.tools.register(defineTool(...))`, and every file operation through `ctx.fs`. Reaching around that seam would put a second filesystem authority in one arm. Applying a single file's hunks — context matching with the fuzz ladder — is the part that is hard to get right, so it is not written here. It is the OpenAI Agents SDK's `applyDiff`, vendored rather than depended on: `@openai/agents-core` unpacks to 9 MB and pulls the whole `openai` SDK into a tree that is fingerprinted file by file, while the function itself has no imports at all. The upstream test suite is vendored with it, which is what turns a mechanical tsc emit from an assertion into a checked fact; its two import lines are the only edit. Biome's formatter now skips `packages/eval/harbor/**/vendor/**` for the reason it already skips the licence artifacts: a rewritten vendor file is no longer the upstream file. What is written here is the envelope parser and the tool. The grammar is transcribed from Codex's lark definition, and the model-facing description is Codex's own instructions text. Two deviations are deliberate and are stated to the model rather than left to be discovered by failure: - No `*** Delete File:` and no `*** Move to:`. `ctx.fs` has no delete or rename primitive and neither does any other harness file tool, so in every arm the model removes and renames through bash. - A patch either applies completely or changes nothing. `applyDiff` is pure, so every file's new content is computed before the first write. The contracts this will be compared against touch one file per call and cannot half-apply; a partly-patched tree would be a difference in failure modes rather than in edit format. Sources and licences are recorded in the plugins' NOTICE. The three test files run from the repository through `test:dist`, against the same `@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-fs` the toolchain installs — added as exact-pinned dev dependencies, which the lockfile takes as pure additions with no existing resolution changed. No arm composes this tool yet. Generated-by: Claude Code
Terminal-Bench results attribute a score to a framework, which leaves the edit contract inside the framework and unmeasured. Comparing Maka's `edit`/`write` against the harness's `str_replace_editor` cannot separate them either: that comparison moves the framework and the contract at once. So this holds everything else and moves only the contract. Three arms run the same one-shot CLI, the same toolchain and fingerprint, the same `dsh-fs-local` provider under every editor, and byte-identical `package.json` and `cordis.yml`: - `deepseek-harness` keeps `str_replace_editor` — one tool, four commands, unique-literal match, all guidance in the tool description. - `deepseek-harness-fs` takes the harness's own `read`/`write`/`edit` — three tools, snake_case arguments, three prompt sections of their own. - `deepseek-harness-apply-patch` takes the V4A tool added previously — one envelope, changes located by surrounding context. The variable is the whole contract, not one dimension of it. Tool count, argument names, path conventions, where the guidance lives, and the read-before-edit policy move together because each family ships that way. `dsh-fs-observation-policy` is mounted for the `fs` arm alone for that reason: its tools' own guidance text tells the model the policy is in force, so omitting it would leave the prompt describing a rule the deployment does not enforce, while `apply_patch` has no such concept and no read tool to satisfy one with. Three compositions in three files can drift apart while still producing numbers, and the numbers would no longer be about the contract. So the control is asserted rather than intended: the new test fails when the compositions differ by any row outside their editors, when the model, reasoning effort, sandbox mode, persona or either Bash deadline drifts in one of them, when the arms stop sharing one toolchain identity object, or when the experiment launches them with anything but a different profile argument. The arms also share one preparer, so a change to how the harness is launched cannot reach one and miss another, and one identity constant, so a re-pinned fingerprint cannot be applied to two arms out of three. The toolchain build now installs the plugin tree beside the harness's `node_modules` — a composition copied under `$DSH_HOME` cannot resolve a plugin by package name from there — and fails if a declared plugin did not land. Its dependencies are unchanged: every package the new arms compose was already in the reviewed lockfile. Re-pinning the fingerprint needs a `linux/amd64` container build and is not done here, so the arms are not yet runnable end to end. Generated-by: Claude Code
The toolchain now carries the `apply_patch` plugin beside the harness's `node_modules`, so its checksum manifest changed and the pin has to follow. Rebuilt with the same pinned `linux/amd64` image and the same reviewed lockfile; no dependency moved. Verified against the rebuilt tree rather than assumed. `dsh --dump-config` composes all three arm profiles successfully, and the only differences between the three composed trees are the editor rows and the `cwd` the two new arms give the filesystem backend — nothing else diverged. Loading the plugin from inside the toolchain resolves `@deepseek-ai/dsh-tools`, `dsh-fs`, `dsh-sandbox` and `schemastery` through the upward walk into `lib/dsh/node_modules`, registers `apply_patch` with the expected schema, and carries the patch instructions without the operations the tool does not implement. `dsh-tool-fs` and `dsh-fs-observation-policy` resolve too. Generated-by: Claude Code
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
The tool shipped without them, refusing both with a pointer to the shell. The stated reason was that `ctx.fs` has no delete or rename primitive, so reaching them through `node:fs` would put a second filesystem authority in the one arm being measured. That reason does not survive contact with the deployment it was written for: this arm mounts `dsh-sandbox-local` at `danger-full-access`, no observation policy is loaded, and the model's own bash can already remove any file in the container. The authority being protected was not enforcing anything. The cost was real, and it was the point of the arm. `apply_patch` exists here to measure Codex's contract; a patch tool missing a third of its grammar is a different contract, and a model trained on the real one pays for the difference in wasted turns. It also broke this PR's own rule — every other arm composes what its tool family ships, which is exactly why the `fs` arm mounts the observation policy. Two standards in one change. So both operations are implemented, against `ctx.fs.processPath(target)` — the provider's own answer to where a file is for something outside it to open, and for `dsh-fs-local` the local absolute path. `ctx.fs` stays the only thing that resolves paths; only the two syscalls it does not offer happen outside it. Semantics follow Codex: a rename writes the destination and then removes the source, overwrites an existing destination rather than refusing, and creates missing parents. Where the original concern is real, it is enforced instead of assumed: under a provider that confines, `processPath` would be the way around the sandbox, so delete and rename refuse up front. `sandboxMode` is what decides, so the refusal fires exactly when a provider actually confines. The parser already implemented the whole grammar, so no change there. The tool description loses its "not part of this tool" paragraph and returns to Codex's text, now with one removal rather than three: the trailing shell invocation example, which is false for a function tool. Tests move from an in-memory store to a real temporary directory, since these two operations are syscalls an in-memory store cannot observe. Red-checked: stubbing out the removal fails five behaviour tests, and disabling the confinement guard fails the two that cover it. Generated-by: Claude Code
The plugin gained its delete and rename paths, so the file it contributes to the toolchain changed and the checksum manifest with it. Rebuilt from the same pinned image and the same lockfile; no dependency moved. Verified against the rebuilt tree: all three arm profiles still compose under `dsh --dump-config`, and loading the plugin from inside the toolchain registers `apply_patch` carrying the full grammar — `*** Delete File:`, `*** Move to:`, and both productions — with the shell-invocation example still absent. Generated-by: Claude Code
Independent review of this arm found five places where the tool behaved differently from the reference it exists to measure. The asymmetry matters more than the count: a tool stricter than Codex costs its arm turns the reference never spends, and a tool safer than Codex wins its arm recoveries the reference never gets. Either way the benchmark reads the difference as an effect of the edit contract. `*** Add File:` did not terminate the created file with a newline, because the vendored applier joins its added lines while Codex appends `\n` after each one. Every file this arm created differed from Codex's byte for byte, and a test pinned the wrong result. Each added line is now terminated, and an empty create still writes nothing, as `String::new()` does upstream. The envelope was planned against one snapshot and written afterwards, so a failed hunk left the tree untouched. That is the nicer property and it was still wrong: two sections naming one path made the second fail against a version the first had replaced, and a delete followed by an update removed the file and then failed, leaving neither. Operations now apply in order, each reading what the previous one wrote. `*** Add File:` over an existing file was refused with an invented `FS_ALREADY_EXISTS`; Codex records what it overwrote and writes. A bare `*** Move to:` with no hunks was accepted; Codex rejects an empty chunk list before it reads the rename. A leading newline, a trailing space on the opening marker, and an indented envelope were all refused; Codex trims the patch and each marker line, while comparing only `trim_end()` inside an update body so an indented context line stays content. Content after `*** End Patch` was silently dropped, so two envelopes in one call reported success over operations that never ran. Three defects in how it reached the filesystem, none of which fire under the arm's own non-confining provider: - `mkdir` ran on every write through `processPath`, the same way out of a sandbox that delete and rename refuse for. A create outside a confining workspace had its write denied and still left the directory behind. - Delete and rename used the provider's realpath-derived target key, so deleting a symlink destroyed the file it pointed at and left the link dangling. They now use the lexical path, which is what Codex removes. - Hunk failures reached the model as a bare `Invalid Context 0`, naming neither the file nor the section, and two error codes were outside `FsErrorCode`'s closed union. The description's provenance note claimed one modification from upstream where there are four, and NOTICE claimed the description dropped the delete and move operations — true when written, false once they were implemented, and wrong in the direction that matters for a modification notice. 84 plugin tests pass. Each divergence is pinned by a test naming the upstream file and line it follows. Generated-by: Claude Code
Review found two differences between the arms that were not the edit contract, and a control test that could not have caught either. Only the `fs` arm mounted `dsh-fs-observation-policy`, justified in the profile by that family's guidance mentioning it. Checking how the harness ships does not support this: of the four presets in `@deepseek-ai/dsh`, three mount `dsh-tool-fs` and none mounts the policy, which is not even a direct dependency; and the policy's own documentation covers the baseline's editor too. It is substrate, not part of an editor. Mounting it in one arm gave that arm two failure modes the others cannot have — an edit without a prior read, and a file the model's own shell touched in between — on a benchmark where shell and editor are used together constantly. No arm mounts it now. The apply-patch profile's stated reason for omitting it was also wrong, and is corrected rather than quietly dropped: that arm emits `fs/observed` with the file's version before it writes, so the policy would have allowed the write. The two new arms configured `fs-local` with a `cwd` expression the baseline did not have. It could not change anything — the provider defaults `cwd` to `process.cwd()`, both tools pass the session's own cwd, and `DSH_CWD` is never set — but an unreachable setting is still a difference a reviewer has to rule out. Read budgets are now matched. The baseline pins its editor to 16000 output characters while `dsh-tool-fs` shipped a 51200-byte default, a 3.2x window on the same task. How much of a file one call returns is not part of the edit contract, so `readMaxBytes` is set to match. The remaining read-side asymmetry, that the patch arm has no read tool at all, is contract-inherent and stays. The control test read each `- id:` row with the `name:` on the line below and compared those strings, leaving every `config:` block unchecked. Narrowing one arm's context window, halving an editor's output budget, rewriting the shared bash description, reordering the rows, or overriding the patch tool's own description all kept it green. It now parses the YAML and compares whole entries, asserts composition order, and pins each arm's editor row by value — the treatment was the one thing no assertion covered. Verified by making each of those six edits and watching it fail. The arm-to-directory table moves to `toolchain-verification.ts`, beside the identity table, and the subject and the test both read it. The test used to restate it, so an arm pointed at the wrong profile would have been checked against the composition it was meant to run rather than the one it runs. The toolchain fingerprint is re-pinned for the plugin changes in the parent commit. Verified in a `linux/amd64` container: all three profiles compose, the composed trees differ only in the editor row, and the plugin loads from inside the toolchain and registers `apply_patch` with the full grammar. Generated-by: Claude Code
The apply_patch arm applied one file's hunks with the OpenAI Agents SDK's `applyDiff`, vendored on the assumption that one V4A implementation is like another. It is not. Running the released `codex` binary over the same trees shows four disagreements, each of which moves this arm's score: a context-free `+` hunk lands at the `@@` anchor rather than at end of file, an updated file keeps whatever trailing newline it had rather than gaining one, CRLF survives, and near-miss context is refused where Codex's four matching passes accept it. Replace it with a port of Codex's own applier and parser. The parser now reads hunks into chunks instead of passing section bodies through, so a syntax error in the third section is found before the first is written, and the tool no longer needs a second project's operation shape. Restore verify-then-write. An earlier round changed the tool to apply operations sequentially, matching the standalone `apply_patch` binary. That binary is not what a model calls: the function-tool handler and the intercepted shell call both go through `try_verify_apply_patch_args`, which collects hunks into a map keyed by resolved path, refuses a duplicate key, and computes every change against the pre-patch tree. So an envelope naming one path twice is refused, and a hunk whose context the model guessed at costs nothing. Fidelity is now measured rather than argued. `codex-oracle.mjs` runs the real binary over a temporary tree for each of 72 cases and records what it printed and what the tree became; `codex-fixtures.test.mjs` replays every case through the registered tool and compares. The two cases where the function tool deliberately differs from the standalone binary are named there with what it does instead. A fuzzer over 1800 random envelopes found no further divergence. Also tighten the arm-control test, which had three gaps: it skipped composition steps that override an already-composed entry, so an override of the patch tool's description was invisible; it named the profile files to compare instead of reading them; and it exempted the first subject's profile argument while comparing only the argument vector, not the whole subject definition. Toolchain fingerprint re-pinned. Test-only files move under `__tests__/` so the build prunes them by directory rather than by filename. Generated-by: Claude Code
`readMaxBytes: 16000` was justified as making "how much of a file one call returns" equal across the two read-capable arms. It does not. The baseline editor spends eight characters a line on line numbers plus a header; the fs tool emits neither, so measured over this repository's own sources it returns 10% more lines for the same budget and 22% on the longest files. The budget is still the right one, for a reason the note did not give: what should be equal is what a read costs the context window, and the numbering the baseline adds is part of its contract that the model pays tokens for either way. Equalising delivered content would mean picking a budget from an assumed average line length. Record the measurement, and record the one genuine mismatch that remains — the budgets are counted in UTF-16 code units against UTF-8 bytes, equal on ASCII and not otherwise. Generated-by: Claude Code
The fixtures pin the cases someone thought to write down. This searches for the rest: it generates envelopes from random line soup, applies each with the released binary and with the registered tool, and reports every input the two disagree on. 2100 envelopes across seven seeds currently diverge on nothing. Red-checked against three deliberate regressions — placing a context-free `+` hunk at the `@@` anchor, dropping the trailing-newline normalisation, and removing the punctuation-folding match pass. The third went unnoticed through 400 envelopes at first, because context lines drawn verbatim from the file only ever exercise the exact-match pass; the generator now half-remembers a line the way a model does, and catches it. Envelopes name each path at most once, and a refusal is compared as a refusal without comparing the tree, because those are exactly where the standalone binary and the function tool are supposed to differ. Generated-by: Claude Code
The function-tool handler verifies an envelope and then throws the result
away: `ApplyPatchRuntime::run` hands `req.action.patch` — the raw text the
model sent — back to `apply_patch_with_mode`, which re-parses it and applies
it sequentially against the live filesystem. The verified `changes` map is
carried for approval and telemetry only.
This tool verified and then wrote the plan it had just computed. That is not
a cheaper version of the same contract, and the reference binary shows the
difference in two cases now covered by tests: a rename onto a path a later
section also updates leaves that section failing against the renamed file,
and `*** Move to:` naming its own source writes the destination and then
unlinks it, deleting the file while reporting success. Both were previously
refused with an invented "multiple operations target" error, because the
verification map also claimed the move destination — upstream keys only on
`hunk.resolve_path()` and carries `move_path` as a value.
Also in the write path: `fs/edit-intent` yields `{version}` with no `kind`,
so passing it straight to `writeText` matched neither guard and silently
wrote unconditionally; the abort signal was not checked between operations;
and `rm` ran with `force: true`, reporting `D <path>` for a file that had
already gone.
In the applier: each fuzzy-matching pass now projects both sides once
instead of recomputing the punctuation fold per candidate position, and
replacements are rebuilt in one forward pass rather than spliced, which
threw a RangeError once a hunk added more lines than the argument limit.
The model-facing description is no longer transcribed into this file. It is
read from a byte-for-byte copy of the upstream artifact it came from, with
three named substitutions applied over it, so the claim that it differs in
exactly those places is checkable rather than asserted in a comment. Copying
the file rather than retyping it also removed a fourth difference that had
no reason to exist: upstream's typographic punctuation is now preserved.
Its recorded provenance was stale — the file it named no longer exists at
the pinned commit — and now points at the artifact that does.
One parameter description said the input was "Not JSON-wrapped". It is: the
harness registers JSON function tools and nothing else.
Generated-by: Claude CodeUpstream ships codex-rs/apply-patch/tests/fixtures/scenarios as, in its own README's words, an end-to-end suite "meant to be easily portable to other languages or platforms". This is the port: the 25 scenario directories are copied verbatim from the pinned commit and replayed through the registered tool, compared the way upstream compares them — copy `input/`, apply `patch.txt`, ignore the exit status, and require the resulting tree to equal `expected/` exactly. It is the only check here with the standing to contradict the code. The oracle fixtures run the reference binary, but over inputs this repository chose; the fuzzer perturbs inputs this repository chose how to perturb. These were chosen by upstream, before and independently of this tool. 22 of 25 match outright. Three do not, and each divergence is recorded with its cause and pinned to bytes the reference binary produces: - 015 specifies the standalone binary, which has no verification pass, so an add preceding a failing update survives. The function tool writes nothing. - 023 and 024 are specified under CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS, a feature that is `Stage::UnderDevelopment, default_enabled: false`, so a shipped Codex and this arm both run the `NormalizeToLf` default. Both divergence lists — here and in the oracle fixtures — now assert that each entry still diverges, so an excuse cannot outlive the thing it excused. Generated-by: Claude Code
The treatment in this arm is the tool name, the parameter it takes, and the description text. None of the three had any coverage, so a build that registered the tool under another name, dropped the parameter, or shipped a truncated description would have run a cohort and produced numbers. The description claim is the one that had to be argued rather than shown: "three differences from upstream and no others" was a comment. It is now checked at both ends. The vendored instructions file is pinned by digest, with the `git show ... | sed -n` that reproduces it from the source recorded beside the constant, and the built text is recomputed here by a different mechanism than the code uses and required to match. Each adaptation must apply exactly once, and no mention of the shell delivery may survive. Red-checked against four sabotages — appending a line to the description, renaming the tool, editing one byte of the vendored text, and neutering an adaptation. Each is caught. Generated-by: Claude Code
Two differences at the outermost layer of the patch, both pinned by new oracle fixtures generated from the reference binary. `parse_patch` selects its mode from `PARSE_IN_STRICT_MODE`, which is `false`: upstream gave up on gating leniency by model, so every call site including the function tool unwraps a heredoc-wrapped envelope. A model asked to send a patch may well send the `<<'EOF' ... EOF` form it was shown, and Codex applies it where this refused it. The strict boundaries are still tried first, so an envelope whose own first line reads `<<EOF` is never stripped. Rust's `str::trim` cuts the Unicode `White_Space` property; JavaScript's also cuts U+FEFF and does not cut U+0085. Only U+FEFF can realistically occur — a byte-order mark carried into a JSON string argument — and it made this tool accept a patch Codex refuses, which is the wrong direction for an arm being compared against the contract it imitates. Red-checked: restoring `String.prototype.trim` fails the byte-order-mark fixture, and disabling the heredoc strip fails both heredoc fixtures. Generated-by: Claude Code
The comment justifying `readMaxBytes: 16000` said `str_replace_editor` spends eight characters a line on line numbers and a header while "this tool emits neither". Both tools number their lines, and this one wraps the result in a `<path>/<type>/<content>` envelope with a footer. The setting still holds something equal, but not what was claimed. The baseline truncates the fully rendered string; this tool charges the budget against raw line bytes plus separators and adds the gutter, the envelope and the footer afterwards, outside it. Measured across this repository's 1480 TS/JS/Python sources at 16000, the arm delivers 1.105x the lines and emits 1.027x the characters, worst file 1.34x and 1.19x — a residual in this arm's favour, now recorded with the mechanism that produces it. The three profiles also claimed to have been "verified request-for-request" against the upstream composition. That was a manual comparison made once during development; nothing here reproduces it, and the headers now say so. `MAKA_EVAL_DSH_PLUGINS` was exported into all three arms and read by one, so the two controls carried an environment difference for a mechanism they do not use. It is now set from the copied composition itself, which keeps the condition and the file that depends on it from drifting apart. Generated-by: Claude Code
`apply_patch` in Codex is a freeform tool: the API is sent a short description
and a Lark grammar the decoder is constrained to, so a model on that path
cannot emit a malformed envelope. The harness registers JSON function tools
and has no grammar seam, so this arm is the unconstrained case. That is not a
detail of the port — it is a cost charged to this arm and to no other, and it
now leads the arm's description instead of being absent from it.
Three further divergences are named with their causes: the function-tool path
against the standalone binary's sequential apply, `NormalizeToLf` against a
preserve-line-endings feature that ships disabled, and diagnostics more
specific than Codex's single `Invalid patch:` line, which is the one place
this arm may be easier on a model than the reference.
The README also described a verify-then-write-the-plan design the tool no
longer has, quoted the old fixture count, and repeated the read-budget
mechanism corrected in the previous commit.
The NOTICE opened by saying nothing was carried verbatim as a file; two
things now are. Its description provenance named a file that no longer exists
at the ported commit and a fourth modification that was an artefact of
retyping rather than copying, its fixture path was stale, its account of how
`PreserveLineEndings` is selected covered only the standalone binary, and its
list of ported functions omitted the ones that decide apply order.
The arm-control test read a composition step as an insertion whenever it had
an `insert` key, so `{insert, remove}` would have passed with the removal
unread, and it filtered directories out of each profile's file list rather
than refusing them. It also now names what it does not cover.
Generated-by: Claude Code`dsh-fs-local` creates a write's parent itself, in `writeFileAtomic`, and it is the provider every arm mounts. The `ensureParent` here ran `mkdir` on a `processPath` — the same way around a confining sandbox that delete and rename refuse for — and existed only because the test harness's provider was weaker than the real one and failed with ENOENT without it. That is the more useful half of this change: a fake that cannot do what the real thing does will not merely miss bugs, it will make production code grow to cover for it. The fake now creates the parent the way `writeFileAtomic` does, and the tool has no call that creates a directory at all. The test that pinned the old behaviour was passing for the wrong reason — it asserted the tool left no directory behind, and what actually happened was the fake's write failing before anything could be created. Generated-by: Claude Code
…sses `deriveNewContents` reported a context miss as a plain `Error`, and the caller rewrapped anything it threw as `FS_EDIT_NOT_FOUND` — so a bug in the applier would have reached the model as "the lines you named are not in the file", which is advice to rewrite a correct patch. The context miss is now its own type and the only thing translated; a comment already claimed this and the code did not. Two oracle fixtures pin the order of the four matching passes against each other. The existing cases pinned the exact pass ahead of the loose ones; between the loose ones, removing a pass does not fail a match, it silently moves it to a different line, and nothing caught that. Each of the four now fails at least one fixture when removed. The line-ending note in codex-apply.mjs described only the standalone binary's environment variable, not the feature flag the function tool reads. The toolchain is rebuilt and its fingerprint re-pinned: the model-facing instructions now ship as a file, so they are covered by `checksums.sha256` rather than living only inside a JavaScript template. Verified inside the pinned linux/amd64 container that the built tree registers one tool named `apply_patch` with one required `input` string and a 2903-byte description carrying no mention of the shell delivery. Generated-by: Claude Code
Differential fuzzing against the reference binary found a regression this branch introduced. `applyReplacements` was rewritten to build the file forward in one pass, on the reasoning that the replacements are sorted and disjoint. They are sorted; they are not always disjoint. `computeReplacements` places a context-free insertion at the end of the file without consulting the search position — deliberately, because that is what `insertion_idx` does upstream — so it can land inside a range a later chunk replaces. Applying descending, the replacement swallows it; applying forward, the two are treated as separate edits and the inserted line survives into a file the reference never produces. Restored to splicing in descending order, with the replacement segment going in in bounded batches so a hunk larger than the engine's argument limit still does not throw. The case is now an oracle fixture, so it is caught without a `codex` on PATH; red-checked by reinstating the forward rebuild. 2300 further envelopes across four seeds diverge on nothing. Toolchain rebuilt and re-pinned. Generated-by: Claude Code
An adversarial review pass found nine behaviours that were wrong or untested
in the V4A tool, each verified against the reference binary or upstream source
before being changed. All nine are now covered: sabotaging any one of them
fails the suite, where before five of them left it green.
The matcher used JavaScript's `trim` where upstream uses Rust's. The two
disagree on U+FEFF and U+0085, so a file whose first line carried a byte-order
mark matched a pattern without one and was written back with the mark deleted,
and a line terminated by NEL was refused where the reference applies it. The
charset the parser already spelled out moves to rust-trim.mjs and both use it.
The verify pass now does what `try_verify_apply_patch_args` does: nothing at
all for `*** Add File:`, and a read for `*** Delete File:` as well as for an
update. Statting a delete target instead applied a delete the reference
refuses, since `read_file_text` maps invalid UTF-8 to `InvalidData` and fails
the whole envelope. It also stops emitting `fs/observed {present}` off a bare
stat, which told an observation policy a file had been seen that nothing read.
The duplicate-path key is lexical, as upstream's `PathUri::join` is, not the
provider's realpath-derived target key. An envelope naming both a symlink and
its target was refused here; the binary applies both sections in order.
A move destination no longer runs the `fs/write-intent` waterfall and discards
the answer, which turned a decider's refusal to clobber into a silent
overwrite. A failed unlink and an abort between passes now arrive as `FsError`
with a routable code and the path the model wrote, not a raw errno and a
resolved host path.
The test provider was weaker than `dsh-fs-local` in four ways that made the
above invisible: it decoded lossily where the real one raises `FS_NOT_TEXT`,
called every non-directory a file, took three parameters so the signal and
sandbox policy could not be observed, and never returned a decider's answer.
Two oracle cases are added for the trim charset and one for a trailing U+FEFF,
which is what separates the three loose matching passes from each other; the
BOM-in-content case is recorded as a divergence with its true cause, which is
that the shared provider strips a leading mark before this tool sees the file.
Generated-by: Claude CodeThe import landed the 25 scenario directories, their README and their .gitattributes twice: once at `__tests__/upstream/` and once, byte-identically, at `__tests__/upstream/scenarios/`. Only the nested copy is read. Generated-by: Claude Code
…arms The three arms are meant to differ only in the edit contract. Three settings outside that contract were not held. `readMaxLineLength` was left at its shipped 2000 while only `readMaxBytes` was matched to the baseline. That is a cap the baseline has no counterpart to: the fs arm could never see the 2001st character of any line, at any offset, while `str_replace_editor` truncates once over the whole rendered view and can spend its entire budget on one line. A minified bundle or a single-line payload is where that lands, and it lands against one arm only. It is raised to the budget, where it can no longer bind before `readMaxBytes` does. `streamIdleTimeoutMs` was omitted from all three profiles, leaving the adapter's 300000 ms default in force — five minutes between two streamed tokens, behind `reasoningEffort: max` and a 65-minute bash deadline. Upstream's own jsonrpc-agent composition sets 172800000, which is what the arms now carry; a turn killed by that timeout would have been scored as a failure of whichever contract was running. `maxConcurrentTaskGroups` was 64. A group holds one cell per subject and starts them together, so three arms made 192 concurrent trials where every other spec here lands on 128. That does not bias one arm against another, but it puts this run's `deepseek-harness` score under a load no single-arm run shares. It is now 43, and the test asserts the arithmetic rather than the number. The read-budget residual quoted in the profile and the README had no reproducible source and reported only the tail that favoured the fs arm. `scripts/measure-read-budget.mjs` reimplements both renderers from the shipped code and reports both tails; the figures are replaced with what it prints. Generated-by: Claude Code
A claims audit checked every factual assertion in the README, the NOTICE and the profiles against upstream source at the ported commit and against the shipped harness packages. Fourteen did not hold. The largest is provenance. `prompt_with_apply_patch_instructions.md` is not in any shipped Codex system prompt at the ported commit: it is referenced only from a test whose four model cases all declare `expects_apply_patch_description: false`, and none of the eight instruction templates in models.json carries the section. The file is upstream's own authored description of the format and it is retired, which is a better reason to use it than the one previously given, but the prose said Codex sends it. The freeform description is 108 characters, not 105. The oracle runs 82 cases, not 76. The fuzz figure is 2300 across four seeds, not 2100. Two of the named divergences were recorded in the tests, not three, and there are now four with the byte-order mark the provider strips. `verifyToolchainDirectory` covers ten registered profiles, not eight. `checksums.sha256` omits itself and manifest.json. The toolchain build's `__tests__` prune is scoped to the plugins it copies. The build script runs from the repository root. `ctx.fs` offers twelve methods, not three. The 0.147.0 delta includes a second behavioural difference — the duplicate-path check does not exist at that tag — and a larger restructure than the three items named. The profiles record three deviations from upstream, not one. Also stated where it was missing: the arm's lack of a read tool is Codex's contract rather than a handicap, since Codex registers no file-read tool either and every arm has the same bash. Generated-by: Claude Code
A second adversarial round found that the previous round's fix traded one divergence for another, and that twelve mutations of this tool still left the whole suite green. `ctx.fs.readText` is not `read_file_text`. Upstream reads bytes and calls `String::from_utf8`, so every valid UTF-8 sequence is text; `dsh-fs-local` adds two rules on top, and both are model-visible. It rejects a NUL in the first 8192 bytes as binary — but NUL is valid UTF-8, and the reference patches and deletes straight through it, verified against the binary — so making the verify pass read delete targets took `*** Delete File:` on such a file with it. And it decodes with `ignoreBOM` left false, which strips a leading byte-order mark, so a patch whose context omits the mark applied here, was refused there, and wrote the file back without the mark. Reading through `ctx.fs.readBytes` and decoding here is exactly `String::from_utf8`; it fixes both, and the byte-order-mark divergence is deleted rather than documented. `*** End of File` as the first line of an update hunk was refused. Upstream guards with `chunks.last().is_some_and(blank)`, which is false when there is no chunk, so the marker is ignored; the binary applies the envelope. Twelve mutations that the suite could not see, now each red-checked: five dropped the turn signal from a provider call, which a fake that ignored the signal could not notice; two removed or falsified the version on `fs/observed`; two swapped a removal's announcement with the removal itself, which leaves the event list identical and the trail a lie; one bypassed the create decider and one renamed its event; one reduced the sandbox-denial remap to a no-op. The fake now honours and records the signal on every call, records what the filesystem actually held when each observation was published, records the version `writeText` returned, and can be made to deny a write. Twelve oracle cases added, generated from the binary: three for the read seam and nine for shapes that separate this port from a plausible variant of it — overlapping chunks, a backward `@@` anchor, an indented header after an add body, a trailing mark on an End of File marker, header paths with a leading space, a repeated `*** Move to:`, a heredoc close with trailing text, and the End of File case above. Eleven passed unchanged, which is the point of adding them; one was the defect. `requireDirectFilesystem` now raises an `FsError` like every other refusal. Generated-by: Claude Code
… meant to help Raising `readMaxLineLength` from its shipped 2000 to the 16000 budget was wrong, and worse than the asymmetry it was meant to close. `dsh-tool-fs` truncates a long line and appends a `... (line truncated to N chars)` marker, then charges the whole thing against `readMaxBytes`. Once the cap approaches the budget the truncated line no longer fits at all: the window returns zero lines, drops every line after it, and the footer invites the model to retry at the offset it just used, so the rest of the file is unreachable. Measured against the shipped functions, a 20000-character line followed by 200 short ones delivers 201 lines at 2000 and 0 lines at 16000. No value equalises the two budgets anyway — one counts UTF-16 code units and the other UTF-8 bytes — so the cap goes back to the value the tool ships with and the residual is measured instead of engineered away. At 2000 the direction is also not the one the earlier reasoning assumed: on that same long-line file the baseline spends its whole budget on the one line and delivers 1, while this arm truncates it, says so, and reads on. The guard test now asserts the invariant rather than the number, so any future cap has to be one a truncated line can still fit under. The measurement script had four transcription errors, all inflating the character ratio: it dropped the baseline's 240-character truncation notice and the fs arm's per-line marker, counted the baseline's prompt line as content, and counted a phantom trailing line for the fs arm. It also silently ate the first positional path when `--budget` was absent. Rather than fix the transcription and hope, `--self-check <dir>` now lifts the real functions out of an installed copy and asserts agreement, and the corpus figures are reported alongside synthetic long-line cases that the repository's own sources do not contain. Corrected: 2197 files, 1.093x lines, 1.001x characters, tails reported as what they are — extremes among the 570 files where a budget binds. `maxConcurrentTaskGroups` moves 43 to 42: three arms at 43 is 129 trials, and the assertion had been written as `<= 129` to admit it. Generated-by: Claude Code
The claim that upstream's `## apply_patch` prose is retired was too strong. Of the eight instruction templates in `models-manager/models.json` at the ported commit, seven carry no such section — but `gpt-5.2` carries a shorter one with the same envelope, the same three headers and the same worked example. So the format is still taught in prose upstream, and the vendored file is the fuller version of that prose rather than something abandoned. The conclusion it was supporting survives unchanged and for a cleaner reason: `gpt-5.2` gets the prose *and* the grammar, so no shipped configuration is the one this arm is in. Codex's handler set is much longer than the six entries named, and one of them takes a filesystem path — `view_image`, for images. The load-bearing claim is that no handler reads file *text*, which is what the prose now says. The NOTICE gave the wrong cause for the duplicate-path divergence: the recorded binary composes an envelope naming one path twice because the standalone applier has no verification pass at either version, not because the check is absent at rust-v0.147.0 — that is a function-tool difference. Also: `invocation.rs:246-248` is the delete arm, not the add arm; the `fs/edit-intent` shape citation named only one of the two declarations; the end-of-file search has no fallback to the ordinary start position, contrary to its comment; `__tests__/upstream/` kept a stray `.gitattributes` naming paths one level down; and the build script's header credited `npm i` for a non-reproducible fingerprint when it runs `npm ci` and the real cause is native compilation. The refusal under a confining provider is described as what it is: dead code under `dsh-fs-local`, which never sets `sandboxMode` at any sandbox mode, not something `danger-full-access` switches off. Generated-by: Claude Code
…t reads it The comment justifying the conditional export had the causation backwards. The model's shell inherits this environment, so setting the variable in all three arms is what makes them identical, and setting it in one arm alone is what creates a difference the model can read with `env`. It names a directory and starts nothing, so it is inert in the two arms whose compositions never mention it. The remaining environment asymmetry, `$HOME` carrying the profile name, is recorded rather than removed: the arms need separate homes because each writes its own composition under `$DSH_HOME/profiles`, and sharing one would have each overwrite what the others booted from. So the experiment is not blind to its subject, and that is now stated where the home is built. Generated-by: Claude Code
The spec declared 42 task groups, or 126 concurrent trials, on the strength of a 128-trial convention rather than a machine. The account's CVM quota is 60 vCPU per zone, which caps the host at 56 vCPU and 256 GiB, and the only completed run of this suite put 24 trials on 32 vCPU / 64 GiB — 2.7 GiB per trial. 126 trials on that host is 2.0 GiB each, close enough to a Terminal Bench build task's peak that an OOM kill would land as an infra failure. 32 groups is 96 trials at the density that is known to survive. Generated-by: Claude Code
The arm named its plugin with `name: !!js process.env.MAKA_EVAL_DSH_PLUGINS + '/tool-apply-patch/index.mjs'`. The harness's loader evaluates that tag for `disabled` alone and hands `name` the unevaluated node, so every trial of that arm died at boot with `name.startsWith is not a function` before it reached the model — on a rented host, at the first smoke task. The row now names `./plugins/tool-apply-patch/index.mjs`, resolved against the profile directory, and the subject plants `plugins` there as a link into the toolchain. That keeps what the loader imports inside the fingerprinted read-only tree, keeps the composition a plain string, and keeps the path independent of the root the wrapper was given — which naming the mount point outright would not. The link is planted for all three arms, on the same reasoning that exported the variable for all three: the model's shell can read $DSH_HOME, so planting it in one arm alone is the difference, not planting it everywhere. The test could not have caught this. It extended its own YAML schema to construct `!!js <source>` as a string, which taught the assertion to accept a form the harness cannot load. The tag is real elsewhere in these compositions — `task: !!js ctx.headlessStartup.task` is upstream's — so the schema now constructs it as the marker shape the harness's loader produces, and a new assertion requires every entry name to be a string and every path-named plugin to be one the toolchain build ships. Reverting the row fails both. Generated-by: Claude Code
The wrapper counted the subject's stderr bytes and hashed them, then dropped them. The relay writes `maka-subject.stderr.txt` from the wrapper's own stderr, which is empty, so a failing arm's own account of what killed it reached the attempt record as a byte count and a digest and nothing else. Both failures this run has had were diagnosed by reproducing them by hand on the host instead: a composition the harness could not load, and a subject that exits after one model response. The second is still open. The last 64 KiB now land in `/logs/agent/<profile>.stderr.txt`, which the relay already collects, as a `stderr-tail` artifact beside the existing `stderr` count. A tail because a subject that loops printing would otherwise fill the trial's disk, and because a harness says what killed it on the way out; the count and digest still describe the whole stream, so truncation is visible. Generated-by: Claude Code
Announcing it as an artifact pushed the frame past the relay's 2 KiB payload cap, so every cell came back `external subject result transport failed`. `/logs/agent` is collected into the trial directory regardless, so writing the file is the whole job. Generated-by: Claude Code
Every failure before the subject starts reached the operator as the string `external subject setup failed`, because `safeFailure` sanitizes what the published result frame may say and nothing recorded the error itself. The process's own stderr is collected into the trial as `maka-subject.stderr.txt`, which is where a subject's account of its own failure belongs, so the error, its stack, and its cause chain go there. Generated-by: Claude Code
This module does its work at the top level, above the function that reads the subject's stderr, so a `const` declared beside that reader is still in its temporal dead zone when the reader runs. Every cell died with `ReferenceError: Cannot access 'STDERR_TAIL_BYTES' before initialization` before the subject's first model response — reported as `external subject setup failed`, and legible only because the commit before this one started writing the error itself. Generated-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A Terminal-Bench score is attributed to a framework, which leaves the edit contract inside the framework and unmeasured. Comparing Maka's
edit/writeagainst the harness'sstr_replace_editorcannot separate them either — that comparison moves the framework and the contract at once.These three arms hold everything else and move only the contract:
deepseek-harnessstr_replace_editor— one tool, four commands, unique-literal match, all guidance in the tool descriptiondeepseek-harness-fsread/write/edit— three tools, snake_case, three prompt sections of their owndeepseek-harness-apply-patchapply_patch— one envelope, changes located by surrounding contextSame one-shot CLI, same toolchain and fingerprint, same
dsh-fs-localprovider under every editor, byte-identicalpackage.jsonandcordis.yml. The variable is the whole tool family and not one dimension of it: tool count, argument names, path conventions and where the guidance lives move together, because each family ships that way. A result is attributable to the family as shipped, not to patch syntax as such.No arm mounts
dsh-fs-observation-policy. It was mounted for thefsarm alone on the reasoning that its tools' guidance mentions the policy; of the four presets in@deepseek-ai/dsh, three mountdsh-tool-fsand none mounts the policy, and its own documentation coversstr_replace_editortoo. Mounting it in one arm gave that arm two failure modes the others cannot have.Three compositions in three files can drift apart while still producing numbers, so the control is asserted rather than intended.
edit-contract-arms.test.tsfails when the compositions differ by any row outside their editors, when they compose in a different order, when the model, context window, reasoning effort, stream idle timeout, sandbox mode, persona or either Bash deadline drifts in one, when the arms stop sharing one toolchain identity object, when the experiment launches them with anything but a different profile argument, or when arms multiplied by concurrency leaves the 128-trial convention.The patch arm is ours, and that is the thing to review
The harness ships no patch tool, so
plugins/tool-apply-patch/is this repository's while the other two arms run their vendor's. Fidelity to Codex is therefore itself an experimental variable, and it is asymmetric: a tool stricter than the reference costs its arm turns the reference never spends, and a safer one wins recoveries the reference never gets.One deviation cannot be removed.
apply_patchin Codex is a freeform tool — the API is sent a 108-character description plus a Lark grammar the decoder is constrained to, so a model on that path cannot emit a malformed envelope. The harness registers JSON function tools and has no grammar seam. This arm is therefore the unconstrained case, and syntax errors it can make are errors Codex's decoder would have prevented. That cost is charged to this arm and to no other, and it leads the arm's description in the README.What is not charged to it is the absence of a read tool. Codex registers no handler that reads file text either — its handler set is long, and the only entry taking a filesystem path to read is
view_image, which takes images — so an arm that reads withcatis that contract, and every arm has the same bash at the same 16000-character cap.What the model is told is upstream's own prose for exactly that case: the
## apply_patchsection ofprompt_with_apply_patch_instructions.md, carried verbatim asupstream-apply-patch-instructions.mdwith three substitutions applied over it. A test pins the file's digest, requires each substitution to apply exactly once, and requires the built description to differ from the copy nowhere else. That file is upstream's and it is not delivered from where it sits: at the ported commit it is referenced only from a test whose four model cases all declareexpects_apply_patch_description: false. Seven of the eight instruction templates inmodels-manager/models.jsoncarry no## apply_patchsection and leave the format to the grammar; the eighth,gpt-5.2, carries a shorter one with the same envelope and worked example. So the format is still taught in prose upstream and this is the fuller version of it. Either way no shipped configuration is the one this arm is in —gpt-5.2gets prose and grammar — so the choice was between upstream's own text and text written here.Three further divergences are known and each is pinned to bytes the reference produces:
NormalizeToLf, the shipped default, wherePreserveLineEndingsisStage::UnderDevelopment, default_enabled: falseupstream;A third divergence was recorded here and has since been removed rather than documented: the provider's
readTextstrips a leading byte-order mark and rejects a NUL byte, neither of whichString::from_utf8does. Reading throughreadBytesand decoding in the tool matches the reference exactly.The grammar is implemented in full.
*** Delete File:and*** Move to:are the only operationsctx.fshas no primitive for, so they run againstctx.fs.processPath(target); under a provider that confines, both refuse up front rather than escape. This arm mountsdanger-full-access, where the refusal never fires.Sources and licences are in the plugins'
NOTICE. An earlier revision of this branch vendored the OpenAI Agents SDK'sapplyDiffas the hunk applier; it is a different V4A implementation, not Codex's, and nothing from it remains.What the adversarial review rounds changed
Two rounds of four fresh-eye passes each — the port against the binary, the harness integration, the three-arm control, and an audit of every factual claim. The second round reviewed the first round's fixes, which is what caught the two entries below that begin "the previous fix". Every finding was re-verified against upstream source or the reference binary before being acted on.
Nine port defects, all now red-checked. The matcher used JavaScript's
trimwhere upstream uses Rust's, so a file whose first line carried a byte-order mark matched a pattern without one and was written back with the mark deleted, and a line terminated by U+0085 was refused where the reference applies it. The verify pass stated a delete target instead of reading it, applying deletes of non-UTF-8 files that the reference refuses outright. It emittedfs/observed {present}off a bare stat, telling an observation policy a file had been seen that nothing read. The duplicate-path key was the provider's realpath-derived target key rather than upstream's lexical join, so an envelope naming both a symlink and its target was refused where the binary applies both. A move destination ran thefs/write-intentwaterfall and discarded the answer, turning a decider's refusal to clobber into a silent overwrite. A failed unlink and an abort between passes escaped the seam's error taxonomy.Five of those were invisible because the test provider was weaker than
dsh-fs-local: it decoded lossily, called every non-directory a file, took three parameters so the signal and sandbox policy could not be observed, and never returned a decider's answer.The previous fix read patch targets through the wrong seam.
ctx.fs.readTextis notread_file_text: upstream reads bytes and callsString::from_utf8, whiledsh-fs-localadditionally rejects a NUL in the first 8192 bytes and strips a leading byte-order mark. NUL is valid UTF-8 and the reference patches and deletes straight through it, so making the verify pass read delete targets — itself a correct fix — took*** Delete File:on such a file with it. Reading throughreadBytesand decoding in the tool is exactlyString::from_utf8, and it removes the byte-order-mark divergence too. Separately,*** End of Fileas the first line of an update hunk was refused where upstream ignores it.Twelve more mutations the suite could not see. Five dropped the turn signal from a provider call, which a fake that ignored the signal could not notice; two removed or falsified the version on
fs/observed; two swapped a removal's announcement with the removal itself, which leaves the event list identical and the trail a lie; one bypassed the create decider, one renamed its event, one made the sandbox-denial remap a no-op. The fake now honours and records the signal on every call, records what the filesystem held when each observation was published, records the versionwriteTextreturned, and can be made to deny a write.Three uncontrolled differences between the arms, of which one fix was itself wrong.
streamIdleTimeoutMswas omitted from all three profiles, leaving a five-minute ceiling on the gap between two streamed tokens behindreasoningEffort: maxand a 65-minute bash deadline; it now carries upstream's own 172800000.maxConcurrentTaskGroups: 64made 192 concurrent trials where the saturating specs here land on 128; it is now 42.readMaxLineLengthwas raised from its shipped 2000 to the budget to close a cap the baseline has no counterpart to, and that was worse than the asymmetry. The tool appends a... (line truncated to N chars)marker and then charges the whole line againstreadMaxBytes, so a cap near the budget makes a long line not fit at all, drops every line after it, and returns a window no offset recovers — 201 lines at 2000 becomes 0 at 16000. No value equalises the two budgets anyway, one counting UTF-16 code units and the other UTF-8 bytes, so the cap is back where the tool ships it and the guard test asserts the invariant rather than the number.Twenty-one false claims across the two rounds, listed in the two docs commits. The largest was the provenance one, twice: first that the vendored prose is what Codex sends, then that it is retired. The read-budget script had four transcription errors of its own, all inflating the character ratio, so it no longer asks to be trusted —
--self-check <dir>lifts the real renderers out of an installed copy and asserts agreement, and the figures are 1.093x lines and 1.001x characters over 2197 files.Verification
Lint, format and typecheck clean. 47 TypeScript and 200 plugin tests pass.
Fidelity is measured against the binary rather than argued from the Rust:
codex-oracle.mjsruns the releasedcodexunder argv0apply_patchover a temporary tree for each of 94 cases and records what it printed and what the tree became;codex-fixtures.test.mjsreplays every case through the registered tool and needs nocodexto do it.__tests__/upstream/scenarios/carries Codex's own conformance suite verbatim — the 25 scenario directories upstream publishes as "meant to be easily portable to other languages or platforms" — andupstream-scenarios.test.mjsreplays them upstream's way: copy the input tree, apply the patch, ignore the exit status, require the resulting tree to match exactly. 22 match outright; the three that do not are named divergences. These are the only inputs here this repository did not choose.codex-fuzz.mjsgenerates envelopes from random line soup, including near-miss context, and runs both. It earned its keep on this branch: it caught a regression introduced here, where rebuilding the file forward let a context-free insertion survive inside a range a later chunk replaces. 2300 envelopes across four seeds diverge on nothing.Red-checked, not just green:
String.prototype.trimin the loose passes fails the trailing-U+FEFF fixture; disabling the heredoc strip fails both heredoc fixtures; matching the heredoc close withincludesinstead ofendsWithfails the trailing-text fixture.End-to-end against the rebuilt
linux/amd64toolchain: loading the plugin from inside the built tree registers exactly one tool namedapply_patch, with one requiredinputstring and a 2903-byte description carrying no mention of the shell delivery. The instructions file ships, so it is covered bychecksums.sha256and the re-pinned fingerprint.Not run: an actual benchmark cohort. No dependency moved.
Review focus
One claim this repository does not reproduce, and which should be read as a one-time manual check rather than a standing guarantee: the three profiles were compared against the upstream
examples/jsonrpc-agentcomposition during development — tool names, tool schemas, system prompt and message sequence on the first outbound request — and found identical.The read budget matched at 16000 equalises the budget's number, not delivered content.
scripts/measure-read-budget.mjsmakes the residual checkable and--self-checkmakes the script itself checkable: over 2197 tracked sources thefsarm delivers 1.093x the lines and 1.001x the characters; among the 570 files where a budget binds, the extremes are 1.398x/1.168x and 0.837x/0.720x.Two things a write-up must carry that this branch cannot fix. The
fsarm receives 764 characters of tool-contributed system-prompt guidance where the other two receive none, and two of its three sections reference anfs-observation-policyno arm mounts. And the treatment is the whole tool family plus that guidance, not "the edit contract" — the arm names are shorter than the thing being varied.experiments/.../edit-contracts.jsondeclaresrepetitions: 1, so 89 tasks x 3 subjects is 267 cells. Cost, not power, chose that. McNemar at one repetition is weak for the effect sizes this is likely to see: on ~20% discordant pairs a true 5pp difference is about 2 sigma before any correction for three pairwise comparisons, so a write-up has to report discordant counts and intervals rather than three success rates.Stacked on #2971.
Checklist
Does this PR entail a change in behavior?