Skip to content

fix(rust-backend): a written []T parameter is &mut [T], to a fixpoint - #3403

Merged
gHashTag merged 2 commits into
masterfrom
fix/array-out-params
Sep 7, 2026
Merged

fix(rust-backend): a written []T parameter is &mut [T], to a fixpoint#3403
gHashTag merged 2 commits into
masterfrom
fix/array-out-params

Conversation

@gHashTag

@gHashTag gHashTag commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Closes #3402

gen-rust rendered every []T parameter as Vec<T>by value, without mut — and then emitted buf[i] = x into it.

The population, before the change

specs declaring a slice parameter 189
of those, parse 174
of those, emit Rust that index-assigns into one 23
of those, compile 0

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

  1. It does not compile: E0382 use of moved value, E0596 cannot borrow as mutable.
  2. Had it compiled, it would have been silently wrong. The writes land in a moved copy and the caller sees nothing. A plausible future repair — adding mut to 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_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 — now excluded by requiring a plausible type name.

What it is worth

metric, all 650 specs master after
move/borrow errors (E0382 + E0596) 33 files, 143 diagnostics 14 files, 45
rustc accepts 430 430
…of which contain a function 312 312

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:

60 REVEALED    0 INTRODUCED

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] on while (... && (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] reads 21, and 0 is 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_parameter and 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_HASH is 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.

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>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-09-07 19:16:49 UTC

Summary

Status Count
Total Open PRs 16
PRs with Failing Checks 14
PRs with All Checks Green 2
READY 2
FAILING 14
PENDING 0
NO CHECKS YET 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=c10b52dbbf00 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

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>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-09-07 19:21:53 UTC

Summary

Status Count
Total Open PRs 16
PRs with Failing Checks 13
PRs with All Checks Green 3
READY 2
FAILING 13
PENDING 0
NO CHECKS YET 0

These columns do not partition: 2 + 13 + 0 + 0 = 15, and there are 16 open PRs. A PR is being counted twice or not at all.

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=7ddf8caecb91 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag

gHashTag commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review found a real hole, and it has been fixed in 05848b42e. Correcting the PR body above.

What the reviewers found

Two skeptics attacking from different angles landed on the same thing, and it was a contradiction inside my own diff. The comment said:

Deliberately narrow: only the written-into slices move to &mut [T]. A read-only Vec<T> parameter is valid Rust that compiles today, and rewriting it to &[T] would change call sites for no defect.

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: []T then meant &[T] in parameter position and Vec<T> in return, field and local position.

fn join(base: []u8, name: []u8) []u8 { var result : []u8 = base; ... }

became

pub fn join(base: &[u8], name: &[u8]) -> Vec<u8> { let mut result: Vec<u8> = base; ... }
                                                                            ^ 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 property this project claims for its four backends.

My instrument could not have caught it

The before/after compared the set of error classes per file. An E0308 added to a file that already had an E0308 is invisible to a set. Counting occurrences per class per file surfaced it at once. A set is the wrong shape for the question "did this get worse", and that is the lesson worth more than the fix.

The corrected measurement

The 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. join is now identical to master; the char_to_tritsbyte_to_trits chain is still repaired.

all 650 specs, vs master narrow (shipped) wide (withdrawn)
move/borrow, files 33 → 24 33 → 14
move/borrow, diagnostics 143 → 117 143 → 45
total diagnostics 3773 → 3747 (−26) 3773 → 3944 (+171)
files whose output changed 42 158
rustc accepts 430 → 430 430 → 430

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 E0615 on data.len, checked line by line: 124 REVEALED, 0 INTRODUCED, each blaming a line byte-identical in both outputs and reached only because an earlier error no longer aborts the compile. That is the separate pre-existing defect filed in #3402.

Tests are 13 green. The read-only assertion was corrected with the code — it had asserted b: &[i32] and now asserts b: Vec<i32>, which is the behaviour the comment always described.

@gHashTag
gHashTag merged commit 1862128 into master Sep 7, 2026
33 of 36 checks passed
gHashTag added a commit that referenced this pull request Sep 7, 2026
#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>
gHashTag added a commit that referenced this pull request Sep 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gen-rust: a []T parameter written by the body is emitted as Vec<T> by value, so the caller never sees the writes

1 participant