Skip to content

Commit 69d2796

Browse files
hyperpolymathclaude
andcommitted
feat(ADR-022 M3): model mut-param call-aliasing in the Polonius extractor
Close the call-aliasing divergence (borrow_use_while_excl): passing `x` to a `mut` parameter and reading `x` again in the SAME call — `mut_then_read(x, x)` — was flagged by the lexical checker (UseWhileExclusivelyBorrowed) but not by the Polonius extractor, so the fixture sat on the parallel-run allowlist. The extractor now mirrors the lexical checker's left-to-right argument fold: a `mut`-param argument that denotes a place mints a CALL-SCOPED exclusive loan (born at the enclosing point, killed at point+1 so it never reaches the next statement), and any LATER argument of the SAME call whose place overlaps an earlier `mut`-arg's loan emits a conflict. Order- and call-scoped, exactly matching the fold: it cannot fire across statements, so `just_mut(x); read_int(x)` stays accepted, and it over-approximates only toward MORE real same-call conflicts the lexical checker also reports — no false positive. The loans are emitted as raw borrow_at/killed/conflict_at facts only (not pushed onto the loan list), so the generic use-while rule and the move passes are untouched; the solver invalidates them via liveness. Allowlist 7 -> 6 (borrow_use_while_excl pruned). Corpus parallel-run gate stays green (117 fixtures, zero unexpected divergence). Adds two hardening tests: the call-aliasing flag, and the call-scoped anti-over-rejection case. 528 -> 530 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cb23c03 commit 69d2796

2 files changed

Lines changed: 110 additions & 11 deletions

File tree

lib/borrow_extract.ml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,85 @@ let extract_fn (ctx : Borrow.context) (symbols : Symbol.t) (fd : fn_decl) : PF.f
401401
then conflict_at := (rl.rl_id, pt) :: !conflict_at)
402402
!loans)
403403
!use_at;
404+
(* M3 call-aliasing (mut-param argument). The lexical checker folds a call's
405+
arguments left-to-right: a [mut] parameter's argument takes a CALL-SCOPED
406+
EXCLUSIVE borrow ([record_borrow … Exclusive]) and every SUBSEQUENT
407+
argument's read trips [find_aliasing_exclusive] — so [mut_then_read(x, x)]
408+
is rejected, while the borrow's release after the call keeps
409+
[just_mut(x); read_int(x)] accepted. We mirror that exactly: per call,
410+
fold the args in order; mint a call-scoped exclusive loan (born at the
411+
enclosing point [pt], killed at [pt+1] so it never reaches the next
412+
statement) for each [mut]-param arg that denotes a place, and emit a
413+
conflict for any LATER arg of the SAME call whose place overlaps an
414+
exclusive loan minted by an EARLIER arg. Order- and call-scoped, matching
415+
the fold: it cannot fire across statements, and (over-approximating only
416+
toward MORE real same-call conflicts the lexical checker also sees) cannot
417+
introduce a false positive. These loans are emitted as raw facts only — not
418+
pushed onto [!loans] — so the generic use-while rule above and the move
419+
passes are unaffected; the solver needs just [borrow_at]/[killed]/
420+
[conflict_at] to invalidate them. *)
421+
let process_call pt fname args =
422+
match Hashtbl.find_opt ctx.Borrow.fn_sigs fname with
423+
| None -> ()
424+
| Some sg ->
425+
let owns = sg.Borrow.fn_param_ownerships in
426+
ignore (List.fold_left (fun excls (i, arg) ->
427+
let ap = Borrow.expr_to_place symbols (peel arg) in
428+
(match ap with
429+
| Some p ->
430+
List.iter (fun rl ->
431+
if Borrow.places_overlap p rl.rl_place then
432+
conflict_at := (rl.rl_id, pt) :: !conflict_at) excls
433+
| None -> ());
434+
match (List.nth_opt owns i, ap) with
435+
| (Some (Some Mut), Some p) ->
436+
let l = fresh next_loan in
437+
borrow_at := (l, pt) :: !borrow_at;
438+
loan_origin := (l, fresh next_origin) :: !loan_origin;
439+
killed := (l, pt + 1) :: !killed;
440+
{ rl_id = l; rl_place = p; rl_binder = None; rl_excl = true } :: excls
441+
| _ -> excls)
442+
[] (List.mapi (fun i a -> (i, a)) args))
443+
in
444+
let call_here pt e =
445+
match peel e with
446+
| ExprApp (f, args) ->
447+
(match peel f with ExprVar id -> process_call pt id.name args | _ -> ())
448+
| _ -> ()
449+
in
450+
let rec alias_stmt pt s =
451+
match s with
452+
| StmtLet sl -> alias_expr pt sl.sl_value
453+
| StmtExpr e -> alias_expr pt e
454+
| StmtAssign (l, _, r) -> alias_expr pt l; alias_expr pt r
455+
| StmtWhile (c, b) -> alias_expr pt c; alias_block pt b
456+
| StmtFor (_, it, b) -> alias_expr pt it; alias_block pt b
457+
and alias_expr pt e =
458+
call_here pt e;
459+
match peel e with
460+
| ExprApp (f, args) -> alias_expr pt f; List.iter (alias_expr pt) args
461+
| ExprUnary (_, x) -> alias_expr pt x
462+
| ExprBinary (a, _, b) | ExprIndex (a, b) -> alias_expr pt a; alias_expr pt b
463+
| ExprField (b, _) | ExprTupleIndex (b, _) -> alias_expr pt b
464+
| ExprTuple xs | ExprArray xs -> List.iter (alias_expr pt) xs
465+
| ExprIf ei ->
466+
alias_expr pt ei.ei_cond; alias_expr pt ei.ei_then;
467+
(match ei.ei_else with Some e -> alias_expr pt e | None -> ())
468+
| ExprMatch em ->
469+
alias_expr pt em.em_scrutinee;
470+
List.iter (fun a -> alias_expr pt a.ma_body) em.em_arms
471+
| ExprBlock blk -> alias_block pt blk
472+
| ExprTry et ->
473+
alias_block pt et.et_body;
474+
(match et.et_catch with Some arms -> List.iter (fun a -> alias_expr pt a.ma_body) arms | None -> ());
475+
(match et.et_finally with Some b -> alias_block pt b | None -> ())
476+
| _ -> ()
477+
and alias_block pt blk =
478+
List.iter (alias_stmt pt) blk.blk_stmts;
479+
(match blk.blk_expr with Some e -> alias_expr pt e | None -> ())
480+
in
481+
List.iteri alias_stmt blk.blk_stmts;
482+
(match blk.blk_expr with Some e -> alias_expr nstmts e | None -> ());
404483
{ PF.empty_facts with
405484
borrow_at = !borrow_at; loan_origin = !loan_origin;
406485
killed = !killed; cfg_edge = base_cfg @ !extra_edges; conflict_at = !conflict_at;

test/test_borrow_polonius.ml

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ let t_extract_shared_read_ok () =
181181
Alcotest.(check bool) "Polonius accepts read-while-shared-borrowed" false pol;
182182
Alcotest.(check bool) "agrees with lexical" lex pol
183183

184+
(* M3 call-aliasing: passing `x` to a `mut` parameter AND a plain parameter in
185+
the SAME call — `mut_then_read(x, x)`. The `mut`-arg mints a call-scoped
186+
exclusive loan; the second arg reads `x` while it is live ⇒ conflict. Both
187+
tiers must flag it. *)
188+
let t_extract_call_alias_excl () =
189+
let (pol, lex) = polonius_vs_lexical "borrow_use_while_excl.affine" in
190+
Alcotest.(check bool) "Polonius flags mut-param call-aliasing" true pol;
191+
Alcotest.(check bool) "agrees with lexical" lex pol
192+
193+
(* anti-over-rejection: a `mut`-param borrow is CALL-SCOPED, so a use of `x` in a
194+
LATER, separate call is fine — `just_mut(x); read_int(x)` is valid under both
195+
tiers. Guards the new call-aliasing rule against leaking the loan past its
196+
call. *)
197+
let t_extract_call_arg_then_use_ok () =
198+
let (pol, lex) = polonius_vs_lexical "borrow_call_arg_then_use.affine" in
199+
Alcotest.(check bool) "Polonius accepts mut-arg then later use" false pol;
200+
Alcotest.(check bool) "agrees with lexical" lex pol
201+
184202
(* expression-level branches (issue-draft 08 conditional-origin family): the RHS
185203
borrows the UNION of its arm sources. Polonius must now match the lexical fix. *)
186204
let t_extract_cond_if () =
@@ -302,21 +320,21 @@ let t_extract_reassign_uam () =
302320
regression gate M3 exists to provide. The extractor is NOT wired into
303321
bin/main.ml; this gate guards the equivalence claim, not the build verdict.
304322
305-
Each allowlist entry is (fixture, reason). 16 are sound UNDER-reporting —
306-
features the extractor does not model yet, where lexical flags an error and
307-
Polonius (conservatively) does not. 1 is a known false positive
308-
(reassign_old_ok) awaiting the reborrow/loan-release increment. As later
309-
increments land, entries graduate OFF this list; an allowlisted fixture that
310-
has started AGREEING is logged (prune it) but does not fail the gate. *)
323+
Each allowlist entry is (fixture, reason). All remaining entries are sound
324+
UNDER-reporting — features the extractor does not model yet, where lexical
325+
flags an error and Polonius (conservatively) does not, so the divergence is
326+
never a false positive. As later increments land, entries graduate OFF this
327+
list; an allowlisted fixture that has started AGREEING is logged (prune it)
328+
but does not fail the gate. *)
311329
let known_divergences : (string * string) list =
312330
[ (* loan-vs-loan exclusivity (use-while-exclusively-borrowed) is now modeled
313331
by the extractor (a direct read of a place while a live [&mut] loan
314332
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)";
333+
AGREE and were pruned. The call-aliasing shape (borrow_use_while_excl:
334+
passing [x] to a [mut] param and reading [x] in the same call) is now
335+
modeled too — a [mut]-param argument mints a call-scoped exclusive loan
336+
and a later same-call arg reading [x] conflicts — so it also AGREES and
337+
was pruned. *)
320338
(* unmodeled: return-escape / borrow-outlives-owner (no escape analysis) *)
321339
"borrow_outlives_owner.affine", "borrow-outlives-owner";
322340
"borrow_return_escape_local.affine", "return-escape (local)";
@@ -377,6 +395,8 @@ let tests =
377395
Alcotest.test_case "extract+solve: &mut/&mut overlap flagged (loan-vs-loan)" `Quick t_extract_mutref_conflict;
378396
Alcotest.test_case "extract+solve: use-while-&mut-borrowed flagged" `Quick t_extract_mutref_use_while;
379397
Alcotest.test_case "extract+solve: read-while-&-shared accepted (no over-reject)" `Quick t_extract_shared_read_ok;
398+
Alcotest.test_case "extract+solve: mut-param call-aliasing flagged" `Quick t_extract_call_alias_excl;
399+
Alcotest.test_case "extract+solve: mut-arg then later use accepted (call-scoped)" `Quick t_extract_call_arg_then_use_ok;
380400
(* M3 branch extraction: conditional-origin family (issue-draft 08) *)
381401
Alcotest.test_case "extract+solve: if-bound UAM, agrees with lexical" `Quick t_extract_cond_if;
382402
Alcotest.test_case "extract+solve: block-bound UAM, agrees with lexical" `Quick t_extract_cond_block;

0 commit comments

Comments
 (0)