Skip to content

Commit 3a531e1

Browse files
hyperpolymathclaude
andcommitted
feat(ADR-022 M3): model loan-vs-loan exclusivity in the Polonius extractor
Closes the borrow-vs-borrow divergence class (2 of the 9 allowlisted extractor↔lexical disagreements). The extractor now emits a conflict for a direct read of a place while a live `&mut` (exclusive) loan covers it: * `let b = &mut x` reads x at its creation point while the first `&mut x` is still live → borrow_mutref_conflict now AGREES; * `let y = x` (plain read) while `&mut x` is live → borrow_mutref_use_while now AGREES. Implementation: add rl_excl to the loan record (threaded from rhs_borrows); a post-pass scans use_at × loans and, for each read of var v while an EXCLUSIVE loan rooted at v is live, emits conflict_at — excluding the loan's own birth point (that read IS the creation) and reads through the borrow (`*a` has root a, not x, via PlaceDeref). Emission is liveness-gated by the solver, so NLL last-use shortening keeps valid code accepted; shared `&` loans never trigger it. Tests: t_extract_mutref_conflict / _use_while (flagged), t_extract_shared_ read_ok (new fixture — read-while-shared-borrowed accepted, anti-over-reject). Allowlist 9→7; corpus parallel-run gate green (no new divergence). 528 tests. Remaining M3 divergences: return-escape/outlives-owner (4, needs escape analysis), captured-linear (2), call-aliasing mut-param-arg (1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b3f63c commit 3a531e1

3 files changed

Lines changed: 76 additions & 9 deletions

File tree

lib/borrow_extract.ml

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ let moved_places (ctx : Borrow.context) (symbols : Symbol.t) (e : expr)
137137
| _ -> []) args)
138138
| None -> []) (apps e)
139139

140-
type rloan = { rl_id : int; rl_place : Borrow.place; rl_binder : Symbol.symbol_id option }
140+
type rloan = {
141+
rl_id : int;
142+
rl_place : Borrow.place;
143+
rl_binder : Symbol.symbol_id option;
144+
rl_excl : bool; (** [&mut] (exclusive) vs [&] (shared) — drives the
145+
use-while-exclusively-borrowed conflict rule. *)
146+
}
141147

142148
(** Extract the Polonius facts for one (straight-line) function body. *)
143149
let extract_fn (ctx : Borrow.context) (symbols : Symbol.t) (fd : fn_decl) : PF.facts =
@@ -207,11 +213,11 @@ let extract_fn (ctx : Borrow.context) (symbols : Symbol.t) (fd : fn_decl) : PF.f
207213
in
208214
let record_binder_loans (pt : int) (sym : Symbol.symbol_id) (rhs : expr) : unit =
209215
let kp = kill_for sym pt (pt + 1) in
210-
List.iter (fun (place, _excl) ->
216+
List.iter (fun (place, excl) ->
211217
let l = fresh next_loan in
212218
borrow_at := (l, pt) :: !borrow_at;
213219
loan_origin := (l, fresh next_origin) :: !loan_origin;
214-
loans := { rl_id = l; rl_place = place; rl_binder = Some sym } :: !loans;
220+
loans := { rl_id = l; rl_place = place; rl_binder = Some sym; rl_excl = excl } :: !loans;
215221
killed := (l, kp) :: !killed)
216222
(rhs_borrows ctx symbols rhs @ alias_places rhs)
217223
in
@@ -373,6 +379,28 @@ let extract_fn (ctx : Borrow.context) (symbols : Symbol.t) (fd : fn_decl) : PF.f
373379
in
374380
List.iteri moves_stmt blk.blk_stmts;
375381
(match blk.blk_expr with Some e -> do_point nstmts e; moves_expr nstmts e | None -> ());
382+
(* M3 loan-vs-loan: use-while-exclusively-borrowed. A direct read of a place
383+
while an EXCLUSIVE [&mut] loan covering it is live is a conflict. This
384+
single rule subsumes both diverging mutref shapes:
385+
- a second overlapping borrow — [let b = &mut x] reads [x] at its
386+
creation point while the first [&mut x] is still live;
387+
- a plain read — [let y = x] (or [x] in any read position) while
388+
[&mut x] is live.
389+
The read at the exclusive loan's OWN birth point is the borrow creation
390+
itself (not a conflicting use), so loans born at [pt] are excluded. Reads
391+
THROUGH the borrow ([*a]) have root [a] (PlaceDeref → root of the holder),
392+
never the borrowed-from root [x], so they never match. Emission is
393+
liveness-gated by the solver ([loan_invalidated_at] requires the loan to
394+
be live at the point), so NLL last-use shortening keeps valid code
395+
accepted — only [&mut], never [&], triggers it. *)
396+
List.iter (fun (v, pt) ->
397+
List.iter (fun rl ->
398+
if rl.rl_excl
399+
&& (match Borrow.root_var rl.rl_place with Some rv -> rv = v | None -> false)
400+
&& not (List.exists (fun (l, p) -> l = rl.rl_id && p = pt) !borrow_at)
401+
then conflict_at := (rl.rl_id, pt) :: !conflict_at)
402+
!loans)
403+
!use_at;
376404
{ PF.empty_facts with
377405
borrow_at = !borrow_at; loan_origin = !loan_origin;
378406
killed = !killed; cfg_edge = base_cfg @ !extra_edges; conflict_at = !conflict_at;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// M3 anti-over-rejection (#553 ADR-022): a SHARED borrow does NOT forbid
2+
// concurrent reads of the borrowed place. `let a = &x; let y = x; *a` is valid
3+
// under both the lexical checker and the Polonius extractor — only `&mut` is
4+
// exclusive. The use-while-exclusively-borrowed rule must fire ONLY for `&mut`,
5+
// never for `&`, so this program must be accepted (pol = lex = false).
6+
module BorrowSharedReadOk;
7+
8+
fn ok() -> Int {
9+
let x = 5;
10+
let a = &x;
11+
let y = x;
12+
*a + y
13+
}

test/test_borrow_polonius.ml

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,27 @@ let t_extract_value_return_ok () =
160160
Alcotest.(check bool) "Polonius: no loan, no error" false pol;
161161
Alcotest.(check bool) "agrees with lexical verdict" lex pol
162162

163+
(* M3 loan-vs-loan exclusivity (#553 ADR-022): two simultaneously-live `&mut`
164+
borrows of the same place — the second `&mut x` reads `x` at its creation
165+
point while the first is still live, so it conflicts. *)
166+
let t_extract_mutref_conflict () =
167+
let (pol, lex) = polonius_vs_lexical "borrow_mutref_conflict.affine" in
168+
Alcotest.(check bool) "Polonius flags &mut/&mut overlap" true pol;
169+
Alcotest.(check bool) "agrees with lexical" lex pol
170+
171+
(* a plain read of `x` while `&mut x` is live (use-while-exclusively-borrowed) *)
172+
let t_extract_mutref_use_while () =
173+
let (pol, lex) = polonius_vs_lexical "borrow_mutref_use_while.affine" in
174+
Alcotest.(check bool) "Polonius flags use-while-&mut-borrowed" true pol;
175+
Alcotest.(check bool) "agrees with lexical" lex pol
176+
177+
(* a SHARED `&x` does NOT forbid reads — `let a = &x; let y = x; *a` is valid
178+
under both tiers (only `&mut` is exclusive). Guards against over-rejection. *)
179+
let t_extract_shared_read_ok () =
180+
let (pol, lex) = polonius_vs_lexical "borrow_shared_read_ok.affine" in
181+
Alcotest.(check bool) "Polonius accepts read-while-shared-borrowed" false pol;
182+
Alcotest.(check bool) "agrees with lexical" lex pol
183+
163184
(* expression-level branches (issue-draft 08 conditional-origin family): the RHS
164185
borrows the UNION of its arm sources. Polonius must now match the lexical fix. *)
165186
let t_extract_cond_if () =
@@ -288,12 +309,14 @@ let t_extract_reassign_uam () =
288309
increments land, entries graduate OFF this list; an allowlisted fixture that
289310
has started AGREEING is logged (prune it) but does not fail the gate. *)
290311
let known_divergences : (string * string) list =
291-
[ (* unmodeled: borrow-vs-borrow conflicts — two simultaneously live borrows
292-
violating shared-XOR-exclusive. The extractor models access-vs-loan
293-
conflicts (move/write over a live loan), not loan-vs-loan. *)
294-
"borrow_mutref_conflict.affine", "conflicting &mut/&mut borrows";
295-
"borrow_mutref_use_while.affine", "use while &mut borrowed";
296-
"borrow_use_while_excl.affine", "use while exclusively borrowed";
312+
[ (* loan-vs-loan exclusivity (use-while-exclusively-borrowed) is now modeled
313+
by the extractor (a direct read of a place while a live [&mut] loan
314+
covers it is a conflict), so borrow_mutref_conflict / borrow_mutref_use_while
315+
AGREE and were pruned. borrow_use_while_excl is the call-aliasing shape
316+
(passing [x] to a [mut] param and reading [x] in the same call) — a
317+
[mut]-param argument is not yet modeled as a call-scoped exclusive loan,
318+
so it stays here. *)
319+
"borrow_use_while_excl.affine", "mut-param-arg aliasing (call-scoped excl borrow not modeled)";
297320
(* unmodeled: return-escape / borrow-outlives-owner (no escape analysis) *)
298321
"borrow_outlives_owner.affine", "borrow-outlives-owner";
299322
"borrow_return_escape_local.affine", "return-escape (local)";
@@ -351,6 +374,9 @@ let tests =
351374
Alcotest.test_case "extract+solve: #554 UAM flagged, agrees with lexical" `Quick t_extract_uam;
352375
Alcotest.test_case "extract+solve: NLL-safe accepted, agrees with lexical" `Quick t_extract_nll_ok;
353376
Alcotest.test_case "extract+solve: value-return no error, agrees with lexical" `Quick t_extract_value_return_ok;
377+
Alcotest.test_case "extract+solve: &mut/&mut overlap flagged (loan-vs-loan)" `Quick t_extract_mutref_conflict;
378+
Alcotest.test_case "extract+solve: use-while-&mut-borrowed flagged" `Quick t_extract_mutref_use_while;
379+
Alcotest.test_case "extract+solve: read-while-&-shared accepted (no over-reject)" `Quick t_extract_shared_read_ok;
354380
(* M3 branch extraction: conditional-origin family (issue-draft 08) *)
355381
Alcotest.test_case "extract+solve: if-bound UAM, agrees with lexical" `Quick t_extract_cond_if;
356382
Alcotest.test_case "extract+solve: block-bound UAM, agrees with lexical" `Quick t_extract_cond_block;

0 commit comments

Comments
 (0)