|
| 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. |
0 commit comments