Skip to content

Commit 6c4f3de

Browse files
hyperpolymathclaude
andcommitted
feat(borrow): CORE-01 Slice C' (#177 pt3) — loop soundness via 2-iteration + StmtAssign clear-on-rewrite
Closes the loop-soundness item documented at lib/borrow.ml's "Still deferred" comment. Two interlocked changes: 1. StmtAssign clear-on-rewrite (precondition for #2). Pre-fix, [check_use] on the LHS of every assignment rejected `x = …` when `x` had a prior move record — even though the assignment is precisely what restores `x` to a fresh value. That made legitimate re-init patterns impossible (`drop_int(x); x = new_value;` in a loop body), and it made the 2-iteration loop check unable to converge on any such loop. Now [StmtAssign] (a) checks only for an exclusive-borrow conflict on the LHS (not for a move), and (b) clears every move record overlapping the LHS place after the assignment completes. 2. StmtWhile/StmtFor 2-iteration body pass. The body runs once; then state-fields snapshot; then cond+body run again from the post-iter-1 state. Any move the body did not restore via reassignment surfaces as UseAfterMove on the second pass — that is the multi-iter soundness gain. After both passes, state is restored to the iter-1-post snapshot so any analysis past the loop sees a single iteration's worth of state (loops may execute 0..N times; iter-1-post is the sound choice when iter-2 doesn't add new conflicts). Tests (+3, all green): - slice_c_prime_loop_sound — counted loop converges, no false +ve - slice_c_prime_loop_reinit_ok — drop-then-reassign in body OK - slice_c_prime_loop_move_persists — anti-regression: move with no rebind raises UseAfterMove on the iter-2 pass Gate: 327 → 330 tests, 0 failures under `dune runtest --force`. Refs #177, CORE-01 pt3 (Slice C'). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1a57163 commit 6c4f3de

5 files changed

Lines changed: 206 additions & 15 deletions

File tree

lib/borrow.ml

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,8 +1307,20 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
13071307
if not (is_mutable state place) then
13081308
Error (CannotBorrowAsMutable (place, expr_span lhs))
13091309
else
1310-
(* Check that the place is not moved and not borrowed *)
1311-
let* () = check_use state place (expr_span lhs) in
1310+
(* CORE-01 pt3 Slice C' / #177: assignment REBINDS the LHS,
1311+
so a prior move on the LHS place is *cleared* by the
1312+
assignment — `x = …` is not a use of the moved value.
1313+
Previously [check_use] rejected `x = …` after `x` was
1314+
moved, which spuriously rejected legitimate re-init in
1315+
loop bodies (and prevented the 2-iteration loop check
1316+
from converging). We still reject if the place is
1317+
*exclusively borrowed* — you cannot reassign through a
1318+
place while a still-live exclusive borrow holds it. *)
1319+
let* () = match find_aliasing_exclusive state place with
1320+
| Some b ->
1321+
Error (UseWhileExclusivelyBorrowed (place, b, expr_span lhs))
1322+
| None -> Ok ()
1323+
in
13121324
(* Check for any active borrows of this place *)
13131325
begin match List.find_opt (fun b -> places_overlap place b.b_place) state.borrows with
13141326
| Some borrow ->
@@ -1349,6 +1361,16 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
13491361
(binder_sym, new_b) :: state.ref_bindings
13501362
| None -> ())
13511363
| _ -> ());
1364+
(* CORE-01 pt3 Slice C' / #177: clear any prior move record on
1365+
the LHS place — the assignment has rebound it to a fresh
1366+
value, so subsequent reads of LHS are uses of the *new*
1367+
binding, not the moved-out one. This is the second half
1368+
of the clear-on-rewrite fix (the first half was skipping
1369+
the LHS move check up at [check_use] entry above). *)
1370+
state.moved <-
1371+
List.filter (fun mr ->
1372+
not (places_overlap place mr.m_place)
1373+
) state.moved;
13521374
Ok ()
13531375
end
13541376
| None ->
@@ -1357,11 +1379,55 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
13571379
check_expr ctx state symbols rhs
13581380
end
13591381
| StmtWhile (cond, body) ->
1382+
(* CORE-01 pt3 Slice C' / #177 — loop soundness via 2-iteration.
1383+
A single body pass misses multi-iter conflicts: a move at
1384+
iter 1 that the body never restores would, at iter 2, be a
1385+
UseAfterMove. We run cond+body twice; iter 2 starts from
1386+
the post-iter-1 state, so an unrestored move surfaces as
1387+
UseAfterMove on the second pass. Pairs with the [StmtAssign]
1388+
clear-on-rewrite fix above so loops that legitimately
1389+
re-initialise a moved-out variable per iteration still
1390+
converge (iter-2 move is no longer treated as use-of-moved
1391+
because iter 1's assignment cleared the move). After both
1392+
passes we restore state to the iter-1-post snapshot so any
1393+
analysis past the loop sees a single iter's worth of state
1394+
(the loop may execute 0..N times — using iter-1-post is the
1395+
sound choice when iter-2 doesn't add new moves). *)
13601396
let* () = check_expr ctx state symbols cond in
1361-
check_block ctx state symbols body
1397+
let* () = check_block ctx state symbols body in
1398+
let snap_borrows = state.borrows in
1399+
let snap_moved = state.moved in
1400+
let snap_ref_bindings = state.ref_bindings in
1401+
let snap_block_locals = state.block_local_syms in
1402+
let snap_mutable = state.mutable_bindings in
1403+
let r =
1404+
let* () = check_expr ctx state symbols cond in
1405+
check_block ctx state symbols body
1406+
in
1407+
state.borrows <- snap_borrows;
1408+
state.moved <- snap_moved;
1409+
state.ref_bindings <- snap_ref_bindings;
1410+
state.block_local_syms <- snap_block_locals;
1411+
state.mutable_bindings <- snap_mutable;
1412+
r
13621413
| StmtFor (_pat, iter, body) ->
13631414
let* () = check_expr ctx state symbols iter in
1364-
check_block ctx state symbols body
1415+
let* () = check_block ctx state symbols body in
1416+
let snap_borrows = state.borrows in
1417+
let snap_moved = state.moved in
1418+
let snap_ref_bindings = state.ref_bindings in
1419+
let snap_block_locals = state.block_local_syms in
1420+
let snap_mutable = state.mutable_bindings in
1421+
let r =
1422+
let* () = check_expr ctx state symbols iter in
1423+
check_block ctx state symbols body
1424+
in
1425+
state.borrows <- snap_borrows;
1426+
state.moved <- snap_moved;
1427+
state.ref_bindings <- snap_ref_bindings;
1428+
state.block_local_syms <- snap_block_locals;
1429+
state.mutable_bindings <- snap_mutable;
1430+
r
13651431

13661432
(** Check a function
13671433
@@ -1480,22 +1546,28 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
14801546
one helper so all CFG-join sites agree). Finally runs after
14811547
the merge, deterministically, against the merged state.
14821548
1549+
CORE-01 pt3 Slice C' (2026-05-26): loop soundness via a 2-iteration
1550+
check on [StmtWhile]/[StmtFor], paired with [StmtAssign]
1551+
clear-on-rewrite. The body runs once; then state-fields snapshot;
1552+
then cond+body run again from the post-iter-1 state. Any move
1553+
that the body didn't restore (no rebinding assignment) surfaces
1554+
as UseAfterMove on the 2nd pass. Pre-fix [check_use] also fired
1555+
on the LHS of every assignment, so a re-init pattern (move x;
1556+
x = new_value) spuriously failed — making 2-iter convergence
1557+
impossible. Now [StmtAssign] (a) only checks the LHS for an
1558+
exclusive-borrow conflict (not for a move), and (b) clears any
1559+
prior move record overlapping the LHS place after the assign
1560+
completes. Together: legitimate re-init loops accept; loops
1561+
with an unrestored body-move reject. State restoration to the
1562+
iter-1-post snapshot keeps analysis past the loop consistent
1563+
(loops may execute 0..N times — using iter-1-post is the sound
1564+
choice when iter-2 doesn't add new conflicts).
1565+
14831566
Still deferred:
1484-
- Reborrow through indirection: `r = some_other_ref_var` (RHS not a
1485-
direct `&place`) does not yet copy the other binder's graph
1486-
entry — the ref-binding stays stale. Same limitation as
1487-
[record_ref_binding] for the let-graph path; would require
1488-
symmetric let/assign handling for ref-to-ref binding.
14891567
- Origin/region variables (true Polonius surface) — a region
14901568
var on each [TyRef]/[TyMut] with subset constraints and a
14911569
proper datalog-style loan-live-at-point solver. Architectural
14921570
change to the type system; ADR-gated.
1493-
- Loop soundness (`StmtWhile`/`StmtFor`): a single body pass
1494-
misses multi-iteration move conflicts. A 2-iteration check
1495-
would catch them but requires fixing the assignment-clears-
1496-
move imprecision first (assignment is currently treated as a
1497-
read of LHS, so `x = …` after a prior move spuriously fails).
1498-
Couple Slice C' with the StmtAssign clear-on-rewrite fix.
14991571
- Tighter integration with the quantity checker for captured
15001572
linears (Slice D).
15011573
*)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice C' / #177: anti-regression — multi-iteration
5+
// move conflict. The loop body moves `x` via `drop_int(x)` and
6+
// then DOES NOT rebind it. Pre-fix, a single-pass body check
7+
// silently accepted this loop because iter 1 moves x cleanly and
8+
// no further use is visible *within iter 1*. With the 2-iteration
9+
// check, iter 2 begins with `x` already moved (moves persist
10+
// across block exit by design), so the iter-2 read of `x` at
11+
// `drop_int(x)` surfaces as UseAfterMove. This is the soundness
12+
// gain. Expected: Error UseAfterMove.
13+
14+
module SliceCPrimeLoopMovePersists;
15+
16+
fn drop_int(x: own Int) -> Unit = ();
17+
18+
fn loop_move_no_rebind() -> Int {
19+
let mut x = 10;
20+
let mut i = 0;
21+
while i < 3 {
22+
drop_int(x);
23+
i = i + 1;
24+
}
25+
0
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice C' / #177: positive case demonstrating the
5+
// clear-on-rewrite half of the slice. The loop body moves `x`
6+
// (via the `own` parameter of `drop_int`) and then *immediately
7+
// rebinds* `x = 1` on the next statement. Pre-fix, the assignment
8+
// after a move was rejected (StmtAssign called `check_use` which
9+
// looked up the move record on `x` and raised UseAfterMove on
10+
// what is in fact a re-init). With clear-on-rewrite the
11+
// assignment skips the move check on LHS and clears any prior
12+
// move record on the place, so iter 2 starts fresh and the loop
13+
// converges. Expected: Ok.
14+
15+
module SliceCPrimeLoopReinitOk;
16+
17+
fn drop_int(x: own Int) -> Unit = ();
18+
19+
fn loop_reinit() -> Int {
20+
let mut x = 10;
21+
let mut i = 0;
22+
while i < 3 {
23+
drop_int(x);
24+
x = 42;
25+
i = i + 1;
26+
}
27+
x
28+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice C' / #177: positive case for the 2-iteration
5+
// loop check. An ordinary counted loop with no moves and no borrows
6+
// must converge on the second pass — iter 2 sees the same state as
7+
// iter 1 (modulo mutable_bindings/borrows snapshot restoration), so
8+
// the body's reads/assignments stay legal. Pre-2-iter, this case
9+
// already passed; this fixture pins that the 2-iter pass did not
10+
// break it. Expected: Ok.
11+
12+
module SliceCPrimeLoopSound;
13+
14+
fn loop_counted() -> Int {
15+
let mut acc = 0;
16+
let mut i = 0;
17+
while i < 5 {
18+
acc = acc + i;
19+
i = i + 1;
20+
}
21+
acc
22+
}

test/test_e2e.ml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4415,6 +4415,43 @@ let test_slice_c_body_move_persists () =
44154415
the catch-arm merge — `read_int(y)` after a moved `y` \
44164416
was silently accepted"
44174417

4418+
(* CORE-01 pt3 Slice C' / #177 — loop soundness via 2-iteration +
4419+
StmtAssign clear-on-rewrite. The two halves are interlocked:
4420+
without clear-on-rewrite, legitimate re-init loops would
4421+
spuriously fail; without the 2-iter pass, multi-iter move
4422+
conflicts go undetected. Three tests pin: (1) sound counted
4423+
loop converges; (2) clear-on-rewrite — move-then-reassign
4424+
inside the body accepts; (3) anti-regression — move without
4425+
rebind raises UseAfterMove on the 2nd pass. *)
4426+
let test_slice_c_prime_loop_sound () =
4427+
match borrow_result (fixture "slice_c_prime_loop_sound.affine") with
4428+
| Ok () -> ()
4429+
| Error e ->
4430+
Alcotest.fail ("Slice C': counted loop was spuriously rejected — \
4431+
iter-2 pass introduced a false positive: "
4432+
^ Borrow.format_borrow_error e)
4433+
4434+
let test_slice_c_prime_loop_reinit_ok () =
4435+
match borrow_result (fixture "slice_c_prime_loop_reinit_ok.affine") with
4436+
| Ok () -> ()
4437+
| Error e ->
4438+
Alcotest.fail ("Slice C' clear-on-rewrite: re-init after move was \
4439+
spuriously rejected — `x = 42` after `drop_int(x)` \
4440+
should clear the move: "
4441+
^ Borrow.format_borrow_error e)
4442+
4443+
let test_slice_c_prime_loop_move_persists () =
4444+
match borrow_result (fixture "slice_c_prime_loop_move_persists.affine") with
4445+
| Error (Borrow.UseAfterMove _) -> ()
4446+
| Error e ->
4447+
Alcotest.fail ("Slice C' anti-regression: expected UseAfterMove on \
4448+
the iter-2 `drop_int(x)` after iter-1 moved `x`, \
4449+
got: " ^ Borrow.format_borrow_error e)
4450+
| Ok () ->
4451+
Alcotest.fail "Slice C' regressed: multi-iter move conflict was \
4452+
silently accepted — the 2-iteration pass did not \
4453+
catch the unrestored move on `x`"
4454+
44184455
let borrow_tests = [
44194456
Alcotest.test_case "BorrowOutlivesOwner: &local escapes its block"
44204457
`Quick test_borrow_outlives_owner;
@@ -4450,6 +4487,12 @@ let borrow_tests = [
44504487
`Quick test_slice_c_catch_arm_isolation;
44514488
Alcotest.test_case "Slice C anti-regression: body's move persists past try (#177 pt3 Slice C)"
44524489
`Quick test_slice_c_body_move_persists;
4490+
Alcotest.test_case "Slice C': counted loop converges (#177 pt3)"
4491+
`Quick test_slice_c_prime_loop_sound;
4492+
Alcotest.test_case "Slice C': clear-on-rewrite — re-init in loop OK (#177 pt3)"
4493+
`Quick test_slice_c_prime_loop_reinit_ok;
4494+
Alcotest.test_case "Slice C' anti-regression: multi-iter move rejected (#177 pt3)"
4495+
`Quick test_slice_c_prime_loop_move_persists;
44534496
]
44544497

44554498
(* ============================================================================

0 commit comments

Comments
 (0)