Skip to content

Commit 48d75df

Browse files
compiler: string-wall slice 4 — to_lowercase / to_uppercase (case-fold) (#570)
## Phase F slice 4 — variable-string backend wall (ASCII case-folding) Fourth slice of the **variable-string backend** wall (`proposals/MIGRATION-PLAN.adoc` §"The two walls"). Follows slices 1–3 (#567/#568/#569). Adds wasm-backend lowerings for `to_lowercase` / `to_uppercase` as a **copy-with-transform** over the runtime-length idiom established in slice 3 (`string_sub`) — demonstrating that idiom carries per-byte transforms, not just verbatim copies. Both were already wired in resolve/typecheck/interp but fell through `gen_call`'s dispatch (`UnboundVariable` at codegen). ### The lowering (`lib/codegen.ml`) Both builtins share one handler (transform selected by name). They allocate `4 + slen` and copy each byte through a **branchless** ASCII case shift, matching the interp oracle (`String.{lowercase,uppercase}_ascii`): ``` lowercase c = c + 32 * ((c >= 'A') & (c <= 'Z')) ['A'..'Z' -> +32] uppercase c = c - 32 * ((c >= 'a') & (c <= 'z')) ['a'..'z' -> -32] ``` The in-range test is a **0/1 product** (`I32And` of two `I32{Ge,Le}S`) times 32, then `I32Add`/`I32Sub` — branchless, so no per-byte `If` inside the loop. Bytes outside the letter range (incl. non-ASCII bytes ≥128, read zero-extended via `I32Load8U`) get `in_range = 0` and pass through unchanged — exactly the ASCII-only semantics. ### Parity (wasm vs interp oracle) — 7/7 `scca` = `string_char_code_at` (the slice-1 reader): | Expression | interp | wasm | |---|---|---| | `scca(to_lowercase("ABC"), 0)` ('A'→'a') | 97 | 97 | | `scca(to_lowercase("ABC"), 2)` ('C'→'c') | 99 | 99 | | `scca(to_uppercase("abc"), 0)` ('a'→'A') | 65 | 65 | | `scca(to_lowercase("aB3"), 2)` ('3' unchanged) | 51 | 51 | | `scca(to_lowercase("@"), 0)` (64, below 'A') | 64 | 64 | | `scca(to_lowercase("["), 0)` (91, above 'Z') | 91 | 91 | | `string_length(to_uppercase("Hello"))` | 5 | 5 | The `@` (64) / `[` (91) probes pin the **exclusive** `'A'..'Z'` boundary — shifting either would mean the range test was off by one. ### Tests - `test/test_e2e.ml` group **"E2E String-wall slice 4 (case-fold)"** — seven interp-oracle cases (lower, upper, non-letter passthrough, both boundary bytes, length preservation). Runs under `dune runtest`. The interp consumer coverage mandated by `.claude/CLAUDE.md` §"Test-fixture hygiene". - `tests/codegen/string_case.affine` + `tests/codegen/test_string_case.mjs` — executable wasm parity via `tools/run_codegen_wasm_tests.sh` (CI). Local verification: full `run_codegen_wasm_tests.sh` green (all four string harnesses, slices 1–4, pass; no sibling regressions); interp oracle confirmed against the real library API; wasm executed under Node. `dune runtest` couldn't run in-sandbox (no `alcotest`), but the new tests' logic + API usage was validated against the real `affinescript` library. ### Scope Per-byte transforms now established. Next: concat (`++`/`string_concat`, two source ranges into one allocation — needs string-vs-list dispatch for `++`), then `string_find` (read-side scan), `trim`, and polymorphic `slice`. Evidence: `proposals/EVIDENCE-stringwall-slice4.adoc`. https://claude.ai/code/session_01WoKhFQePiRsAj7aqnxbG8s --- _Generated by [Claude Code](https://claude.ai/code/session_01WoKhFQePiRsAj7aqnxbG8s)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7c7b31 commit 48d75df

6 files changed

Lines changed: 315 additions & 4 deletions

File tree

lib/codegen.ml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,66 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
10471047
in
10481048
Ok (c11, code)
10491049

1050+
| ExprVar id when (id.name = "to_lowercase" || id.name = "to_uppercase")
1051+
&& List.length args = 1 ->
1052+
(* PHASE-F string-wall slice 4: ASCII case-folding — a copy-with-
1053+
transform over the runtime-length idiom established in slice 3
1054+
(`string_sub`). `to_lowercase` / `to_uppercase` allocate
1055+
`4 + slen` bytes and copy each byte through a *branchless* ASCII
1056+
case shift, matching the interp oracle (lib/interp.ml:
1057+
`String.{lowercase,uppercase}_ascii`):
1058+
lowercase c = c + 32 * ((c >= 'A') & (c <= 'Z')) ['A'..'Z' -> +32]
1059+
uppercase c = c - 32 * ((c >= 'a') & (c <= 'z')) ['a'..'z' -> -32]
1060+
The in-range test is a 0/1 product (`I32And` of two `I32{Ge,Le}S`),
1061+
so bytes outside the letter range (incl. non-ASCII bytes >= 128
1062+
read via `I32Load8U`) pass through unchanged — exactly the
1063+
ASCII-only semantics. *)
1064+
let lower = id.name = "to_lowercase" in
1065+
let* (ctx1, s_code) = gen_expr ctx (List.hd args) in
1066+
let (ctx2, heap_idx) = ensure_heap_ptr ctx1 in
1067+
let (c3, src) = alloc_local ctx2 "__scase_src" in
1068+
let (c4, slen) = alloc_local c3 "__scase_slen" in
1069+
let (c5, dst) = alloc_local c4 "__scase_dst" in
1070+
let (c6, k) = alloc_local c5 "__scase_k" in
1071+
let (c7, c) = alloc_local c6 "__scase_c" in
1072+
(* transform: leave the case-shifted byte on the stack from `c` *)
1073+
let lo, hi, combine =
1074+
if lower then 65l, 90l, I32Add (* 'A'..'Z' -> +32 *)
1075+
else 97l, 122l, I32Sub (* 'a'..'z' -> -32 *)
1076+
in
1077+
let transform =
1078+
[ LocalGet c;
1079+
LocalGet c; I32Const lo; I32GeS;
1080+
LocalGet c; I32Const hi; I32LeS;
1081+
I32And; I32Const 32l; I32Mul;
1082+
combine ]
1083+
in
1084+
let loop_body =
1085+
[ LocalGet k; LocalGet slen; I32GeS; BrIf 1;
1086+
(* c = src[4 + k] *)
1087+
LocalGet src; LocalGet k; I32Add; I32Load8U (0, 4); LocalSet c;
1088+
(* dst slot address (store uses static +4): dst + k *)
1089+
LocalGet dst; LocalGet k; I32Add ]
1090+
@ transform
1091+
@ [ I32Store8 (0, 4);
1092+
LocalGet k; I32Const 1l; I32Add; LocalSet k;
1093+
Br 0 ]
1094+
in
1095+
let code =
1096+
s_code @ [LocalSet src] @
1097+
[ LocalGet src; I32Load (2, 0); LocalSet slen;
1098+
(* dst = heap; heap += 4 + slen *)
1099+
GlobalGet heap_idx; LocalSet dst;
1100+
GlobalGet heap_idx; I32Const 4l; LocalGet slen; I32Add; I32Add;
1101+
GlobalSet heap_idx;
1102+
(* dst[0] = slen *)
1103+
LocalGet dst; LocalGet slen; I32Store (2, 0);
1104+
I32Const 0l; LocalSet k;
1105+
Block (BtEmpty, [ Loop (BtEmpty, loop_body) ]);
1106+
LocalGet dst ]
1107+
in
1108+
Ok (c7, code)
1109+
10501110
| ExprVar id when (id.name = "env_at" || id.name = "arg_at")
10511111
&& List.length args = 1 ->
10521112
(* ADR-015 S5 (#180): env_at(i) / arg_at(i) — fetch the i-th
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2025-2026 hyperpolymath
3+
= Phase F — string-wall slice 4: to_lowercase / to_uppercase (evidence)
4+
:toc: macro
5+
6+
[IMPORTANT]
7+
====
8+
*Slice landed: ASCII case-folding.* `to_lowercase(s)` and `to_uppercase(s)` —
9+
already wired in resolve/typecheck/interp — gained wasm-backend lowerings as a
10+
*copy-with-transform* over the runtime-length idiom established in slice 3
11+
(`string_sub`): allocate `4 + slen`, then copy each byte through a *branchless*
12+
ASCII case shift.
13+
14+
Fourth slice of the *variable-string backend* wall
15+
(`proposals/MIGRATION-PLAN.adoc` §"The two walls", Phase F). Demonstrates the
16+
slice-3 alloc + copy-loop idiom generalises to per-byte transforms; the same
17+
shape will carry concat (two source ranges) and other byte-wise string ops.
18+
====
19+
20+
toc::[]
21+
22+
== What was missing
23+
24+
[cols="2,1,1,1,1",options="header"]
25+
|===
26+
| Builtin | resolve.ml | typecheck.ml | interp.ml | codegen.ml (wasm)
27+
| `string_sub` | ✓ | ✓ | ✓ | ✓ (slice 3)
28+
| `to_lowercase` | ✓ | ✓ | ✓ | *was missing -> added*
29+
| `to_uppercase` | ✓ | ✓ | ✓ | *was missing -> added*
30+
|===
31+
32+
Both failed at codegen (`UnboundVariable`) before this slice.
33+
34+
== The lowering (lib/codegen.ml)
35+
36+
Interp oracle: `String.lowercase_ascii` / `String.uppercase_ascii` — shift only
37+
the ASCII letters, leave everything else (digits, punctuation, bytes >= 128)
38+
unchanged. Both builtins share one handler; the transform is selected by name:
39+
40+
----
41+
lowercase c = c + 32 * ((c >= 'A') & (c <= 'Z')) ;; 'A'..'Z' -> +32
42+
uppercase c = c - 32 * ((c >= 'a') & (c <= 'z')) ;; 'a'..'z' -> -32
43+
----
44+
45+
The in-range test is a *0/1 product* (`I32And` of two `I32{Ge,Le}S`) multiplied
46+
by 32, then added/subtracted — *branchless*, so no per-byte `If` block inside
47+
the loop. Bytes outside the letter range (incl. non-ASCII bytes >= 128, read
48+
zero-extended via `I32Load8U`) get `(in_range = 0)` and pass through unchanged,
49+
exactly matching the ASCII-only semantics.
50+
51+
Structure (same runtime-length alloc + `Block`/`Loop` copy as slice 3, with the
52+
transform spliced into the loop body):
53+
54+
----
55+
dst = heap; heap += 4 + slen
56+
dst[0] = slen
57+
for k in 0..slen:
58+
c = src[4 + k] ;; I32Load8U
59+
dst[4 + k] = c (+/-) 32*((c>=lo)&(c<=hi)) ;; I32Store8
60+
----
61+
62+
== Gate evidence
63+
64+
=== Gate 1 — builds
65+
66+
`dune build bin/main.exe` exit 0. Both previously-failing ops now compile.
67+
68+
=== Gate 2 — parity (wasm vs interpreter oracle)
69+
70+
Same inputs through both backends agree across folding, non-letter
71+
passthrough, and the *exclusive* `'A'..'Z'` boundary. Interp oracle confirmed
72+
against the real library API; wasm executed under Node.
73+
74+
[cols="4,1,1",options="header"]
75+
|===
76+
| Expression | interp | wasm
77+
| `scca(to_lowercase("ABC"), 0)` ('A'->'a') | 97 | 97
78+
| `scca(to_lowercase("ABC"), 2)` ('C'->'c') | 99 | 99
79+
| `scca(to_uppercase("abc"), 0)` ('a'->'A') | 65 | 65
80+
| `scca(to_lowercase("aB3"), 2)` ('3' unchanged) | 51 | 51
81+
| `scca(to_lowercase("@"), 0)` (64, below 'A') | 64 | 64
82+
| `scca(to_lowercase("["), 0)` (91, above 'Z') | 91 | 91
83+
| `string_length(to_uppercase("Hello"))` | 5 | 5
84+
|===
85+
86+
(`scca` = `string_char_code_at`, the slice-1 reader.) The `@`/`[` probes pin
87+
the boundary: shifting either would mean the range test was off by one.
88+
89+
The packed fixture `tests/codegen/string_case.affine` returns `4285492`; both
90+
backends produce it.
91+
92+
== Tests added
93+
94+
* `test/test_e2e.ml` — group *"E2E String-wall slice 4 (case-fold)"*: seven
95+
interp-oracle cases (lower, upper, non-letter passthrough, both boundary
96+
bytes, length preservation). Runs under `dune runtest`.
97+
* `tests/codegen/string_case.affine` + `tests/codegen/test_string_case.mjs` —
98+
executable wasm parity, run by `tools/run_codegen_wasm_tests.sh` (CI).
99+
100+
Full `tools/run_codegen_wasm_tests.sh` run: *all* codegen WASM tests pass
101+
(slices 1-4 string harnesses), no sibling regressions.
102+
103+
== Corpus impact
104+
105+
Case-insensitive normalisation (a very common string-gated operation — keyed
106+
lookups, comparisons, command parsing) now lowers to wasm. More structurally:
107+
this proves the slice-3 idiom carries *per-byte transforms*, not just verbatim
108+
copies — the template for the remaining byte-wise ops.
109+
110+
== Next slices (variable-string backend wall)
111+
112+
. *Concatenation* — `string_concat` / `++` on strings (allocate `4 + la + lb`,
113+
two copy loops). Needs compile-time string-vs-list dispatch for the `++`
114+
operator (lists already lower `OpConcat`).
115+
. *`string_find`* — substring search returning an index (read-side nested
116+
scan, no allocation; builds on slice-1 indexing).
117+
. *`trim`* — leading/trailing whitespace strip (scan for bounds, then a
118+
slice-3-style copy of the inner range).
119+
. *`slice`* — the polymorphic (array|string) JS-semantics variant with
120+
negative-index normalisation.
121+
122+
Each slice: add the codegen arm, an interp-parity e2e group, and a
123+
`tests/codegen/*.mjs` executable check, then re-run the census and drop the
124+
gated count.

proposals/MIGRATION-PLAN.adoc

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ toc::[]
4040

4141
. *Variable-string backend* — `String.length`, read-side *indexing*
4242
(`string_char_code_at` / `char_to_int`, slice 1), single-byte *construction*
43-
(`string_from_char_code`, slice 2), and *runtime-length substring*
44-
(`string_sub` — runtime-sized alloc + byte-copy loop, slice 3) now lower to
45-
wasm (Phase F, see Ledger); concat / `++` / `string_find` / case-fold /
43+
(`string_from_char_code`, slice 2), *runtime-length substring* (`string_sub`
44+
— runtime-sized alloc + byte-copy loop, slice 3), and *ASCII case-folding*
45+
(`to_lowercase` / `to_uppercase` — copy-with-transform, slice 4) now lower to
46+
wasm (Phase F, see Ledger); concat / `++` / `string_find` / `trim` /
4647
polymorphic `slice` do not yet. Gates every STRING-GATED kernel (e.g.
4748
`Kernel_IO`, `DeviceType`, i18n) whose work is building/transforming strings
4849
rather than scanning them. Lives in *this* repo's compiler, so it is
@@ -208,7 +209,8 @@ Heuristic:
208209
| F (string slice 1) | DONE | String-wall slice 1 — *read-side indexing landed in the wasm backend* (2026-06-12, Opus). `string_char_code_at(s, i)` + `char_to_int(c)` (already wired in resolve/typecheck/interp) gained wasm lowerings on the `[len: i32 LE][utf8]` ABI: byte `i` at `base + 4 + i`, bounds-guarded `If` load, `-1` OOB sentinel shared with the interp oracle; `char_to_int` is the i32 identity. A previously STRING-GATED shape (byte indexing into a literal) now *compiles to wasm and parity-greens* vs the interpreter. *Gates:* G1 builds; G2 parity 7/7 (interp oracle vs wasm: 65/66/67/-1/-1/-1/90); type safety preserved (`char_to_int(65)` still a Char/Int error). Tests: `test/test_e2e.ml` group "E2E String-wall slice 1 (indexing)" (interp, `dune runtest`) + `tests/codegen/string_char_code_at.{affine,…mjs}` (executable wasm parity, full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice1.adoc`. Unblocks the *indexing/scan* family; allocation-returning string ops (`string_from_char_code`/`string_sub`/`slice`/concat/case-fold) are later slices. NEXT: allocation-returning ABI (`gen_string_alloc` + `string_from_char_code`).
209210
| F (string slice 2) | DONE | String-wall slice 2 — *write-side ABI landed in the wasm backend* (2026-06-12, Opus). `string_from_char_code(n)` (already wired in resolve/typecheck/interp) gained a wasm lowering: bump-allocate `[len: i32 LE = 1][byte]` where the byte is the low 8 bits of `n` (`I32Store8` does the `land 0xff` mask itself, incl. negatives). First heap-allocating string op; reuses the existing `gen_heap_alloc` bump allocator (no new memory machinery). *Gates:* G1 builds; G2 parity 8/8 (interp vs wasm over masking/NUL/255/256/-1/320/length/OOB-after-construction); round-trips with slice-1's reader. Tests: `test/test_e2e.ml` group "E2E String-wall slice 2 (string_from_char_code)" (interp, `dune runtest`) + `tests/codegen/string_from_char_code.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice2.adoc`. Read+write byte primitives now both present. NEXT: runtime-length allocation ABI for `string_sub`/`slice` (slice 3).
210211
| F (string slice 3) | DONE | String-wall slice 3 — *runtime-length copy op landed in the wasm backend* (2026-06-12, Opus). `string_sub(s, start, length)` (already wired in resolve/typecheck/interp) gained a wasm lowering introducing the two capabilities the rest of the wall needs: a *runtime-sized* heap allocation (`heap += 4 + length'`) and a *byte-copy loop* (`Block`/`Loop`/`BrIf`/`Br`, `I32Load8U`/`I32Store8`), both modelled on the list `++` allocate-then-copy idiom. Interp clamps reproduced exactly: `start' = max 0 (min start slen)`, `length' = max 0 (min length (slen - start'))` (`min`/`max` via `Select`). *Gates:* G1 builds; G2 parity 8/8 (interp vs wasm over extraction/length/clamp-length/clamp-start/negative-start/zero-length); round-trips with slice-1's reader. Tests: `test/test_e2e.ml` group "E2E String-wall slice 3 (string_sub)" (interp, `dune runtest`) + `tests/codegen/string_sub.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice3.adoc`. Runtime alloc + copy loop now established. NEXT: concat/`string_find`/case-fold (slice 4) reuse these idioms.
211-
| F+ | TODO | Remaining string-wall slices (concat/`++` -> `string_find` scan -> case-fold -> polymorphic `slice`), then the effect-codegen wall (module-state / `Date.now` / `Console.log`). *Cluster migration complete; string slices 1+2+3 landed (read + single-byte write + runtime-length copy).*
212+
| F (string slice 4) | DONE | String-wall slice 4 — *ASCII case-folding landed in the wasm backend* (2026-06-13, Opus). `to_lowercase` / `to_uppercase` (already wired in resolve/typecheck/interp) gained wasm lowerings as a *copy-with-transform* over the slice-3 runtime-length idiom: allocate `4 + slen`, copy each byte through a *branchless* ASCII case shift (`c ± 32*((c>=lo)&(c<=hi))`, an `I32And` 0/1 product — no per-byte `If`). Matches the interp oracle (`String.{lowercase,uppercase}_ascii`): only `'A'..'Z'`/`'a'..'z'` shift; digits, punctuation, and bytes ≥128 pass through. *Gates:* G1 builds; G2 parity 7/7 (interp vs wasm), incl. the exclusive `@`(64)/`[`(91) boundary probes + length preservation. Tests: `test/test_e2e.ml` group "E2E String-wall slice 4 (case-fold)" (interp, `dune runtest`) + `tests/codegen/string_case.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice4.adoc`. Proves the alloc+copy idiom carries per-byte transforms. NEXT: concat (`++`/`string_concat`), then `string_find`/`trim`/`slice`.
213+
| F+ | TODO | Remaining string-wall slices (concat/`++` -> `string_find` scan -> `trim` -> polymorphic `slice`), then the effect-codegen wall (module-state / `Date.now` / `Console.log`). *Cluster migration complete; string slices 1+2+3+4 landed (read + single-byte write + runtime-length copy + per-byte transform).*
212214
| Ω | TODO (access-gated) | Cutover + ReScript extinction.
213215
|===
214216

test/test_e2e.ml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3838,6 +3838,53 @@ let stringwall_sub_tests = [
38383838
Alcotest.test_case "zero length -> 0" `Quick test_stringwall_sub_zero_len;
38393839
]
38403840

3841+
(* ---- PHASE-F string-wall slice 4: to_lowercase / to_uppercase ----
3842+
3843+
ASCII case-folding gained wasm lowerings: a copy-with-transform over the
3844+
slice-3 runtime-length idiom. The branchless per-byte shift matches the
3845+
interp oracle (lib/interp.ml String.{lowercase,uppercase}_ascii): only
3846+
'A'..'Z' / 'a'..'z' shift by 32; everything else (digits, punctuation,
3847+
non-ASCII bytes) passes through. The @ (64) / [ (91) probes pin the
3848+
exclusive 'A'..'Z' boundary. *)
3849+
3850+
let test_stringwall_lower_A () =
3851+
Alcotest.(check int) "to_lowercase(\"ABC\")[0] == 'a'" 97
3852+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_lowercase(\"ABC\"), 0) }")
3853+
3854+
let test_stringwall_lower_C () =
3855+
Alcotest.(check int) "to_lowercase(\"ABC\")[2] == 'c'" 99
3856+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_lowercase(\"ABC\"), 2) }")
3857+
3858+
let test_stringwall_upper_a () =
3859+
Alcotest.(check int) "to_uppercase(\"abc\")[0] == 'A'" 65
3860+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_uppercase(\"abc\"), 0) }")
3861+
3862+
let test_stringwall_lower_digit_passthrough () =
3863+
Alcotest.(check int) "non-letter '3' passes through" 51
3864+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_lowercase(\"aB3\"), 2) }")
3865+
3866+
let test_stringwall_lower_below_A () =
3867+
Alcotest.(check int) "'@' (64, just below 'A') unchanged" 64
3868+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_lowercase(\"@\"), 0) }")
3869+
3870+
let test_stringwall_lower_above_Z () =
3871+
Alcotest.(check int) "'[' (91, just above 'Z') unchanged" 91
3872+
(eval_int_fn "fn f() -> Int { string_char_code_at(to_lowercase(\"[\"), 0) }")
3873+
3874+
let test_stringwall_case_length () =
3875+
Alcotest.(check int) "case-fold preserves length" 5
3876+
(eval_int_fn "fn f() -> Int { string_length(to_uppercase(\"Hello\")) }")
3877+
3878+
let stringwall_case_tests = [
3879+
Alcotest.test_case "lower(\"ABC\")[0] == 97" `Quick test_stringwall_lower_A;
3880+
Alcotest.test_case "lower(\"ABC\")[2] == 99" `Quick test_stringwall_lower_C;
3881+
Alcotest.test_case "upper(\"abc\")[0] == 65" `Quick test_stringwall_upper_a;
3882+
Alcotest.test_case "digit passthrough == 51" `Quick test_stringwall_lower_digit_passthrough;
3883+
Alcotest.test_case "'@' below 'A' unchanged" `Quick test_stringwall_lower_below_A;
3884+
Alcotest.test_case "'[' above 'Z' unchanged" `Quick test_stringwall_lower_above_Z;
3885+
Alcotest.test_case "case-fold preserves length" `Quick test_stringwall_case_length;
3886+
]
3887+
38413888
(* ---- STDLIB-04b: Throws extern `error<T>` (Refs #329) ----
38423889
38433890
`error<T>(msg: String) -> T / Throws` was declared in
@@ -4948,6 +4995,7 @@ let tests =
49484995
("E2E String-wall slice 1 (indexing)", stringwall_index_tests);
49494996
("E2E String-wall slice 2 (string_from_char_code)", stringwall_alloc_tests);
49504997
("E2E String-wall slice 3 (string_sub)", stringwall_sub_tests);
4998+
("E2E String-wall slice 4 (case-fold)", stringwall_case_tests);
49514999
("E2E STDLIB-04b error #329", stdlib_04b_error_tests);
49525000
("E2E Vscode Bindings", vscode_bindings_tests);
49535001
("E2E Array Type Sugar", array_type_tests);

tests/codegen/string_case.affine

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2025-2026 hyperpolymath
3+
//
4+
// PHASE-F string-wall slice 4: to_lowercase / to_uppercase — ASCII
5+
// case-folding as copy-with-transform over the runtime-length idiom
6+
// (proposals/MIGRATION-PLAN.adoc §"The two walls"). Allocates 4 + slen and
7+
// copies each byte through a branchless ASCII case shift. Read back through
8+
// the slice-1 reader string_char_code_at and string_length.
9+
//
10+
// The companion harness test_string_case.mjs asserts main() against the value
11+
// derived from the interp oracle (lib/interp.ml String.{lowercase,uppercase}_ascii).
12+
// The same constants are pinned interp-side in test/test_e2e.ml
13+
// (E2E String-wall slice 4).
14+
15+
fn main() -> Int {
16+
let la = string_char_code_at(to_lowercase("ABC"), 0); // 'A'->'a' = 97
17+
let lc = string_char_code_at(to_lowercase("ABC"), 2); // 'C'->'c' = 99
18+
let uA = string_char_code_at(to_uppercase("abc"), 0); // 'a'->'A' = 65
19+
let digit = string_char_code_at(to_lowercase("aB3"), 2); // '3' unchanged = 51
20+
let below = string_char_code_at(to_lowercase("@"), 0); // 64 (just below 'A') unchanged
21+
let above = string_char_code_at(to_lowercase("["), 0); // 91 (just above 'Z') unchanged
22+
let len = string_length(to_uppercase("Hello")); // length preserved = 5
23+
24+
// Positional pack of three transformed bytes + non-letter passthroughs and
25+
// length as an additive checksum:
26+
// packed = 97 + 99*256 + 65*65536 = 4285281
27+
// + digit(51) + below(64) + above(91) + len(5) = +211
28+
// total = 4285492
29+
let packed = la + lc * 256 + uA * 65536;
30+
packed + digit + below + above + len
31+
}

0 commit comments

Comments
 (0)