Hand-off from the branch claude/value-copy-helper-identity-36oxbx (work on #1588).
That branch fixed the helper-identity defect #1588 named, and — following the trail it opened — four wrong-code paths in the ownership analysis. In doing so it found that three things WEP 2026-05-21 — Resource Ownership specifies are not implemented. The WEP now states all three, and its implementation status lists them unchecked. This issue is to close them.
None is a regression: each is a place where the implementation approximates the design and the approximation costs precision.
Background: what the branch established
The design is settled and should not be revisited here. Copy decisions are caller-side and single-phase — chosen in lower, before NIR exists, with no elision pass (optimize::escape / value_copy_elide were deleted). docs/optimizer.md records that an imprecise copy is the analysis's to fix, not the optimizer's.
The branch's own fixes are in, verified, and are the prerequisites for the work below:
- A reference local owns nothing, so a root reached through one resolves to the place it borrows. This was wrong in three separate predicates (
source_root, the move analysis's freshness seed, the share analysis's path disjointness), each a wrong-code path on its own: a let out of a &- or &mut-held struct kept seeing the source's later writes.
- A self-recursive function can now prove it returns owned. A least fixpoint that only adds could never lift one, so every
? on CborDeserializer::skip_item deep-copied the error it propagated.
- A place repointed after a binding read it releases that binding (
take / drain / snapshot).
wrap_value_copy no longer answers a missing helper by passing the value through — it asserts. That silent path had been dropping deep copies in 236 fixtures.
The three parts to finish
1. Key sharing on liveness
The WEP's rule is "a read-only binding whose storage is never mutated while live". compute_share_eligible implements only the second half: it is a forward walk with no liveness and no control-flow handling (walk_block walks statements in order and ignores branches and loops), so a conflicting write anywhere in the body refuses the binding.
Both halves are readings of one backward walk, and that walk already exists in the same file — last_use::Analyzer computes liveness for the move analysis. Merging share collection into it makes the rule real: a write conflicts with a binding when the binding is live at that write and the two places may alias.
Measured cost of not doing it, against origin/main on the golden corpus:
| copies |
where |
| +6 |
ArgvDeserializer::take_value — if let Some(v) = self.forced { self.forced = null; return Ok(v); } is refused because a later self.skip_consumed() exists, though v has returned before it runs |
| +6 |
value_copy_elide_variant_alias tests 3–4 — the binding is dead before the write |
A smaller, separable part of the same fix: a &mut self receiver goes through walk_value, which marks the receiver consumed. That is double-counting — the callee's writes are already recorded by record_mutation at the receiver — and it should use walk_place_base as a &self receiver does. This is half of what refuses take_value.
2. Drive the helper seed from declared types
$value_copy$T helpers are additive synthesis, so they are created in plan — before the fold that decides where to call them, and before pattern lowering, which runs at the top of translate and mints the scrutinee and destructure temps some of those calls land on.
The seed therefore has to be complete without predicting what a later pass will write. It currently walks expressions and predicts, and every shape it misses is a copy the fold cannot emit. A seed driven by the types a program declares cannot miss them, because no expression rewrite introduces a type the program did not already name. Over-synthesis is free — dce removes an unused helper — and the walk already accepts over-approximation for a different reason.
WEP 2026-05-11 — NIR predicted this failure mode when it justified keeping lift_mut out of the fold: "a choice between a missing helper and a silently skipped copy — a real semantic change". Pattern lowering's move into translate reintroduced exactly that choice one pass later.
3. Decide a match arm's binding in the fold
emit_pattern_bindings writes an arm binding as a bare local.set with no copy of its own, so its safety rests on the scrutinee temp — which exists for labeled_block_fusion. The copy therefore appears or not by syntactic accident, and lower/translate/pattern.rs now carries place_is_writable / binds_by_value / owned_temps to compensate: a copy-necessity decision in a pass whose remit is expression shape.
The fix is the codebase's own plan (the "Phase 10 Step 2b" note in pattern.rs): lower an arm's bindings to ordinary projections of the scrutinee, as let-destructure already is, so the fold sees them and the lowerer keeps no predicates. It interacts with labeled_block_fusion, which keys on the (Let, Match) statement pair, so it wants its own design pass.
Suggested order
- One
Place constructor with reference resolution inside it. The place model is derived four ways today (place_path, source_root, as_materialize, collect_local_roots), each with its own idea of where a chain bottoms out; the backward walk in (1) needs one.
- Part 1 — liveness. Highest value, and it is what the remaining measured cost needs.
- Part 2 — type-driven seed. Small and mechanical; removes a bug class.
- Part 3 — arm bindings in the fold. Largest; own design pass.
Verifying
The golden corpus is the instrument. Count copy sites with
grep -rho 'array_clone_src_[0-9]*\|\$value_copy\$[^"]*' wado-compiler/tests/generated/fixtures/ | wc -l
after mise run update-golden-fixtures, and compare per fixture against origin/main. Benchmarks alone do not show these — the corpus does. wado-compiler/tests/fixtures/pattern_temp_no_alias.wado pins the aliasing cases as a matrix over syntactic position × writability × binding kind; extend it rather than chasing one finding at a time.
Two fixtures carry wir_not_expect assertions that guard the read-only-share refinement (value_copy_elide_disjoint_field_mut, the shape of ArrayRefIter::next) — they are the regression net for part 1.
Related
Hand-off from the branch
claude/value-copy-helper-identity-36oxbx(work on #1588).That branch fixed the helper-identity defect #1588 named, and — following the trail it opened — four wrong-code paths in the ownership analysis. In doing so it found that three things WEP 2026-05-21 — Resource Ownership specifies are not implemented. The WEP now states all three, and its implementation status lists them unchecked. This issue is to close them.
None is a regression: each is a place where the implementation approximates the design and the approximation costs precision.
Background: what the branch established
The design is settled and should not be revisited here. Copy decisions are caller-side and single-phase — chosen in
lower, before NIR exists, with no elision pass (optimize::escape/value_copy_elidewere deleted).docs/optimizer.mdrecords that an imprecise copy is the analysis's to fix, not the optimizer's.The branch's own fixes are in, verified, and are the prerequisites for the work below:
source_root, the move analysis's freshness seed, the share analysis's path disjointness), each a wrong-code path on its own: aletout of a&- or&mut-held struct kept seeing the source's later writes.?onCborDeserializer::skip_itemdeep-copied the error it propagated.take/drain/snapshot).wrap_value_copyno longer answers a missing helper by passing the value through — it asserts. That silent path had been dropping deep copies in 236 fixtures.The three parts to finish
1. Key sharing on liveness
The WEP's rule is "a read-only binding whose storage is never mutated while live".
compute_share_eligibleimplements only the second half: it is a forward walk with no liveness and no control-flow handling (walk_blockwalks statements in order and ignores branches and loops), so a conflicting write anywhere in the body refuses the binding.Both halves are readings of one backward walk, and that walk already exists in the same file —
last_use::Analyzercomputes liveness for the move analysis. Merging share collection into it makes the rule real: a write conflicts with a binding when the binding is live at that write and the two places may alias.Measured cost of not doing it, against
origin/mainon the golden corpus:ArgvDeserializer::take_value—if let Some(v) = self.forced { self.forced = null; return Ok(v); }is refused because a laterself.skip_consumed()exists, thoughvhas returned before it runsvalue_copy_elide_variant_aliastests 3–4 — the binding is dead before the writeA smaller, separable part of the same fix: a
&mut selfreceiver goes throughwalk_value, which marks the receiver consumed. That is double-counting — the callee's writes are already recorded byrecord_mutationat the receiver — and it should usewalk_place_baseas a&selfreceiver does. This is half of what refusestake_value.2. Drive the helper seed from declared types
$value_copy$Thelpers are additive synthesis, so they are created inplan— before the fold that decides where to call them, and before pattern lowering, which runs at the top oftranslateand mints the scrutinee and destructure temps some of those calls land on.The seed therefore has to be complete without predicting what a later pass will write. It currently walks expressions and predicts, and every shape it misses is a copy the fold cannot emit. A seed driven by the types a program declares cannot miss them, because no expression rewrite introduces a type the program did not already name. Over-synthesis is free —
dceremoves an unused helper — and the walk already accepts over-approximation for a different reason.WEP 2026-05-11 — NIR predicted this failure mode when it justified keeping
lift_mutout of the fold: "a choice between a missing helper and a silently skipped copy — a real semantic change". Pattern lowering's move intotranslatereintroduced exactly that choice one pass later.3. Decide a match arm's binding in the fold
emit_pattern_bindingswrites an arm binding as a barelocal.setwith no copy of its own, so its safety rests on the scrutinee temp — which exists forlabeled_block_fusion. The copy therefore appears or not by syntactic accident, andlower/translate/pattern.rsnow carriesplace_is_writable/binds_by_value/owned_tempsto compensate: a copy-necessity decision in a pass whose remit is expression shape.The fix is the codebase's own plan (the "Phase 10 Step 2b" note in
pattern.rs): lower an arm's bindings to ordinary projections of the scrutinee, aslet-destructure already is, so the fold sees them and the lowerer keeps no predicates. It interacts withlabeled_block_fusion, which keys on the(Let, Match)statement pair, so it wants its own design pass.Suggested order
Placeconstructor with reference resolution inside it. The place model is derived four ways today (place_path,source_root,as_materialize,collect_local_roots), each with its own idea of where a chain bottoms out; the backward walk in (1) needs one.Verifying
The golden corpus is the instrument. Count copy sites with
after
mise run update-golden-fixtures, and compare per fixture againstorigin/main. Benchmarks alone do not show these — the corpus does.wado-compiler/tests/fixtures/pattern_temp_no_alias.wadopins the aliasing cases as a matrix over syntactic position × writability × binding kind; extend it rather than chasing one finding at a time.Two fixtures carry
wir_not_expectassertions that guard the read-only-share refinement (value_copy_elide_disjoint_field_mut, the shape ofArrayRefIter::next) — they are the regression net for part 1.Related