fix(rust-backend): a written []T parameter is &mut [T], to a fixpoint - #3403
Conversation
Closes #3402 gen-rust rendered every `[]T` parameter as `Vec<T>` -- by value, without `mut` -- and then emitted `buf[i] = x` into it. Measured on master: 189 specs declare a slice parameter, 174 parse, 23 emit Rust that index-assigns into one, and 0 of those 23 compile. The path is uniformly broken, so this cannot regress a working case -- there are none. Two defects, and the second is the worse one. It does not compile (E0382 use of moved value, E0596 cannot borrow as mutable). And had it compiled, the writes would have landed in a moved copy and the caller would have seen nothing, so a plausible future repair -- adding `mut` to the binding -- would have made it compile and left it silently wrong. The C backend passes a pointer and the Verilog backend gives the caller its writes; Rust was the column disagreeing with the other three, in a project whose stated property is that the targets agree. A per-function scan cannot see the whole class. `char_to_trits` never assigns into its own `trits`; it hands it to `byte_to_trits`, which does. Marking only direct writers gives the caller `Vec<i32>` and the callee `&mut [i32]`, and the call between them is E0308. `collect_written_slice_params` iterates to a fixpoint over calls, following an argument only when it is a bare identifier naming a slice parameter of the caller -- anything else is not a parameter being threaded through and is left alone rather than guessed at. Deliberately narrow. A fixed `[3]T` is not a slice and keeps its by-value rendering. Return types and struct fields are untouched. An element type the mapping did not produce as `Vec<T>` is left exactly as master rendered it: `[]const u8` renders `Vec<const>`, and rewriting that to `&[const]` was the one genuinely introduced error found while measuring. Worth, over all 650 specs: move/borrow errors fall from 33 files / 143 diagnostics to 14 / 45. rustc acceptance is unchanged at 430 of 650 (312 of them containing a function) -- the second consecutive fix with a zero acceptance delta, because a rejected file carries four or five error families rather than one. 158 files' emitted Rust changed; 29 lost an error class and 16 gained one. Every newly reported diagnostic was checked against the line it blames: 60 REVEALED, 0 INTRODUCED. Each blames a line byte-identical in both outputs, reached only because an earlier name-resolution error no longer aborts the compile. The dominant one, E0615 on `data.len`, is a separate pre-existing defect and is filed in #3402 rather than folded in here. Three tests in backend_behaviour.rs, two of which COMPILE AND RUN the output and assert the caller observes the writes -- a signature test alone cannot tell a correct out-parameter from one whose writes go into a copy. Mutation-checked: disabling the inductive step fails the chain test and leaves the base-case test green. FROZEN_HASH updated in the same commit, as M5 requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
📓 NotebookLM Notebook linked to this PR
This notebook contains session context, decisions, and artifacts for this work. |
Refs #3402 Two adversarial reviewers found the same hole, and it was a contradiction inside the previous commit: its comment said "only the written-into slices move to `&mut [T]` ... rewriting a read-only one would change call sites for no defect", and three lines below, the code rewrote every slice parameter. An experiment that widened the rule had been measured and never reverted. The consequence: `[]T` meant `&[T]` in parameter position and `Vec<T>` in return, field and local position, so fn join(base: []u8, name: []u8) []u8 { var r : []u8 = base; } emitted `base: &[u8]` beside `let mut r: Vec<u8> = base;` and E0308. Zig renders `[]u8` in every position and C renders `uint8_t*` in every position; only Rust would have disagreed with itself, in the one place this project claims the targets agree. The guard now has three outcomes, and the third is a byte-for-byte no-op: a slice parameter the fixpoint does not mark is emitted exactly as master emits it. Re-measured over all 650 specs, narrow against master: move/borrow (E0382 + E0596) 33 files / 143 diags -> 24 / 117 total diagnostics 3773 -> 3747 (down 26) files whose output changed 158 -> 42 rustc accepts 430 -> 430 The wide version read 14 files / 45 diagnostics on the first line and +171 on the second. That sign is the evidence: it bought the move/borrow reduction by breaking read-only parameters that master compiled. Of the 42 changed files, 7 are strictly better and 5 show a higher count of one class -- all E0615 on `data.len`, checked line by line as 124 REVEALED and 0 INTRODUCED, each blaming a line byte-identical in both outputs and reached only because an earlier error no longer aborts the compile. That defect is filed in #3402. Also recorded: the instrument that missed this. The before/after compared the SET of error classes per file, so an E0308 added to a file that already had one was invisible. Counting occurrences per class per file showed it immediately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
📓 NotebookLM Notebook linked to this PR
This notebook contains session context, decisions, and artifacts for this work. |
PR DashboardGenerated at: 2026-09-07 19:21:53 UTC
Summary
Seal Status
|
|
Adversarial review found a real hole, and it has been fixed in What the reviewers foundTwo skeptics attacking from different angles landed on the same thing, and it was a contradiction inside my own diff. The comment said:
and three lines below it, the code rewrote every slice parameter. An experiment that widened the rule had been measured and never reverted; the comment was the version I intended and the code was the version I pushed. The consequence they named precisely: became pub fn join(base: &[u8], name: &[u8]) -> Vec<u8> { let mut result: Vec<u8> = base; ... }
^ E0308Zig renders My instrument could not have caught itThe before/after compared the set of error classes per file. An The corrected measurementThe guard now has three outcomes, and the third is a byte-for-byte no-op: an unmarked slice parameter is emitted exactly as master emits it.
The sign of the third row is the evidence. The wide version bought its better move/borrow number by breaking read-only parameters that master compiled. The narrow one moves the targeted class and moves total diagnostics down. Of the 42 changed files, 7 are strictly better and 5 show a higher count of one class — all Tests are 13 green. The read-only assertion was corrected with the code — it had asserted |
#3405) Refs #3402 #3403 rewrote a written `[]T` parameter to `&mut [T]` and never rewrote the argument. Adversarial review measured the gap: of 608 call-site arguments landing in a slice parameter position across the generated corpus, zero gained a borrow. So `tritwise_and(a, b, temp, len)` read expected `&mut [i32]`, found `[i32; 27]` with a correct signature and an uncorrected call. A call site now borrows the argument that lands in a marked slot. It does NOT borrow one that is already a `&mut [T]` parameter of the calling function: passing a borrow onward is a reborrow, and a second `&mut` is an error. That distinction cannot be made from the argument text alone, which is why `current_mut_slice_params` exists; `collect_param_names` supplies the map from argument POSITION to the parameter NAME that `written_slice_params` is keyed by. Measured over all 650 specs against master: total diagnostics 3773 -> 3745 move/borrow (E0382 + E0596) 33 files / 143 -> 24 / 117 rustc accepts 430 -> 430 specs/isa/ternary_bitwise 12 errors -> 3, across both halves 41 files' output changed, 7 strictly better, 5 show a higher count of one class -- all E0615 on `data.len`, already verified as blaming byte-identical lines reached only because an earlier error no longer aborts the compile (#3402). The corpus value of this half is two diagnostics. It is worth having regardless: passing a local buffer into a kernel is exactly the shape a ported hand-written kernel needs, and without it the parameter fix is unusable from any caller that does not already hold a borrow. Writing the test surfaced a third, separate defect: `var tmp : [4]i32 = undefined;` lowers to `let mut tmp: [i32; 4];` with no initialiser, so E0381 fires before the call is type-checked at all. The fixture uses `[_]i32{0, 0, 0, 0}` so the test measures the rule it exists for. Mutation-checked twice: removing the reborrow guard fails three tests, removing the borrow fails two. FROZEN_HASH updated in the same commit, as M5 requires. Co-authored-by: lab <lab@example.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…rom now on (#3416) Closes #3415 A seal records four `gen_hash_*` fields -- the hashes of what each backend emitted. Nothing checked them. Measured on master: 109 stale gen_hash_rust, 7 stale gen_hash_zig, 0 C, 0 Verilog, and 0 stale spec_hash. All 116 were left by five merged backend repairs -- #3401, #3403, #3405, #3407, #3411 -- every one of them mine. None touched a spec, so spec_hash stayed correct and every coverage and staleness check in the repository stayed green while a sixth of the Rust seals described output the compiler no longer produces. The existing checks cover the other half. check_seal_coverage.py asks whether a seal describes a spec that exists, unchanged at SOURCE. `Seal Staleness Warning` is about the NMSE manifest and FROZEN_HASH, unrelated to .trinity/seals, and exits 0 by design. `t27c seal --verify` answers this question exactly -- exit 1 with a precise MISMATCH line, exit 0 on a current seal, both used as controls here -- and nothing called it across the corpus. The refresh took three attempts and each failure was informative. `--save` fixed 116 -> 58, not 0, because it writes to .trinity/seals/<module>.json, one name, while 1313 seals cover 728 distinct specs and 547 specs carry more than one seal file. The remaining 58 duplicates are rewritten in place. `sealed_at` is left alone in those: rewriting a hash the tool itself just computed is not a fresh certification event, and moving the timestamp would claim one. gen_hash=none is a different debt and is counted apart -- 169 seals record it for at least one backend, and --save refuses to overwrite them ("4 of 4 backends rejected it"). Conflating the two is how the 116 stayed invisible. tools/check_seal_currency.py now asks the question. Its --self-check plants a wrong hash on a scratch tree and requires exactly that seal to be reported, because a zero from a check that cannot see is indistinguishable from a healthy zero. It exits 2 when t27c is absent rather than 0: a check that could not run has not passed. Filed, not guessed: some duplicate seals are not named after a module at all. specs/tri/utils/logger.t27 carries `"[]const u8".json`, `utils_"[]const u8".json` and `utils_TriLogger.json` -- a seal named after a type string. After this: 1318 seals scanned, 1055 current, 94 whose spec is gone, 169 sealed with none, 0 stale. Co-authored-by: lab <lab@example.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #3402
gen-rustrendered every[]Tparameter asVec<T>— by value, withoutmut— and then emittedbuf[i] = xinto it.The population, before the change
The path is uniformly broken, so this change cannot regress a working case — there are none. That is why it is a defect rather than a design decision, which is how I wrongly framed it a pass ago.
Two defects, and the second is worse
E0382use of moved value,E0596cannot borrow as mutable.mutto the binding — makes it compile and leaves it wrong, which is why a signature-level test would not be enough here.The C backend passes a pointer and the Verilog backend gives the caller its writes. Rust was the one column disagreeing with the other three, in a project whose stated property is that the targets agree.
The chain a per-function scan cannot see
char_to_tritsnever assigns into its owntrits— it hands it tobyte_to_trits, which does. Marking only direct writers gives the callerVec<i32>and the callee&mut [i32], and the call between them isE0308.collect_written_slice_paramsiterates to a fixpoint over calls, following an argument only when it is a bare identifier naming a slice parameter of the caller; anything else is not a parameter being threaded through and is left alone rather than guessed at.Deliberately narrow
[3]Tis not a slice and keeps its by-value rendering.Vec<T>is left exactly as master rendered it.[]const u8rendersVec<const>, and rewriting that to&[const]was the one genuinely introduced error found while measuring — now excluded by requiring a plausible type name.What it is worth
Acceptance is unchanged. This is the second consecutive fix with a zero acceptance delta, and the reason is the repository’s established shape: a rejected file carries four or five error families, not one. The class this change targets falls by 58% of files and 69% of diagnostics; that is the number this PR should be judged on.
Every new diagnostic was checked, not counted
158 files’ output changed: 29 lost an error class, 16 gained one. Rather than report that as 16 regressions, each newly reported diagnostic was compared against the line it blames:
Every one blames a line that is byte-identical in both outputs — reached only because an earlier name-resolution error no longer aborts the compile before type-checking. The dominant case,
error[E0615]onwhile (... && (i < data.len))in 13 files, is a separate pre-existing defect (Rust exposes length as a method) and is filed in #3402 rather than folded in here.Tests
Three, in
backend_behaviour.rs. Two of them compile and run the output and assert the caller observes the writes —a[0]+a[1]+a[2]reads21, and0is exactly what a correct-looking signature over a copied buffer would print. A signature test alone cannot tell those apart.Mutation-checked: disabling the inductive step of the fixpoint fails
an_out_parameter_threaded_through_a_call_is_still_an_out_parameterand leaves the base-case test green, so the two are independent and the chain test is specific to the step it guards.bootstrap/stage0/FROZEN_HASHis updated in the same commit, as M5 requires.Not auto-merging: an adversarial review of this fixpoint is still running (aliasing under
&mut, termination, cross-backend consistency). I will report what it finds before merging.