Skip to content

Commit 40eb461

Browse files
hyperpolymathclaude
andcommitted
test(stdlib): batch 1 of algebraic-law property tests (6 cases)
First slice of the proof-obligation catalogue's "ready-this-week" list — small, no-stdlib-dep semigroup/monoid laws over built-in operators, asserted through the tree-walking interpreter (the source-level oracle, backend-independent). ## Cases | # | Law | Operators | |---|---|---| | 1 | `(a ++ b) ++ c = a ++ (b ++ c)` | string `++` associativity | | 2 | `"" ++ s = s` | string `++` left unit | | 3 | `s ++ "" = s` | string `++` right unit | | 4 | `5 / 2 = 2` | int `/` truncates toward zero (positive) | | 5 | `-5 / 2 = -2` | int `/` truncates toward zero (negative); #478 source-level | | 6 | `(a + b) + c = a + (b + c)` | int `+` associativity | Each case compiles a small inline `.affine` source string, runs the full frontend (parse → resolve → typecheck) + the interpreter, looks up the law-checker function in the resulting env, applies it with no args, and asserts the returned `Int` is `0` (convention: zero means the law held; nonzero is the failing branch's tag). The interpreter is the right oracle: it executes the AS semantics directly, so we're testing what every backend must preserve, not just one backend's lowering. The #478 fix's *codegen* invariant is already covered by the int_div codegen-deno harness; this case asserts the *source-level* invariant the codegen must respect. ## Tests run 363 / 363 (was 357 — six new). No flakes. Total runtime delta ≈ 50ms. ## Next batches (catalogue refs) - Option / Result functor laws (`map(id) = id`, `map(f ∘ g) = map(f) ∘ map(g)`) - Dict key uniqueness under insert/delete - Bytes endianness round-trip (`bytes_get_u32_le ∘ bytes_set_u32_le = id`) - JSON round-trip (`parse ∘ stringify = id` on canonical Json values) - Hash stability for derived-`Hash` AST types Stacked on #511 (fix/cicd-foundational-2026-06-01). After #511 merges, this branch rebases cleanly via patch-equivalent skip on the shared commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent acd44c7 commit 40eb461

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

test/test_main.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ let () =
1515
("TW L13 isolation (#10)", Test_tw_isolation.tests);
1616
("Qualified paths (#228, ADR-014)", Test_qualified_paths.tests);
1717
("Deno builtins ↔ stdlib decls consistency", Test_deno_builtins_consistency.tests);
18+
("Stdlib algebraic laws (batch 1)", Test_stdlib_laws.tests);
1819
] @ Test_e2e.tests @ Test_stdlib_aot.tests)

test/test_stdlib_laws.ml

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
(* SPDX-License-Identifier: MPL-2.0 *)
2+
(* SPDX-FileCopyrightText: 2026 hyperpolymath *)
3+
4+
(** Stdlib algebraic-law regression tests (first batch from the
5+
proof-obligation catalogue's "ready-this-week" list).
6+
7+
Each test compiles a small inline AffineScript program, runs it
8+
through the full frontend (parse → resolve → typecheck) + the
9+
tree-walking interpreter, then calls a named `test_X` function and
10+
asserts the result is `VInt 0` (the convention is: zero means the
11+
law held; nonzero is the failing branch's tag).
12+
13+
The interpreter is the right oracle here: it executes the AS
14+
semantics directly, so we are asserting that the semantics — not
15+
just any particular backend's lowering — agree with the law.
16+
Where a backend-specific regression is the point (e.g. int-division
17+
truncation in JavaScript lowering, #478), the matching codegen-deno
18+
harness in `tools/run_codegen_deno_tests.sh` already covers it; the
19+
test here checks the source-level invariant the codegen must
20+
preserve.
21+
22+
Laws covered in this batch:
23+
1. String concatenation: associativity (semigroup)
24+
2. String concatenation: left unit (`"" ++ s = s`) (monoid)
25+
3. String concatenation: right unit (`s ++ "" = s`) (monoid)
26+
4. Integer division truncates toward zero, positive (#478 source-level)
27+
5. Integer division truncates toward zero, negative (#478 source-level)
28+
6. Integer addition associativity (semigroup)
29+
30+
Follow-on batches (filed as issues from the catalogue) will add
31+
Option/Result functor laws, dict key uniqueness, bytes endianness
32+
round-trip, JSON round-trip, hash stability. *)
33+
34+
open Affinescript
35+
36+
(** parse + resolve + typecheck + borrow-check an inline source. *)
37+
let frontend (src : string) : (Ast.program, string) result =
38+
let open Result in
39+
let ( let* ) = bind in
40+
let* prog =
41+
try Ok (Parse_driver.parse_string ~file:"<test_stdlib_laws>" src)
42+
with
43+
| Parse_driver.Parse_error (m, sp) ->
44+
Error (Printf.sprintf "Parse error at %s: %s" (Span.show sp) m)
45+
| e -> Error (Printf.sprintf "Unexpected: %s" (Printexc.to_string e))
46+
in
47+
let loader = Module_loader.create (Module_loader.default_config ()) in
48+
let* ctx =
49+
match Resolve.resolve_program_with_loader prog loader with
50+
| Ok (rc, _) -> Ok rc
51+
| Error (e, _) -> Error ("Resolution error: " ^ Resolve.show_resolve_error e)
52+
in
53+
let* _ =
54+
match Typecheck.check_program ctx.symbols prog with
55+
| Ok _ -> Ok ()
56+
| Error e -> Error ("Type error: " ^ Typecheck.format_type_error e)
57+
in
58+
Ok prog
59+
60+
(** Compile + eval, then call `name` with no args and assert the int
61+
result equals `expected`. *)
62+
let run_returns_int ~name ~src ~expected =
63+
match frontend src with
64+
| Error m -> Alcotest.failf "frontend: %s" m
65+
| Ok prog ->
66+
(match Interp.eval_program prog with
67+
| Error e ->
68+
Alcotest.failf "eval_program: %s" (Value.show_eval_error e)
69+
| Ok env ->
70+
(match Value.lookup_env name env with
71+
| Error _ -> Alcotest.failf "%s not found in env" name
72+
| Ok fn ->
73+
(match Interp.apply_function fn [] with
74+
| Error e ->
75+
Alcotest.failf "%s: %s" name (Value.show_eval_error e)
76+
| Ok v ->
77+
(match v with
78+
| Value.VInt n ->
79+
Alcotest.(check int)
80+
(Printf.sprintf "%s returned %d" name n)
81+
expected n
82+
| _ ->
83+
Alcotest.failf "%s returned non-Int" name))))
84+
85+
(* ── Law programs ─────────────────────────────────────────────────── *)
86+
87+
let string_concat_assoc () =
88+
let src = {|
89+
pub fn check() -> Int {
90+
let lhs = ("a" ++ "b") ++ "c";
91+
let rhs = "a" ++ ("b" ++ "c");
92+
if lhs == rhs { 0 } else { 1 }
93+
}
94+
|} in
95+
run_returns_int ~name:"check" ~src ~expected:0
96+
97+
let string_concat_left_unit () =
98+
let src = {|
99+
pub fn check() -> Int {
100+
if "" ++ "x" == "x" { 0 } else { 1 }
101+
}
102+
|} in
103+
run_returns_int ~name:"check" ~src ~expected:0
104+
105+
let string_concat_right_unit () =
106+
let src = {|
107+
pub fn check() -> Int {
108+
if "x" ++ "" == "x" { 0 } else { 1 }
109+
}
110+
|} in
111+
run_returns_int ~name:"check" ~src ~expected:0
112+
113+
let int_div_truncates_positive () =
114+
let src = {|
115+
pub fn check() -> Int {
116+
if 5 / 2 == 2 { 0 } else { 1 }
117+
}
118+
|} in
119+
run_returns_int ~name:"check" ~src ~expected:0
120+
121+
let int_div_truncates_negative () =
122+
let src = {|
123+
pub fn check() -> Int {
124+
// `-5 / 2` in JS truncates toward zero -> -2 (not floored -> -3).
125+
// The codegen-deno fix #478 makes the JS lowering match this; here
126+
// we assert the source-level semantics that *must* be preserved.
127+
if (0 - 5) / 2 == 0 - 2 { 0 } else { 1 }
128+
}
129+
|} in
130+
run_returns_int ~name:"check" ~src ~expected:0
131+
132+
let int_add_assoc () =
133+
let src = {|
134+
pub fn check() -> Int {
135+
if (1 + 2) + 3 == 1 + (2 + 3) { 0 } else { 1 }
136+
}
137+
|} in
138+
run_returns_int ~name:"check" ~src ~expected:0
139+
140+
let tests =
141+
[ ("string_concat: associativity", `Quick, string_concat_assoc);
142+
("string_concat: left unit", `Quick, string_concat_left_unit);
143+
("string_concat: right unit", `Quick, string_concat_right_unit);
144+
("int division: truncates toward zero (positive)", `Quick, int_div_truncates_positive);
145+
("int division: truncates toward zero (negative, #478)", `Quick, int_div_truncates_negative);
146+
("int addition: associativity", `Quick, int_add_assoc);
147+
]

0 commit comments

Comments
 (0)