Skip to content

Commit d2f9b7b

Browse files
hyperpolymathclaude
andcommitted
feat(linear-arrows): enforce -[q]-> quantity annotations on lambda params
Three coordinated fixes: **typecheck.ml — lambda synth:** Replace the hardcoded QOmega in ExprLambda synthesis with the actual p_quantity from each parameter declaration. A lambda `|@linear x: T| e` now synthesises type `T -[1]-> U` instead of `T -[ω]-> U`, so the arrow type faithfully records the linearity contract. **typecheck.ml — lambda check mode:** When checking a lambda against an expected `TArrow (_, q, _, _)`, verify that any explicit param quantity annotation matches the expected arrow quantity. Unannotated params silently inherit the expected quantity (correct for context-driven inference). **quantity.ml — lambda body param checking:** Add an `errors` accumulator to `env` (since `infer_usage_expr` is unit). In `ExprLambda`, declare annotated params via `env_declare` so `env_use` tracks their usage counts. After walking the body, check each annotated param with `check_quantity` and push violations to `env.errors`. Drain accumulated errors at the end of `check_function_quantities` (step 4). Also fixes: save/restore `env.quantities` entries that lambda params shadow, so nested scopes cannot leak stale quantity entries. **codegen.ml / wasm.ml / wasm_encode.ml:** Add `custom_sections` field to `wasm_module` and emit it in the encoder (Wasm section ID 0). Wire `ownership_kind` annotations collected during codegen into the `affinescript.ownership` custom section for typed-wasm Level 7/10 verification. Fix `codegen.ml` record literal to include the new field. **Tests:** - `test/e2e/fixtures/linear_arrow.affine` — valid single-use @linear lambda - `test/e2e/fixtures/linear_arrow_violation.affine` — double-use @linear lambda - `E2E Linear Arrows` suite: 2 cases (valid accepted, violation rejected) - 75 tests total, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 48e289e commit d2f9b7b

8 files changed

Lines changed: 385 additions & 28 deletions

File tree

lib/codegen.ml

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
open Ast
1111
open Wasm
1212

13+
(** Ownership kind for typed-wasm schema annotations.
14+
Maps AffineScript ownership qualifiers to typed-wasm Level 7/10 verification. *)
15+
type ownership_kind =
16+
| Unrestricted (** Plain value, no ownership constraint (Wasm i32/f64 etc.) *)
17+
| Linear (** TyOwn / own — consumed exactly once (typed-wasm Level 10 linearity) *)
18+
| SharedBorrow (** TyRef / ref — read-only aliasing safety (typed-wasm Level 7) *)
19+
| ExclBorrow (** TyMut / mut — exclusive mutable aliasing safety (typed-wasm Level 7) *)
20+
1321
(** Code generation context *)
1422
type context = {
1523
types : func_type list; (** type section *)
@@ -29,6 +37,10 @@ type context = {
2937
string_data : (string * int) list; (** string content -> memory offset *)
3038
next_string_offset : int; (** next available offset for string data *)
3139
datas : data list; (** data segments *)
40+
ownership_annots : (int * ownership_kind list * ownership_kind) list;
41+
(** Collected ownership annotations: (func_index, param_kinds, return_kind).
42+
Emitted as the [affinescript.ownership] Wasm custom section for typed-wasm
43+
Level 7/10 verification. Kind encoding: 0=Unrestricted, 1=Linear, 2=SharedBorrow, 3=ExclBorrow. *)
3244
}
3345

3446
(** Code generation error *)
@@ -70,8 +82,63 @@ let create_context () : context = {
7082
string_data = [];
7183
next_string_offset = 2048; (* Start strings after heap at offset 2048 *)
7284
datas = [];
85+
ownership_annots = [];
7386
}
7487

88+
(** Extract ownership kind from a parameter declaration.
89+
Checks p_ownership first; falls back to the shape of p_ty. *)
90+
let ownership_kind_of_param (p : param) : ownership_kind =
91+
match p.p_ownership with
92+
| Some Own -> Linear
93+
| Some Ref -> SharedBorrow
94+
| Some Mut -> ExclBorrow
95+
| None ->
96+
match p.p_ty with
97+
| TyOwn _ -> Linear
98+
| TyRef _ -> SharedBorrow
99+
| TyMut _ -> ExclBorrow
100+
| _ -> Unrestricted
101+
102+
(** Extract ownership kind from an optional return type expression *)
103+
let ownership_kind_of_ret (ret : type_expr option) : ownership_kind =
104+
match ret with
105+
| Some (TyOwn _) -> Linear
106+
| Some (TyRef _) -> SharedBorrow
107+
| Some (TyMut _) -> ExclBorrow
108+
| _ -> Unrestricted
109+
110+
(** Encode an ownership_kind as a single byte (0–3) *)
111+
let ownership_kind_byte = function
112+
| Unrestricted -> 0 | Linear -> 1 | SharedBorrow -> 2 | ExclBorrow -> 3
113+
114+
(** Build the payload for the [affinescript.ownership] Wasm custom section.
115+
Encoding (all little-endian):
116+
u32 entry_count
117+
per entry:
118+
u32 func_index
119+
u8 param_count
120+
u8* param_kind (one per param, see kind encoding above)
121+
u8 return_kind *)
122+
let build_ownership_section (annots : (int * ownership_kind list * ownership_kind) list) : bytes =
123+
if annots = [] then Bytes.empty
124+
else
125+
let buf = Buffer.create 64 in
126+
let write_u32_le n =
127+
Buffer.add_char buf (Char.chr (n land 0xff));
128+
Buffer.add_char buf (Char.chr ((n lsr 8) land 0xff));
129+
Buffer.add_char buf (Char.chr ((n lsr 16) land 0xff));
130+
Buffer.add_char buf (Char.chr ((n lsr 24) land 0xff))
131+
in
132+
let write_u8 n = Buffer.add_char buf (Char.chr (n land 0xff)) in
133+
write_u32_le (List.length annots);
134+
List.iter (fun (func_idx, param_kinds, ret_kind) ->
135+
write_u32_le func_idx;
136+
write_u8 (List.length param_kinds);
137+
List.iter (fun k -> write_u8 (ownership_kind_byte k)) param_kinds;
138+
write_u8 (ownership_kind_byte ret_kind)
139+
) annots;
140+
Buffer.to_bytes buf
141+
75142
(** Map AffineScript type to WASM value type *)
76143
let type_to_wasm (ty : type_expr) : value_type result =
77144
match ty with
@@ -1617,9 +1684,14 @@ let gen_decl (ctx : context) (decl : top_level) : context result =
16171684
(* Determine function index before generating *)
16181685
let func_idx = import_func_count ctx_with_type + List.length ctx_with_type.funcs in
16191686

1620-
(* Register function name to index mapping *)
1687+
(* Stage 2: Extract ownership annotations for typed-wasm [affinescript.ownership] section *)
1688+
let param_kinds = List.map ownership_kind_of_param fd.fd_params in
1689+
let ret_kind = ownership_kind_of_ret fd.fd_ret_ty in
1690+
1691+
(* Register function name to index mapping and record ownership annotations *)
16211692
let ctx_with_func_idx = { ctx_with_type with
1622-
func_indices = ctx_with_type.func_indices @ [(fd.fd_name.name, func_idx)]
1693+
func_indices = ctx_with_type.func_indices @ [(fd.fd_name.name, func_idx)];
1694+
ownership_annots = ctx_with_type.ownership_annots @ [(func_idx, param_kinds, ret_kind)];
16231695
} in
16241696

16251697
(* Generate function with correct type index *)
@@ -1722,6 +1794,14 @@ let generate_module (prog : program) : wasm_module result =
17221794
(* Add memory export *)
17231795
let exports_with_mem = { e_name = "memory"; e_desc = ExportMemory 0 } :: ctx'.exports in
17241796

1797+
(* Stage 2: Build [affinescript.ownership] custom section from collected annotations *)
1798+
let ownership_payload = build_ownership_section ctx'.ownership_annots in
1799+
let custom_sections = if Bytes.length ownership_payload > 0 then
1800+
[("affinescript.ownership", ownership_payload)]
1801+
else
1802+
[]
1803+
in
1804+
17251805
Ok {
17261806
types = ctx'.types;
17271807
funcs = all_funcs;
@@ -1733,4 +1813,5 @@ let generate_module (prog : program) : wasm_module result =
17331813
elems = elems;
17341814
datas = List.rev ctx'.datas; (* Reverse to get original order *)
17351815
start = None;
1816+
custom_sections;
17361817
}

lib/quantity.ml

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,18 @@ type env = {
198198
(** Declared quantity for each tracked variable. *)
199199
usages : (string, usage) Hashtbl.t;
200200
(** Current observed usage count for each tracked variable. *)
201+
errors : (quantity_error * Span.t) list ref;
202+
(** Quantity errors accumulated by nested constructs (lambda params).
203+
[infer_usage_expr] returns [unit], so nested violations are
204+
pushed here and drained at the end of [check_function_quantities]. *)
201205
}
202206

203207
(** Create a fresh quantity environment. *)
204208
let create_env () : env =
205209
{
206210
quantities = Hashtbl.create 16;
207211
usages = Hashtbl.create 16;
212+
errors = ref [];
208213
}
209214

210215
(** Declare a variable with its quantity annotation in the environment.
@@ -299,23 +304,55 @@ let rec infer_usage_expr (env : env) (expr : expr) : unit =
299304
300305
Consequence: a lambda that captures an @linear variable raises
301306
LinearVariableUsedMultiple, because scale_usage QOmega UOne = UMany.
302-
This closes BUG-004. *)
307+
This closes BUG-004.
308+
309+
Additionally: lambda params with explicit quantity annotations are
310+
declared in the env so env_use tracks them, and their usage is
311+
verified against their declared quantity after the body walk.
312+
Violations are pushed to env.errors (since infer_usage_expr is unit)
313+
and drained by check_function_quantities at the top level. *)
303314
let param_names =
304315
List.map (fun (p : param) -> p.p_name.name) lam.elam_params
305316
in
306317
let before_snapshot = env_snapshot env in
307-
(* Shadow lambda params so their usage inside the body doesn't leak
308-
into the outer quantity environment. *)
309-
List.iter (fun name ->
310-
if Hashtbl.mem env.usages name then
311-
Hashtbl.replace env.usages name UZero
312-
(* Params not yet in env are fine — they are lambda-local. *)
313-
) param_names;
318+
(* Save any quantity entries that the param names may shadow, so we
319+
can restore them after the lambda body is analysed. *)
320+
let old_quantities = List.map (fun name ->
321+
(name, Hashtbl.find_opt env.quantities name)
322+
) param_names in
323+
(* Declare each lambda param in the env so env_use can track it.
324+
Annotated params get their declared quantity; unannotated params
325+
get QOmega (unrestricted — no constraint to verify). *)
326+
List.iter (fun (p : param) ->
327+
let q = Option.value p.p_quantity ~default:QOmega in
328+
env_declare env p.p_name.name q
329+
) lam.elam_params;
314330
infer_usage_expr env lam.elam_body;
315331
let after_snapshot = env_snapshot env in
332+
(* Check annotated params against their declared quantities.
333+
Only params with an explicit annotation are verified — unannotated
334+
params default to QOmega which accepts any usage count. *)
335+
List.iter (fun (p : param) ->
336+
match p.p_quantity with
337+
| None -> ()
338+
| Some declared_q ->
339+
let actual =
340+
Hashtbl.find_opt after_snapshot p.p_name.name
341+
|> Option.value ~default:UZero
342+
in
343+
(match check_quantity p.p_name declared_q actual with
344+
| Ok () -> ()
345+
| Error e -> env.errors := e :: !(env.errors))
346+
) lam.elam_params;
316347
(* Restore outer env, then re-apply QOmega-scaled deltas for captured
317348
outer variables only (exclude lambda params). *)
318349
env_restore env before_snapshot;
350+
(* Restore quantity entries that lambda params may have shadowed. *)
351+
List.iter (fun (name, old_q) ->
352+
match old_q with
353+
| Some q -> Hashtbl.replace env.quantities name q
354+
| None -> Hashtbl.remove env.quantities name
355+
) old_quantities;
319356
Hashtbl.iter (fun name after_u ->
320357
if List.mem name param_names then ()
321358
else begin
@@ -562,18 +599,29 @@ let check_function_quantities (fd : fn_decl) : unit result =
562599
| FnBlock blk -> infer_usage_block env blk
563600
| FnExpr e -> infer_usage_expr env e
564601
end;
565-
(* Step 3: check each parameter *)
566-
List.fold_left (fun acc (param : param) ->
567-
match acc with
568-
| Error _ -> acc (* Stop at first error *)
569-
| Ok () ->
570-
let declared = Option.value param.p_quantity ~default:QOmega in
571-
let actual =
572-
Hashtbl.find_opt env.usages param.p_name.name
573-
|> Option.value ~default:UZero
574-
in
575-
check_quantity param.p_name declared actual
576-
) (Ok ()) fd.fd_params
602+
(* Step 3: check each top-level parameter *)
603+
let param_result =
604+
List.fold_left (fun acc (param : param) ->
605+
match acc with
606+
| Error _ -> acc (* Stop at first error *)
607+
| Ok () ->
608+
let declared = Option.value param.p_quantity ~default:QOmega in
609+
let actual =
610+
Hashtbl.find_opt env.usages param.p_name.name
611+
|> Option.value ~default:UZero
612+
in
613+
check_quantity param.p_name declared actual
614+
) (Ok ()) fd.fd_params
615+
in
616+
(* Step 4: drain errors accumulated by nested lambda param checks.
617+
infer_usage_expr is unit, so nested violations are pushed to
618+
env.errors during the body walk above. Report the first one. *)
619+
match param_result with
620+
| Error _ -> param_result
621+
| Ok () ->
622+
match !(env.errors) with
623+
| [] -> Ok ()
624+
| (err, span) :: _ -> Error (err, span)
577625

578626
(** Check quantities for all functions in a program.
579627

lib/typecheck.ml

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -686,11 +686,21 @@ let rec synth (ctx : context) (expr : expr) : ty result =
686686
| Some sc -> Hashtbl.replace ctx.name_types n sc
687687
| None -> Hashtbl.remove ctx.name_types n
688688
) old;
689-
(* Build the arrow type: curried for multi-param *)
689+
(* Build the arrow type: curried for multi-param.
690+
Each arrow carries the declared quantity of the corresponding parameter.
691+
If the parameter has no explicit annotation, we default to QOmega
692+
(unrestricted), matching the convention used by check_fn_decl. *)
690693
let eff = fresh_effvar ctx.level in
691-
let ty = List.fold_right (fun param_ty acc ->
692-
TArrow (param_ty, QOmega, acc, eff)
693-
) param_tys body_ty in
694+
let param_qty_pairs = List.map2 (fun (p : param) param_ty ->
695+
let q = match p.p_quantity with
696+
| Some q -> lower_quantity q
697+
| None -> QOmega
698+
in
699+
(q, param_ty)
700+
) elam_params param_tys in
701+
let ty = List.fold_right (fun (q, param_ty) acc ->
702+
TArrow (param_ty, q, acc, eff)
703+
) param_qty_pairs body_ty in
694704
Ok ty
695705

696706
(* Function application *)
@@ -954,13 +964,30 @@ and check_stmt (ctx : context) (stmt : stmt) : unit result =
954964
(** Check that an expression has the expected type. *)
955965
and check (ctx : context) (expr : expr) (expected : ty) : unit result =
956966
match expr with
957-
(* Lambda against arrow type: check mode is more precise *)
967+
(* Lambda against arrow type: check mode is more precise.
968+
We peel the expected arrow type one param at a time. For each param:
969+
- If the param has an explicit quantity annotation, we verify it is
970+
consistent with the arrow quantity from the expected type.
971+
- The arrow quantity from the expected type is used as the definitive
972+
quantity for binding (so an unannotated lambda param correctly inherits
973+
the quantity from its context, e.g. a @linear annotation on the let). *)
958974
| ExprLambda { elam_params; elam_body; elam_ret_ty = _ }
959975
when (match repr expected with TArrow _ -> true | _ -> false) ->
960976
let rec peel_arrows ty params =
961977
match params, repr ty with
962978
| [], _ -> Ok ()
963-
| p :: rest, TArrow (param_ty, _q, ret_ty, _eff) ->
979+
| p :: rest, TArrow (param_ty, q, ret_ty, _eff) ->
980+
(* Validate explicit quantity annotation against the expected arrow. *)
981+
let* () = match p.p_quantity with
982+
| Some pq ->
983+
let pq' = lower_quantity pq in
984+
if pq' = q then Ok ()
985+
else Error (TypeMismatch {
986+
expected = TArrow (param_ty, q, ret_ty, EPure);
987+
got = TArrow (param_ty, pq', ret_ty, EPure);
988+
})
989+
| None -> Ok ()
990+
in
964991
bind_var ctx p.p_name.name param_ty;
965992
peel_arrows ret_ty rest
966993
| _ -> synth_and_unify ctx expr expected

lib/wasm.ml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ type wasm_module = {
242242
elems : elem list; (** element segments for table initialization *)
243243
datas : data list; (** data segments for memory initialization *)
244244
start : int option; (** optional start function index *)
245+
custom_sections : (string * bytes) list;
246+
(** Named custom sections (Wasm section ID 0).
247+
Used for [affinescript.ownership] — carries ownership annotations
248+
(TyOwn/TyRef/TyMut) that survive to the binary for typed-wasm
249+
Level 7/10 verification. *)
245250
}
246251
[@@deriving show, eq]
247252

@@ -257,4 +262,5 @@ let empty_module () : wasm_module = {
257262
elems = [];
258263
datas = [];
259264
start = None;
265+
custom_sections = [];
260266
}

lib/wasm_encode.ml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,14 @@ let write_module_to_file path (m : wasm_module) : unit =
360360
add_section buf 10 (fun b -> add_vec b m.funcs add_code);
361361
add_section buf 11 (fun b -> add_vec b m.datas add_data);
362362

363+
(* Emit custom sections (Wasm section ID 0) — includes [affinescript.ownership] typed-wasm schema *)
364+
List.iter (fun (name, payload) ->
365+
add_section buf 0 (fun b ->
366+
add_string b name;
367+
add_bytes b payload
368+
)
369+
) m.custom_sections;
370+
363371
let oc = open_out_bin path in
364372
output_bytes oc (Bytes.of_string (Buffer.contents buf));
365373
close_out oc
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// End-to-end test: linear arrow quantity enforcement in lambda expressions
3+
// Tests:
4+
// - Lambda with @linear param produces T -[1]-> U type (not T -[ω]-> U)
5+
// - Lambda with @linear param used once passes quantity check
6+
// - Named function with @linear param passes quantity check
7+
8+
fn consume_once(@linear x: Int) -> Int = x + 1;
9+
10+
fn wrap_linear() -> Int {
11+
let f = |@linear x: Int| x + 1;
12+
f(41)
13+
}
14+
15+
fn hof_linear(g: (Int) -> Int, n: Int) -> Int = g(n);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Regression: @linear param in a lambda must not be used twice inside the body.
3+
// The quantity checker must reject this program because `x` is used
4+
// two times inside the lambda body, violating the @linear (must use once)
5+
// contract declared on the lambda parameter.
6+
7+
fn trigger_double_use() -> Int {
8+
let f = |@linear x: Int| x + x;
9+
f(21)
10+
}

0 commit comments

Comments
 (0)