Skip to content

Commit 2af23f9

Browse files
committed
harness: affine-parity String argument support (+ Float-wall finding)
Extend the affine-parity differential harness to pass String arguments: after instantiation it grows linear memory by one page and writes String args as [len:i32 LE][utf8] (matching the compiler's string layout), passing them by pointer; the oracle still receives the JS string. Adds a `{ strings: [...] }` arg-spec form. Float args already pass through as wasm f64 via `{ values: [...] }`. This connects the string-wall compiler fixes to the migration harness. The demo (examples/string_float_demo) proves slice-9 `==` against a literal AND slice-9 + slice-10 comparing two RUNTIME-PROVIDED strings pointer-vs-pointer (22/22). No regression on int-only configs (DeviceType/VmState/Maths/NetworkZones unchanged). Finding: Float binops type-check but codegen emits i32 instructions on f64 operands (band_of fails wasm validation: `i32.lt_s ... found f64`). The harness is already ready for float-input brains; the "float wall" codegen fix is the unblocker. See EVIDENCE-float-wall-finding.adoc. https://claude.ai/code/session_01WoKhFQePiRsAj7aqnxbG8s
1 parent 70b3e8f commit 2af23f9

4 files changed

Lines changed: 163 additions & 13 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
= Finding: the "float wall" — Float arith/compare codegen emits i32 ops (2026-06-14)
4+
:toc: macro
5+
6+
== Summary
7+
8+
`Float`-typed binary operations (`+`, `-`, `*`, `/`, `<`, `<=`, `>`, `>=`, `==`)
9+
*type-check* but the wasm backend lowers them with **integer** instructions on
10+
f64 operands, producing a module that fails WebAssembly validation. This is the
11+
float analogue of the string wall (slices 8b/9/10): the type checker dispatches
12+
`Float` correctly, but `gen_binop` returns only the i32 instruction family.
13+
14+
== Reproduction
15+
16+
[source]
17+
----
18+
pub fn band_of(x: Float) -> Int {
19+
if x < 10.0 { 0 } else { if x < 100.0 { 1 } else { 2 } }
20+
}
21+
----
22+
23+
[source]
24+
----
25+
$ main.exe compile band_of.affine -o band_of.wasm # compiles
26+
$ # instantiation fails:
27+
WebAssembly.instantiate(): Compiling function failed:
28+
i32.lt_s[1] expected type i32, found f64.const of type f64
29+
----
30+
31+
`codegen.ml gen_binop` maps `OpLt -> I32LtS`, `OpAdd -> I32Add`, etc., with no
32+
`Float` path; the `ExprBinary` lowering applies it to f64 operands.
33+
34+
== Why it has never surfaced
35+
36+
The entire idaptik migration corpus (94 brains) is `Int(...)->Int`: every float
37+
quantity is carried as a x1000 milli-unit / permille integer, precisely because
38+
float codegen does not work and the parity harness passes integer args. So no
39+
shipped artefact exercises float binops, and the gap stayed latent.
40+
41+
== Shape of the fix (the float wall)
42+
43+
The same type-channel pattern that closed the string wall applies. Either:
44+
45+
* *Type-directed elaboration* (mirrors slices 8b/9/10): `synth` records
46+
`Float`-typed binop nodes; an elaboration rewrites them to dedicated
47+
`ExprFloatBinary` / `ExprFloatRel` nodes; codegen lowers those to the f64
48+
instruction family (`F64Add`, `F64Lt`, …). Most faithful to the established
49+
pattern.
50+
* *Operand-type threading at codegen*: give the `ExprBinary` lowering the
51+
synthesised operand type so it can pick the i32 vs f64 instruction directly.
52+
Smaller surface but introduces a type channel into codegen that does not yet
53+
exist there.
54+
55+
The f64 instructions already exist in `lib/wasm.ml` (`F64Const` is emitted for
56+
float literals today); only the binop dispatch is missing.
57+
58+
== Harness readiness
59+
60+
`proposals/nextgen-evangelist/affine-parity` already accepts Float arguments
61+
(non-integer values in a `{ values: [...] }` spec pass through as wasm f64; the
62+
result is read as i32). The moment float binop codegen lands, float-input brains
63+
(physics thresholds, geometry classifications, the ~12 float-domain modules
64+
marked `NO_NEW_BRAINS` in cluster C13/C14 purely for the integer-only ABI)
65+
become compilable and parity-testable with no further harness work.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 hyperpolymath
3+
//
4+
// Demo for affine-parity String argument support. `classify` exercises slice-9
5+
// String `==` against a literal; `str_cmp` exercises slice-9 + slice-10
6+
// comparing two RUNTIME-PROVIDED strings (pointer vs pointer) — the strongest
7+
// check that value (not pointer) comparison holds for JS-supplied strings.
8+
// (A Float case is omitted: float `<`/arithmetic codegen still emits i32 ops —
9+
// a separate "float wall" the harness is ready for once codegen is fixed.)
10+
11+
pub fn classify(s: String) -> Int {
12+
if s == "scan" { 1 } else { if s == "exit" { 2 } else { 0 } }
13+
}
14+
15+
pub fn str_cmp(a: String, b: String) -> Int {
16+
if a < b { -1 } else { if a == b { 0 } else { 1 } }
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
export default {
3+
affine: "string_float_demo.affine",
4+
cases: [
5+
{ export: "classify", args: [{ strings: ["scan", "exit", "foo", "sca", "scann", ""] }],
6+
oracle: (s) => (s === "scan" ? 1 : s === "exit" ? 2 : 0) },
7+
{ export: "str_cmp", args: [{ strings: ["a", "ab", "b", ""] }, { strings: ["a", "ab", "b", ""] }],
8+
oracle: (a, b) => (a < b ? -1 : a === b ? 0 : 1) },
9+
],
10+
};

proposals/nextgen-evangelist/affine-parity/parity.mjs

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,24 @@
1717
// which oracle.
1818
//
1919
//## ABI SCOPE (read this)
20-
// This runner handles the **scalar i32 ABI** only: every argument is an i32 and
21-
// every result is an i32. That is exactly the boundary the DESIGN-VISION
22-
// prescribes ("they pass primitives across the wasm boundary") and it covers
23-
// pure-integer co-processors such as SecurityRank. Array/string parameters use
24-
// a different convention -- `[len:i32 LE][utf8 bytes]` written into linear
25-
// memory plus an exported `__affine_alloc` -- and are a deliberate follow-on,
26-
// NOT implemented here. Exports taking [Int]/String params therefore can only be
27-
// invoked here in their nullary form (e.g. Kernel_IO's `main`, whose array
28-
// arguments are inlined inside the .affine source).
20+
// This runner handles i32 results over i32 / f64 / String arguments:
21+
// * i32 args -- swept as integer ranges/values (the original scalar ABI),
22+
// * f64 args -- any non-integer values in a `{ values: [...] }` spec are
23+
// passed through verbatim as wasm f64 (args are not coerced),
24+
// * String args -- written into linear memory as `[len:i32 LE][utf8 bytes]`
25+
// and passed BY POINTER (an i32). The string layout matches
26+
// the compiler's (codegen `gen_literal`). The oracle still
27+
// receives the original JS string, not the pointer.
28+
// Every result is read back as an i32 (`| 0`).
29+
//
30+
// String scratch memory: after instantiation the runner grows the module's
31+
// linear memory by one page (64 KiB) and writes string args into that fresh
32+
// page (reset before each call). Because the page sits above all of the
33+
// module's data and bump-heap base, a read-only string consumer (the common
34+
// case -- a classifier that reads its String arg and returns an Int) never
35+
// collides with it. A module that *allocates heavily during the call* before
36+
// reading its String arg could in principle reach the scratch page; such cases
37+
// should size their sweeps modestly or write a bespoke driver.
2938
//
3039
//## CONFIG SHAPE
3140
// A config is a `.mjs` module with a default export:
@@ -126,11 +135,44 @@ function expandArgSpec(spec, paramIndex) {
126135
return out;
127136
}
128137
if (spec && Array.isArray(spec.values)) return spec.values.slice();
138+
if (spec && Array.isArray(spec.strings)) return spec.strings.slice();
129139
throw new Error(
130-
`arg spec #${paramIndex} must be a number, a [lo,hi] range, or { values: [...] }`,
140+
`arg spec #${paramIndex} must be a number, a [lo,hi] range, { values: [...] }, or { strings: [...] }`,
131141
);
132142
}
133143

144+
// Build a writer for String args. Grows the module's linear memory by one page
145+
// and bump-allocates `[len:i32 LE][utf8]` strings into it, 4-byte aligned, so
146+
// each is a valid AffineScript string the wasm can read by pointer. Returns
147+
// null when the module exports no memory (then String args are a usage error).
148+
function makeStringWriter(memory) {
149+
if (!(memory instanceof WebAssembly.Memory)) return null;
150+
const oldPages = memory.grow(1); // fresh page above all module data + heap
151+
const base = oldPages * 65536;
152+
const enc = new TextEncoder();
153+
let bump = base;
154+
return {
155+
reset() {
156+
bump = base;
157+
},
158+
write(s) {
159+
const u = enc.encode(s);
160+
const need = 4 + u.length;
161+
// Grow if a long string would overrun the scratch page.
162+
if (bump + need > memory.buffer.byteLength) {
163+
memory.grow(Math.ceil(need / 65536) + 1);
164+
}
165+
const ptr = bump;
166+
const dv = new DataView(memory.buffer); // re-read: grow detaches the buffer
167+
dv.setInt32(ptr, u.length, true);
168+
new Uint8Array(memory.buffer, ptr + 4, u.length).set(u);
169+
bump = ptr + need;
170+
bump += (4 - (bump & 3)) & 3; // 4-byte align the next string
171+
return ptr;
172+
},
173+
};
174+
}
175+
134176
// Cartesian product of a list of value-lists. [] -> [[]] (the single empty tuple).
135177
function cartesian(lists) {
136178
return lists.reduce(
@@ -141,7 +183,7 @@ function cartesian(lists) {
141183

142184
// Run a single case (one export + its arg sweep + its oracle). Returns
143185
// { name, total, pass, failures: [{args, got, want}] }.
144-
function runCase(exports, kase, idx) {
186+
function runCase(exports, kase, idx, stringWriter) {
145187
const name = kase.name || kase.export || `case#${idx}`;
146188
const fn = exports[kase.export];
147189
if (typeof fn !== "function") {
@@ -159,7 +201,22 @@ function runCase(exports, kase, idx) {
159201
let pass = 0;
160202
const failures = [];
161203
for (const tuple of tuples) {
162-
const got = fn(...tuple) | 0; // normalise to i32
204+
// String args are written into scratch linear memory as [len][utf8] and
205+
// passed by pointer; the oracle still sees the original JS string. i32 /
206+
// f64 args pass through unchanged.
207+
let wasmArgs = tuple;
208+
if (tuple.some((v) => typeof v === "string")) {
209+
if (!stringWriter) {
210+
throw new Error(
211+
`case "${name}" passes a String arg but the module exports no "memory"`,
212+
);
213+
}
214+
stringWriter.reset();
215+
wasmArgs = tuple.map((v) =>
216+
typeof v === "string" ? stringWriter.write(v) : v
217+
);
218+
}
219+
const got = fn(...wasmArgs) | 0; // normalise result to i32
163220
const want = kase.oracle(...tuple) | 0;
164221
if (got === want) {
165222
pass++;
@@ -206,12 +263,13 @@ export async function runParity(configPath, { verbose = false } = {}) {
206263
}
207264

208265
const exports = await instantiate(outPath);
266+
const stringWriter = makeStringWriter(exports.memory);
209267

210268
let grandTotal = 0;
211269
let grandPass = 0;
212270
const results = [];
213271
cfg.cases.forEach((kase, i) => {
214-
const r = runCase(exports, kase, i);
272+
const r = runCase(exports, kase, i, stringWriter);
215273
results.push(r);
216274
grandTotal += r.total;
217275
grandPass += r.pass;

0 commit comments

Comments
 (0)