Skip to content

Commit f6cab6d

Browse files
hyperpolymathclaude
andcommitted
feat(tw_verify): Stage 9 — per-path min/max linearity analysis
Replace branch-max single-count with per-path (min_uses, max_uses) range analysis in the typed-wasm ownership verifier. Key changes: - tw_verify.ml: `count_uses_range` returns (min, max) across all paths. For `If` nodes: min=min(then,else), max=max(then,else). Sequential instrs sum their ranges. `LinearDroppedOnSomePath` error added for min=0, max≥1. - tea_router.ml: `fn_push` else-branch now `[LocalGet 0; Drop]` instead of `[]`. Ownership is explicitly discharged when the stack is full, giving min=1, max=1 on both paths — clean under per-path analysis. - test_e2e.ml: 2 new E2E tests (one-arm drop → LinearDroppedOnSomePath; explicit else-drop → OK). Test 7 comment updated. Bridge/router verify comments updated. 161/161 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fbe7c98 commit f6cab6d

3 files changed

Lines changed: 148 additions & 64 deletions

File tree

lib/tea_router.ml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,13 @@ let fn_init : func = {
158158
the back-stack if the stack is not full.
159159
160160
Encoding: [stack_data[stack_len] := screen_tag; stack_len += 1].
161-
[screen_tag] is Linear — consumed exactly once per TEA update cycle. *)
161+
[screen_tag] is Linear — consumed exactly once per TEA update cycle.
162+
163+
When the stack is full the screen_tag is explicitly dropped via [Drop]
164+
rather than left in an unused-param else-branch. This satisfies the
165+
per-path linearity verifier: both branches consume param 0 exactly once
166+
(store in the then-branch, explicit drop in the else-branch), giving
167+
min_uses = 1, max_uses = 1 → OK. *)
162168
let fn_push : func = {
163169
f_type = 1;
164170
f_locals = [];
@@ -189,8 +195,8 @@ let fn_push : func = {
189195
I32Add;
190196
I32Store (2, 0);
191197
],
192-
(* else: stack full — no-op, message consumed but state unchanged *)
193-
[]
198+
(* else: stack full — explicitly consume screen_tag (ownership discharged) *)
199+
[ LocalGet 0; Drop ]
194200
);
195201
];
196202
}

lib/tw_verify.ml

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
(* SPDX-License-Identifier: PMPL-1.0-or-later *)
22
(* SPDX-FileCopyrightText: 2024-2026 hyperpolymath *)
33

4-
(** typed-wasm ownership verifier — Stage 7.
4+
(** typed-wasm ownership verifier — Stage 7 (per-path analysis added Stage 9).
55
66
Statically verifies typed-wasm Level 7 (aliasing safety) and Level 10
77
(linearity) constraints on AffineScript's Wasm IR.
@@ -13,21 +13,21 @@
1313
re-checks them on the emitted Wasm IR to catch any codegen bugs.
1414
1515
Level 10 — Linearity: a parameter annotated [Linear] (TyOwn) must be
16-
loaded exactly once in the function body. Loading zero times means the
17-
owned value is dropped; loading more than once means it may be duplicated.
16+
loaded exactly once on every execution path. Zero uses = dropped;
17+
more than one use = possible duplication; non-zero use on some paths
18+
but zero on others = partial drop (dropped on the zero-count path).
1819
1920
Level 7 — Aliasing safety: a parameter annotated [ExclBorrow] (TyMut)
20-
must be loaded at most once. Multiple loads create aliased references to
21-
the same mutable memory, violating exclusive-borrow invariants.
21+
must be loaded at most once on any execution path.
2222
23-
[SharedBorrow] (TyRef) and [Unrestricted] params are not checked by this
24-
pass (no constraints violated by multiple reads of a shared reference).
23+
[SharedBorrow] (TyRef) and [Unrestricted] params are not checked.
2524
26-
Branch semantics: for [If] instructions, we take the {b max} of the
27-
use-counts across the two branches (since only one branch executes at
28-
runtime). This is conservative: a linear param used exactly once in
29-
each branch of an if/else is counted as 1 (correct), not 2. Stage 8
30-
will refine this with a per-path analysis if needed.
25+
Branch semantics (per-path min/max): for [If] instructions we compute
26+
[(min_then, max_then)] and [(min_else, max_else)] independently and
27+
combine as [(min_then, max_then, min_else, max_else) →
28+
(min min_then min_else, max max_then max_else)]. A Linear param used
29+
exactly once in BOTH branches gives (1, 1) — OK. A param used in the
30+
then-branch only gives (min=0, max=1) — [LinearDroppedOnSomePath].
3131
3232
@see typed-wasm LEVEL-STATUS.md — Level 7 and Level 10 sections.
3333
*)
@@ -41,37 +41,57 @@ open Codegen
4141
(** An ownership violation found in a Wasm function body. *)
4242
type ownership_error =
4343
| LinearNotUsed of { func_idx : int; param_idx : int }
44-
(** Level 10: Linear parameter was never loaded — dropped without consumption.
45-
The owned resource leaks. *)
44+
(** Level 10: Linear parameter was never loaded on any path — dropped without
45+
consumption. The owned resource leaks unconditionally. *)
46+
| LinearDroppedOnSomePath of { func_idx : int; param_idx : int }
47+
(** Level 10: Linear parameter is consumed on some execution paths but
48+
silently dropped on others (per-path min_uses = 0, max_uses >= 1).
49+
The caller's ownership guarantee is satisfied only conditionally —
50+
the drop path leaks the resource. *)
4651
| LinearUsedMultiple of { func_idx : int; param_idx : int; count : int }
47-
(** Level 10: Linear parameter was loaded [count] times — potential duplication.
48-
Only one consumer is permitted. *)
52+
(** Level 10: Linear parameter was loaded [count] times on some path —
53+
potential duplication. Only one consumer is permitted. *)
4954
| ExclBorrowAliased of { func_idx : int; param_idx : int; count : int }
5055
(** Level 7: ExclBorrow parameter was loaded [count] times — aliasing violation.
5156
An exclusive borrow must not have multiple simultaneous references. *)
5257

5358
(* ============================================================================
54-
Use-count analysis
59+
Per-path use-range analysis
5560
============================================================================ *)
5661

57-
(** Count how many times [local_idx] is loaded inside [instrs].
62+
(** Compute [(min_uses, max_uses)] — the minimum and maximum number of times
63+
[local_idx] is loaded across all execution paths within [instrs].
5864
59-
For [If] nodes the branch counts are combined with [max] rather than
60-
summed, reflecting that only one branch executes. All other nodes are
61-
summed. Nested [Block] and [Loop] bodies are descended into. *)
62-
let rec count_uses (local_idx : int) (instrs : Wasm.instr list) : int =
63-
List.fold_left (fun acc instr -> acc + uses_in_instr local_idx instr) 0 instrs
65+
Sequential instructions sum their ranges: if instr A uses the param
66+
(1, 1) times and instr B uses it (0, 1) times, the sequence is (1, 2).
6467
65-
and uses_in_instr (local_idx : int) (instr : Wasm.instr) : int =
68+
For [If] nodes only one branch executes, so we take the component-wise
69+
min/max of the two branch ranges:
70+
- [min = min(then_min, else_min)]: may be zero if one branch doesn't use it
71+
- [max = max(then_max, else_max)]: the worst-case duplication count
72+
73+
Example: [If { LocalGet 0 } { LocalGet 0 }] → [(1, 1)] OK for Linear.
74+
Example: [If { LocalGet 0 } { [] }] → [(0, 1)] LinearDroppedOnSomePath.
75+
Example: [If { LocalGet 0; LocalGet 0 } { LocalGet 0 }] → [(1, 2)]
76+
→ LinearUsedMultiple (max = 2).
77+
78+
Nested [Block] and [Loop] bodies are descended into (no new paths). *)
79+
let rec count_uses_range (local_idx : int) (instrs : Wasm.instr list) : int * int =
80+
List.fold_left (fun (a_min, a_max) instr ->
81+
let (i_min, i_max) = uses_range_in_instr local_idx instr in
82+
(a_min + i_min, a_max + i_max)
83+
) (0, 0) instrs
84+
85+
and uses_range_in_instr (local_idx : int) (instr : Wasm.instr) : int * int =
6686
match instr with
67-
| Wasm.LocalGet n -> if n = local_idx then 1 else 0
87+
| Wasm.LocalGet n -> if n = local_idx then (1, 1) else (0, 0)
6888
| Wasm.Block (_, body) | Wasm.Loop (_, body) ->
69-
count_uses local_idx body
89+
count_uses_range local_idx body
7090
| Wasm.If (_, then_, else_) ->
71-
(* Only one branch executes: take max so that
72-
If { then: LocalGet 0 } { else: LocalGet 0 } counts as 1, not 2. *)
73-
max (count_uses local_idx then_) (count_uses local_idx else_)
74-
| _ -> 0
91+
let (t_min, t_max) = count_uses_range local_idx then_ in
92+
let (e_min, e_max) = count_uses_range local_idx else_ in
93+
(min t_min e_min, max t_max e_max)
94+
| _ -> (0, 0)
7595

7696
(* ============================================================================
7797
Per-function verification
@@ -82,27 +102,39 @@ and uses_in_instr (local_idx : int) (instr : Wasm.instr) : int =
82102
[func] is the Wasm function body.
83103
[param_kinds] are the ownership annotations for each parameter (in order).
84104
[func_idx] is the global function index (for error reporting).
85-
Returns all violations found (empty list = clean). *)
105+
Returns all violations found (empty list = clean).
106+
107+
Uses per-path min/max analysis:
108+
- [LinearNotUsed]: max_uses = 0 (param dropped on every path)
109+
- [LinearDroppedOnSomePath]: min_uses = 0, max_uses >= 1 (dropped conditionally)
110+
- [LinearUsedMultiple]: max_uses > 1 (duplicated on some path)
111+
Multiple violations can be reported for a single param (e.g. both
112+
[LinearDroppedOnSomePath] and [LinearUsedMultiple] if min=0, max>1). *)
86113
let verify_function
87114
(func : Wasm.func)
88115
(param_kinds : ownership_kind list)
89116
(func_idx : int)
90117
: ownership_error list =
91118
List.concat_map (fun (param_idx, kind) ->
92-
let uses = count_uses param_idx func.Wasm.f_body in
119+
let (min_uses, max_uses) = count_uses_range param_idx func.Wasm.f_body in
93120
match kind with
94121
| Linear ->
95-
(* Exactly once: zero = dropped, >1 = may be duplicated. *)
96-
if uses = 0 then
97-
[LinearNotUsed { func_idx; param_idx }]
98-
else if uses > 1 then
99-
[LinearUsedMultiple { func_idx; param_idx; count = uses }]
100-
else
101-
[]
122+
(* Exactly once on every path: zero everywhere = dropped; zero on some
123+
path = partial drop; more than one on some path = may duplicate. *)
124+
let drop_errors =
125+
if max_uses = 0 then [LinearNotUsed { func_idx; param_idx }]
126+
else if min_uses = 0 then [LinearDroppedOnSomePath { func_idx; param_idx }]
127+
else []
128+
in
129+
let dup_errors =
130+
if max_uses > 1 then [LinearUsedMultiple { func_idx; param_idx; count = max_uses }]
131+
else []
132+
in
133+
drop_errors @ dup_errors
102134
| ExclBorrow ->
103-
(* At most once: >1 creates simultaneous aliases. *)
104-
if uses > 1 then
105-
[ExclBorrowAliased { func_idx; param_idx; count = uses }]
135+
(* At most once on any path: max > 1 creates simultaneous aliases. *)
136+
if max_uses > 1 then
137+
[ExclBorrowAliased { func_idx; param_idx; count = max_uses }]
106138
else
107139
[]
108140
| Unrestricted | SharedBorrow ->
@@ -217,12 +249,17 @@ let pp_error (fmt : Format.formatter) (err : ownership_error) : unit =
217249
| LinearNotUsed { func_idx; param_idx } ->
218250
Format.fprintf fmt
219251
"Level 10 violation: function %d, param %d — Linear (own) param dropped \
220-
(must be consumed exactly once)"
252+
on all paths (must be consumed exactly once)"
253+
func_idx param_idx
254+
| LinearDroppedOnSomePath { func_idx; param_idx } ->
255+
Format.fprintf fmt
256+
"Level 10 violation: function %d, param %d — Linear (own) param dropped \
257+
on some paths (per-path min uses = 0; must be consumed on every path)"
221258
func_idx param_idx
222259
| LinearUsedMultiple { func_idx; param_idx; count } ->
223260
Format.fprintf fmt
224261
"Level 10 violation: function %d, param %d — Linear (own) param loaded \
225-
%d times (exactly 1 required; possible duplication)"
262+
%d times on some path (exactly 1 required; possible duplication)"
226263
func_idx param_idx count
227264
| ExclBorrowAliased { func_idx; param_idx; count } ->
228265
Format.fprintf fmt

test/test_e2e.ml

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,8 +1291,8 @@ let test_bridge_tea_layout_section () =
12911291
Alcotest.(check int) "layout version byte = 1" 1 version_byte
12921292

12931293
(** Stage 8: TEA bridge module passes typed-wasm Level 7/10 verification.
1294-
[fn_update] uses [LocalGet 0] (msg) exactly once — branch-max semantics
1295-
ensure the if/else in fn_push does not cause false positives here. *)
1294+
[fn_update] uses [LocalGet 0] (msg) exactly once — per-path analysis
1295+
gives min=1, max=1 → OK. *)
12961296
let test_bridge_ownership_verify () =
12971297
let m = Tea_bridge.generate () in
12981298
match Tw_verify.verify_from_module m with
@@ -1449,9 +1449,9 @@ let test_router_tea_layout_section () =
14491449
let version_byte = Char.code (Bytes.get payload 0) in
14501450
Alcotest.(check int) "layout version byte = 1" 1 version_byte
14511451

1452-
(** Stage 8: Router bridge module passes typed-wasm Level 7/10 verification.
1453-
Branch-max semantics: fn_push uses [LocalGet 0] in the then-branch only
1454-
(else = stack full, silent drop). max(1, 0) = 1 → OK. *)
1452+
(** Stage 9: Router bridge module passes typed-wasm Level 7/10 verification.
1453+
fn_push: then-branch stores LocalGet 0, else-branch explicitly [LocalGet 0; Drop].
1454+
Per-path analysis: min(1,1)=1, max(1,1)=1 → OK. *)
14551455
let test_router_ownership_verify () =
14561456
let m = Tea_router.generate () in
14571457
match Tw_verify.verify_from_module m with
@@ -2115,11 +2115,11 @@ let test_verify_unrestricted_ok () =
21152115
let errs = Tw_verify.verify_module m (single_annot [Codegen.Unrestricted]) in
21162116
Alcotest.(check bool) "unrestricted multi-load → OK" true (errs = [])
21172117

2118-
(* ---- Test 7: If branch — Linear used once in each arm → branch-max=1 → OK ---- *)
2118+
(* ---- Test 7: If branch — Linear used once in each arm → per-path (1,1) → OK ---- *)
21192119

21202120
let test_verify_if_branch_ok () =
21212121
(* if (1) { LocalGet 0 } else { LocalGet 0 }
2122-
Branch-max semantics: max(1, 1) = 1 → OK. *)
2122+
Per-path analysis: min(1,1)=1, max(1,1)=1 → OK. *)
21232123
let body = [
21242124
Wasm.I32Const 1l;
21252125
Wasm.If (Wasm.BtType Wasm.I32,
@@ -2128,7 +2128,46 @@ let test_verify_if_branch_ok () =
21282128
] in
21292129
let m = mk_single_func_module body in
21302130
let errs = Tw_verify.verify_module m (single_annot [Codegen.Linear]) in
2131-
Alcotest.(check bool) "if/else each use once → branch-max OK" true (errs = [])
2131+
Alcotest.(check bool) "if/else each use once → per-path OK" true (errs = [])
2132+
2133+
(* ---- Test 10: Linear dropped in one branch only → LinearDroppedOnSomePath ---- *)
2134+
2135+
let test_verify_if_partial_drop () =
2136+
(* if (1) { LocalGet 0 } else { [] }
2137+
Per-path analysis: then=(1,1), else=(0,0) → combined (min=0, max=1).
2138+
min_uses=0, max_uses=1 → LinearDroppedOnSomePath violation. *)
2139+
let body = [
2140+
Wasm.I32Const 1l;
2141+
Wasm.If (Wasm.BtEmpty,
2142+
[Wasm.LocalGet 0; Wasm.Drop],
2143+
[]);
2144+
] in
2145+
let m = mk_single_func_module body in
2146+
let errs = Tw_verify.verify_module m (single_annot [Codegen.Linear]) in
2147+
Alcotest.(check bool) "linear dropped in one branch → LinearDroppedOnSomePath" true
2148+
(List.exists (function
2149+
| Tw_verify.LinearDroppedOnSomePath { param_idx = 0; _ } -> true
2150+
| _ -> false) errs)
2151+
2152+
(* ---- Test 11: Linear consumed in then, explicitly dropped in else → OK ---- *)
2153+
(*
2154+
This mirrors fn_push in tea_router.ml after Stage 9 fix.
2155+
then: LocalGet 0; I32Store (uses param once)
2156+
else: LocalGet 0; Drop (explicitly drops param)
2157+
Per-path: min(1,1)=1, max(1,1)=1 → OK. *)
2158+
2159+
let test_verify_if_explicit_drop_ok () =
2160+
let body = [
2161+
Wasm.I32Const 1l;
2162+
Wasm.If (Wasm.BtEmpty,
2163+
(* then: use the value *)
2164+
[Wasm.LocalGet 0; Wasm.Drop],
2165+
(* else: explicitly discharge ownership *)
2166+
[Wasm.LocalGet 0; Wasm.Drop]);
2167+
] in
2168+
let m = mk_single_func_module body in
2169+
let errs = Tw_verify.verify_module m (single_annot [Codegen.Linear]) in
2170+
Alcotest.(check bool) "explicit drop in else → per-path OK" true (errs = [])
21322171

21332172
(* ---- Test 8: Pipeline — ownership_codegen.affine → LinearNotUsed expected ---- *)
21342173
(*
@@ -2166,15 +2205,17 @@ let test_verify_pipeline_clean () =
21662205
Alcotest.fail (Printf.sprintf "Unexpected violations: %s" msg))
21672206

21682207
let tw_verify_tests = [
2169-
Alcotest.test_case "linear used once → OK" `Quick test_verify_linear_ok;
2170-
Alcotest.test_case "linear dropped → violation" `Quick test_verify_linear_dropped;
2171-
Alcotest.test_case "linear duplicated → violation" `Quick test_verify_linear_dup;
2172-
Alcotest.test_case "excl borrow once → OK" `Quick test_verify_excl_ok;
2173-
Alcotest.test_case "excl borrow aliased → violation" `Quick test_verify_excl_aliased;
2174-
Alcotest.test_case "unrestricted multi-load → OK" `Quick test_verify_unrestricted_ok;
2175-
Alcotest.test_case "if/else branch-max linear → OK" `Quick test_verify_if_branch_ok;
2176-
Alcotest.test_case "pipeline: dropped linear → violation" `Quick test_verify_pipeline_violations;
2177-
Alcotest.test_case "pipeline: clean fixture → OK" `Quick test_verify_pipeline_clean;
2208+
Alcotest.test_case "linear used once → OK" `Quick test_verify_linear_ok;
2209+
Alcotest.test_case "linear dropped → violation" `Quick test_verify_linear_dropped;
2210+
Alcotest.test_case "linear duplicated → violation" `Quick test_verify_linear_dup;
2211+
Alcotest.test_case "excl borrow once → OK" `Quick test_verify_excl_ok;
2212+
Alcotest.test_case "excl borrow aliased → violation" `Quick test_verify_excl_aliased;
2213+
Alcotest.test_case "unrestricted multi-load → OK" `Quick test_verify_unrestricted_ok;
2214+
Alcotest.test_case "if/else per-path linear → OK" `Quick test_verify_if_branch_ok;
2215+
Alcotest.test_case "pipeline: dropped linear → violation" `Quick test_verify_pipeline_violations;
2216+
Alcotest.test_case "pipeline: clean fixture → OK" `Quick test_verify_pipeline_clean;
2217+
Alcotest.test_case "if one-arm drop → LinearDroppedOnSomePath" `Quick test_verify_if_partial_drop;
2218+
Alcotest.test_case "if explicit else-drop → per-path OK" `Quick test_verify_if_explicit_drop_ok;
21782219
]
21792220

21802221
(* ============================================================================

0 commit comments

Comments
 (0)