Skip to content

Commit eb66a13

Browse files
hyperpolymathclaude
andcommitted
feat(borrow): CORE-01 Slice D (#177 pt3) — reject @linear capture by closure at borrow check
Tighter integration with the quantity checker for captured linears: the borrow checker now refuses to let a closure capture a @linear (QOne) binding. Pre-Slice-D, this case fell only to the quantity checker (which scales lambda captures by QOmega and emits LinearVariableUsedMultiple); now the same constraint fires earlier in the pipeline (Typecheck → Borrow → Quantity) with a more specific diagnostic that points at the *lambda span* — the actual capture site — rather than the downstream "used multiple times" message. Mechanism: - New [state.linear_bindings] tracks sym-ids of @linear bindings declared in the current function: explicit @linear annotations on `let`-statements and `let`-expressions, @linear function params, and `let x: Cmd[T] = …` (linear-by-construction per ADR-002). - At ExprLambda, collect-free walks the body; for each captured free-name we look up its symbol and reject with [LinearCapturedByClosure name * lambda_span] if the sym-id is in [linear_bindings]. - The Shared-borrow creation for non-linear captures is unchanged. Tests (+3, all green): - slice_d_captured_linear_let_rejected: @linear let captured → LinearCapturedByClosure("x", _) - slice_d_captured_linear_param_rejected: @linear param captured → LinearCapturedByClosure("y", _) - slice_d_captured_nonlinear_ok: anti-regression, non-linear capture must still pass Gate: 327 → 330 tests, 0 failures under `dune runtest --force`. Refs #177, CORE-01 pt3 (Slice D). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1a57163 commit eb66a13

5 files changed

Lines changed: 184 additions & 6 deletions

File tree

lib/borrow.ml

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ type state = {
9494
*caller-owned* referents and are deliberately absent here — returning
9595
a borrow of them is sound. CORE-01 pt2 / #177 (return-escape). *)
9696
mutable callee_owned_params : Symbol.symbol_id list;
97+
98+
(** Sym-ids of bindings declared @linear (`QOne`) in the current
99+
function — surfaced as explicit `@linear` annotations on lets/params
100+
and as inferred `QOne` from a [Cmd _] type annotation. Maintained
101+
alongside the quantity checker's per-block linear-tracking so the
102+
borrow checker can reject *capture* of these bindings by a closure:
103+
a closure can be called 0..N times, so capturing a linear binding
104+
makes its consumption count unprovable at borrow time. The quantity
105+
checker also catches this via [QOmega] scaling of captures, but the
106+
borrow-side error fires earlier in the pipeline and points at the
107+
*lambda* span (the capture site) rather than at a downstream "used
108+
multiple times" diagnostic. CORE-01 pt3 Slice D / #177. *)
109+
mutable linear_bindings : Symbol.symbol_id list;
97110
}
98111

99112
(** Borrow checker errors *)
@@ -108,6 +121,11 @@ type borrow_error =
108121
(** use of [place] (at the trailing span) while a still-live exclusive
109122
borrow holds it — the shared-XOR-exclusive aliasing rule, enforced
110123
at use sites, not only at borrow creation. CORE-01 / #177. *)
124+
| LinearCapturedByClosure of string * Span.t
125+
(** the named @linear binding has been captured as a closure free
126+
variable at the lambda's span — capturing extends consumption
127+
beyond what the @linear contract can be checked at borrow time
128+
(a closure may be called 0..N times). CORE-01 pt3 Slice D / #177. *)
111129
[@@deriving show]
112130

113131
type 'a result = ('a, borrow_error) Result.t
@@ -152,8 +170,27 @@ let create () : state =
152170
ref_bindings = [];
153171
block_local_syms = [];
154172
callee_owned_params = [];
173+
linear_bindings = [];
155174
}
156175

176+
(** Mirror of [Quantity.quantity_of_ty_annotation]: returns [QOne] when the
177+
given type annotation is a [Cmd _] application (linear by construction
178+
per ADR-002 / Stage 11), [QOmega] otherwise. Duplicated here so
179+
[Borrow] does not depend on [Quantity]; the canonical helper still
180+
lives in [quantity.ml]. CORE-01 pt3 Slice D / #177. *)
181+
let borrow_quantity_of_ty (te_opt : type_expr option) : quantity =
182+
match te_opt with
183+
| Some (TyApp ({ name = "Cmd"; _ }, _)) -> QOne
184+
| _ -> QOmega
185+
186+
(** Returns true if a let-binding declared with the given explicit-quantity
187+
annotation and type annotation should be tracked as @linear (QOne) by
188+
the borrow checker. CORE-01 pt3 Slice D / #177. *)
189+
let let_is_linear (q_opt : quantity option) (ty_opt : type_expr option) : bool =
190+
match q_opt with
191+
| Some q -> q = QOne
192+
| None -> borrow_quantity_of_ty ty_opt = QOne
193+
157194
(** Add a function signature to context *)
158195
let add_fn_signature (ctx : context) (fd : fn_decl) : unit =
159196
let sig_ = {
@@ -369,6 +406,13 @@ let format_borrow_error (e : borrow_error) : string =
369406
exclusive borrow (id %d) taken at %s is still live"
370407
(format_place place) (format_span use_span)
371408
b.b_id (format_span b.b_span)
409+
| LinearCapturedByClosure (name, lam_span) ->
410+
Printf.sprintf
411+
"cannot capture @linear binding `%s` in a closure (at %s)\n \
412+
a closure may be called zero or many times, so capturing a \
413+
@linear binding makes its consumption count unprovable. \
414+
Inline the use, or move the binding into the closure body."
415+
name (format_span lam_span)
372416

373417
(** Get span from an expression *)
374418
let rec expr_span (expr : expr) : Span.t =
@@ -900,9 +944,24 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
900944
) acc ops
901945
in
902946
let free_names = collect_free [] lam.elam_body in
947+
let borrow_span = expr_span (ExprLambda lam) in
948+
(* CORE-01 pt3 Slice D / #177: reject capture of a @linear binding.
949+
A closure may be called 0..N times, so capturing a linear
950+
binding lifts its consumption count out of what the borrow
951+
checker can prove finite-once. The quantity checker also
952+
catches this via [QOmega] scaling, but the borrow-side error
953+
(a) fires earlier in the pipeline and (b) names the lambda
954+
span — the actual capture site — rather than a downstream
955+
"used multiple times" diagnostic. *)
956+
let* () = List.fold_left (fun acc name ->
957+
let* () = acc in
958+
match lookup_symbol_by_name symbols name with
959+
| Some sym when List.mem sym.Symbol.sym_id state.linear_bindings ->
960+
Error (LinearCapturedByClosure (name, borrow_span))
961+
| _ -> Ok ()
962+
) (Ok ()) free_names in
903963
(* Create a Shared borrow for each captured free variable. If a borrow
904964
conflict or use-after-move is detected, fail immediately. *)
905-
let borrow_span = expr_span (ExprLambda lam) in
906965
let* () = List.fold_left (fun acc name ->
907966
let* () = acc in
908967
match expr_to_place symbols (ExprVar { name; span = borrow_span }) with
@@ -922,7 +981,11 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
922981
| PatVar id ->
923982
begin match lookup_symbol_by_name symbols id.name with
924983
| Some sym ->
925-
state.block_local_syms <- sym.Symbol.sym_id :: state.block_local_syms
984+
state.block_local_syms <- sym.Symbol.sym_id :: state.block_local_syms;
985+
(* Slice D / #177: record @linear let-bindings for the
986+
capture-rejection check on subsequent ExprLambda's. *)
987+
if let_is_linear lb.el_quantity lb.el_ty then
988+
state.linear_bindings <- sym.Symbol.sym_id :: state.linear_bindings
926989
| None -> ()
927990
end
928991
| _ -> ()
@@ -1292,7 +1355,11 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
12921355
| PatVar id ->
12931356
begin match lookup_symbol_by_name symbols id.name with
12941357
| Some sym ->
1295-
state.block_local_syms <- sym.Symbol.sym_id :: state.block_local_syms
1358+
state.block_local_syms <- sym.Symbol.sym_id :: state.block_local_syms;
1359+
(* Slice D / #177: track @linear let-bindings for the
1360+
capture-rejection check on subsequent ExprLambda's. *)
1361+
if let_is_linear sl.sl_quantity sl.sl_ty then
1362+
state.linear_bindings <- sym.Symbol.sym_id :: state.linear_bindings
12961363
| None -> ()
12971364
end
12981365
| _ -> ()
@@ -1385,7 +1452,12 @@ let check_function (ctx : context) (symbols : Symbol.t) (fd : fn_decl) : unit re
13851452
| None | Some Own ->
13861453
state.callee_owned_params <-
13871454
sym.sym_id :: state.callee_owned_params
1388-
| Some Ref | Some Mut -> ())
1455+
| Some Ref | Some Mut -> ());
1456+
(* Slice D / #177: track @linear-annotated params for the
1457+
capture-rejection check on subsequent ExprLambda's. *)
1458+
if p.p_quantity = Some QOne then
1459+
state.linear_bindings <-
1460+
sym.sym_id :: state.linear_bindings
13891461
| None -> ())
13901462
) fd.fd_params;
13911463
match fd.fd_body with
@@ -1480,6 +1552,20 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
14801552
one helper so all CFG-join sites agree). Finally runs after
14811553
the merge, deterministically, against the merged state.
14821554
1555+
CORE-01 pt3 Slice D (2026-05-26): borrow-side rejection of
1556+
@linear-binding capture by a closure. A closure may be called
1557+
0..N times, so capturing a [@linear] (`QOne`) binding lifts its
1558+
consumption count out of what the borrow checker can prove
1559+
finite-once. The quantity checker also rejects this via [QOmega]
1560+
scaling of lambda captures (quantity.ml ExprLambda), but the
1561+
borrow-side error fires earlier in the pipeline (Typecheck →
1562+
Borrow → Quantity) and names the *lambda* span as the capture
1563+
site rather than producing a downstream "used multiple times"
1564+
diagnostic. [state.linear_bindings] is populated from
1565+
[@linear] / `Cmd _` annotations on params, `let`-statements, and
1566+
`let`-expressions; [ExprLambda] then rejects free-vars whose
1567+
sym-id is in that set with [LinearCapturedByClosure].
1568+
14831569
Still deferred:
14841570
- Reborrow through indirection: `r = some_other_ref_var` (RHS not a
14851571
direct `&place`) does not yet copy the other binder's graph
@@ -1496,6 +1582,4 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
14961582
move imprecision first (assignment is currently treated as a
14971583
read of LHS, so `x = …` after a prior move spuriously fails).
14981584
Couple Slice C' with the StmtAssign clear-on-rewrite fix.
1499-
- Tighter integration with the quantity checker for captured
1500-
linears (Slice D).
15011585
*)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice D / #177: a closure that captures a @linear
5+
// let-binding must be rejected by the borrow checker — a closure
6+
// may be called 0..N times, so capturing a @linear binding lifts
7+
// its consumption count out of finite-once provability.
8+
// Expected: Error LinearCapturedByClosure.
9+
10+
module SliceDCapturedLinearLetRejected;
11+
12+
fn capture_linear_let() -> Int {
13+
@linear let x = 42;
14+
let f = fn() => x + 1;
15+
f()
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice D / #177: a closure that captures a @linear
5+
// function parameter is rejected at the borrow checker for the
6+
// same reason as the let-binding case — a closure may be called
7+
// 0..N times. Pre-Slice-D this would have surfaced only at the
8+
// quantity checker (as "used multiple times via QOmega scaling");
9+
// now it surfaces at borrow check with a span pointing at the
10+
// closure. Expected: Error LinearCapturedByClosure.
11+
12+
module SliceDCapturedLinearParamRejected;
13+
14+
fn capture_linear_param(@linear y: Int) -> Int {
15+
let f = fn() => y + 1;
16+
f()
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice D / #177: anti-regression — capturing a
5+
// non-linear (unannotated → defaults to QOmega) binding by a
6+
// closure must still pass. This pins that the new rejection is
7+
// scoped to @linear captures, not any free-var capture. Expected: Ok.
8+
9+
module SliceDCapturedNonlinearOk;
10+
11+
fn capture_nonlinear() -> Int {
12+
let x = 7;
13+
let f = fn() => x + 1;
14+
f()
15+
}

test/test_e2e.ml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4415,6 +4415,46 @@ 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 D / #177 — borrow-side rejection of @linear
4419+
capture by a closure. The quantity checker already rejected
4420+
these via QOmega scaling, but the borrow-side error fires
4421+
earlier (Typecheck → Borrow → Quantity) and points at the
4422+
lambda span. Three tests pin: (1) @linear let captured;
4423+
(2) @linear param captured; (3) anti-regression — non-linear
4424+
capture must still pass. *)
4425+
let test_slice_d_captured_linear_let_rejected () =
4426+
match borrow_result (fixture "slice_d_captured_linear_let_rejected.affine") with
4427+
| Error (Borrow.LinearCapturedByClosure (name, _)) ->
4428+
Alcotest.(check string) "captured name surfaced in error" "x" name
4429+
| Error e ->
4430+
Alcotest.fail ("Slice D: expected LinearCapturedByClosure on `|| x + 1` \
4431+
capture of @linear let x, got: "
4432+
^ Borrow.format_borrow_error e)
4433+
| Ok () ->
4434+
Alcotest.fail "Slice D regressed: @linear let-binding was silently \
4435+
captured by closure — multi-call would break linear \
4436+
contract"
4437+
4438+
let test_slice_d_captured_linear_param_rejected () =
4439+
match borrow_result (fixture "slice_d_captured_linear_param_rejected.affine") with
4440+
| Error (Borrow.LinearCapturedByClosure (name, _)) ->
4441+
Alcotest.(check string) "captured param name surfaced" "y" name
4442+
| Error e ->
4443+
Alcotest.fail ("Slice D: expected LinearCapturedByClosure on `|| y + 1` \
4444+
capture of @linear param y, got: "
4445+
^ Borrow.format_borrow_error e)
4446+
| Ok () ->
4447+
Alcotest.fail "Slice D regressed: @linear param was silently captured \
4448+
by closure"
4449+
4450+
let test_slice_d_captured_nonlinear_ok () =
4451+
match borrow_result (fixture "slice_d_captured_nonlinear_ok.affine") with
4452+
| Ok () -> ()
4453+
| Error e ->
4454+
Alcotest.fail ("Slice D anti-regression: non-linear capture was \
4455+
spuriously rejected — the new rule must scope to \
4456+
@linear only: " ^ Borrow.format_borrow_error e)
4457+
44184458
let borrow_tests = [
44194459
Alcotest.test_case "BorrowOutlivesOwner: &local escapes its block"
44204460
`Quick test_borrow_outlives_owner;
@@ -4450,6 +4490,12 @@ let borrow_tests = [
44504490
`Quick test_slice_c_catch_arm_isolation;
44514491
Alcotest.test_case "Slice C anti-regression: body's move persists past try (#177 pt3 Slice C)"
44524492
`Quick test_slice_c_body_move_persists;
4493+
Alcotest.test_case "Slice D: @linear let captured by closure rejected (#177 pt3)"
4494+
`Quick test_slice_d_captured_linear_let_rejected;
4495+
Alcotest.test_case "Slice D: @linear param captured by closure rejected (#177 pt3)"
4496+
`Quick test_slice_d_captured_linear_param_rejected;
4497+
Alcotest.test_case "Slice D anti-regression: non-linear capture still OK (#177 pt3)"
4498+
`Quick test_slice_d_captured_nonlinear_ok;
44534499
]
44544500

44554501
(* ============================================================================

0 commit comments

Comments
 (0)