Skip to content

Value-copy ownership analysis: finish the three parts WEP 2026-05-21 specifies but does not implement #1850

Description

@gfx

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_valueif 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

  1. 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.
  2. Part 1 — liveness. Highest value, and it is what the remaining measured cost needs.
  3. Part 2 — type-driven seed. Small and mechanical; removes a bug class.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions