|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +// SPDX-FileCopyrightText: 2025-2026 hyperpolymath |
| 3 | += Phase F — string-wall slice 3: string_sub (evidence) |
| 4 | +:toc: macro |
| 5 | + |
| 6 | +[IMPORTANT] |
| 7 | +==== |
| 8 | +*Slice landed: the runtime-length copy op.* `string_sub(s, start, length)` — |
| 9 | +already wired in resolve/typecheck/interp — gained a wasm-backend lowering |
| 10 | +that introduces the two capabilities the rest of the string wall needs: a |
| 11 | +*runtime-sized* heap allocation and a *byte-copy loop*. Both are modelled on |
| 12 | +the existing list `++` lowering (the canonical allocate-then-copy idiom in |
| 13 | +`lib/codegen.ml`). |
| 14 | +
|
| 15 | +Third slice of the *variable-string backend* wall |
| 16 | +(`proposals/MIGRATION-PLAN.adoc` §"The two walls", Phase F). With runtime |
| 17 | +allocation + copy now in hand, the remaining write-side ops (concat, case-fold) |
| 18 | +and the read-side scans (`startsWith`, `string_find`) reuse these idioms. |
| 19 | +==== |
| 20 | + |
| 21 | +toc::[] |
| 22 | + |
| 23 | +== What was missing |
| 24 | + |
| 25 | +[cols="2,1,1,1,1",options="header"] |
| 26 | +|=== |
| 27 | +| Builtin | resolve.ml | typecheck.ml | interp.ml | codegen.ml (wasm) |
| 28 | +| `string_char_code_at` / `char_to_int` | ✓ | ✓ | ✓ | ✓ (slice 1) |
| 29 | +| `string_from_char_code` | ✓ | ✓ | ✓ | ✓ (slice 2) |
| 30 | +| `string_sub` | ✓ | ✓ | ✓ | *was missing -> added* |
| 31 | +|=== |
| 32 | + |
| 33 | +Before this slice it failed at codegen: |
| 34 | + |
| 35 | +---- |
| 36 | +Code generation error: (Codegen.UnboundVariable |
| 37 | + "Function or variable not found: string_sub") |
| 38 | +---- |
| 39 | + |
| 40 | +== The lowering (lib/codegen.ml) |
| 41 | + |
| 42 | +Interp oracle (lib/interp.ml): |
| 43 | + |
| 44 | +---- |
| 45 | +slen = String.length s |
| 46 | +start' = max 0 (min start slen) |
| 47 | +length' = max 0 (min length (slen - start')) |
| 48 | +String.sub s start' length' |
| 49 | +---- |
| 50 | + |
| 51 | +The wasm lowering, on the `[len: i32 LE][utf8]` ABI: |
| 52 | + |
| 53 | +. Evaluate `s` (-> base pointer), `start`, `length`; load `slen` from `[src+0]`. |
| 54 | +. Clamp: `start' = max(0, min(start, slen))`, then |
| 55 | + `length' = max(0, min(length, slen - start'))`. `min`/`max` use `Select` |
| 56 | + over the operand pair (the operands are locals/constants — no side effects, |
| 57 | + so evaluating both arms is safe, unlike the bounds-guarded load in slice 1). |
| 58 | +. *Runtime-sized* allocation: `dst = heap; heap += 4 + length'` — the bump |
| 59 | + allocator (`ensure_heap_ptr`) with a runtime byte count, exactly as list |
| 60 | + `++` allocates `4 + (la+lb)*4`. |
| 61 | +. Store `length'` at `[dst+0]`. |
| 62 | +. *Copy loop* (`Block [ Loop [ k>=length' ? break; dst[4+k] = src[4+start'+k]; |
| 63 | + k++; continue ] ]`), byte-wise via `I32Load8U` / `I32Store8`. Same |
| 64 | + `Block`/`Loop`/`BrIf 1`/`Br 0` idiom as the list-`++` element copy, with a |
| 65 | + 1-byte stride instead of 4. |
| 66 | +. Return `dst`. |
| 67 | + |
| 68 | +All clamps are non-negative (`max 0`), so no negative addresses are formed. |
| 69 | +`length' = 0` exits the loop immediately and yields the empty string. |
| 70 | + |
| 71 | +== Gate evidence |
| 72 | + |
| 73 | +=== Gate 1 — builds |
| 74 | + |
| 75 | +`dune build bin/main.exe` exit 0. The previously-failing op now compiles and |
| 76 | +round-trips with the slice-1 reader. |
| 77 | + |
| 78 | +=== Gate 2 — parity (wasm vs interpreter oracle) |
| 79 | + |
| 80 | +Same inputs through both backends agree across normal extraction, both |
| 81 | +clamps, negative start, and zero length. Interp oracle confirmed against the |
| 82 | +real library API; wasm executed under Node. |
| 83 | + |
| 84 | +[cols="4,1,1",options="header"] |
| 85 | +|=== |
| 86 | +| Expression | interp | wasm |
| 87 | +| `scca(string_sub("hello",1,3), 0)` ('e') | 101 | 101 |
| 88 | +| `scca(string_sub("hello",1,3), 2)` ('l') | 108 | 108 |
| 89 | +| `string_length(string_sub("hello",1,3))` | 3 | 3 |
| 90 | +| `string_length(string_sub("hello",0,5))` (full) | 5 | 5 |
| 91 | +| `string_length(string_sub("hello",2,100))` (clamp length) | 3 | 3 |
| 92 | +| `string_length(string_sub("hello",10,3))` (clamp start) | 0 | 0 |
| 93 | +| `scca(string_sub("hello",-1,2), 0)` (neg start -> 0, 'h') | 104 | 104 |
| 94 | +| `string_length(string_sub("hello",1,0))` (zero length) | 0 | 0 |
| 95 | +|=== |
| 96 | + |
| 97 | +(`scca` = `string_char_code_at`, the slice-1 reader.) |
| 98 | + |
| 99 | +The packed fixture `tests/codegen/string_sub.affine` returns `6843501` |
| 100 | +(positional pack of three round-tripped bytes + four length probes); both |
| 101 | +backends produce it. |
| 102 | + |
| 103 | +== Tests added |
| 104 | + |
| 105 | +* `test/test_e2e.ml` — group *"E2E String-wall slice 3 (string_sub)"*: eight |
| 106 | + interp-oracle cases (extraction, length, length-clamp, start-clamp, |
| 107 | + negative-start, zero-length). Runs under `dune runtest`. The interp consumer |
| 108 | + coverage mandated by `.claude/CLAUDE.md` §"Test-fixture hygiene". |
| 109 | +* `tests/codegen/string_sub.affine` + `tests/codegen/test_string_sub.mjs` — |
| 110 | + executable wasm parity, run by `tools/run_codegen_wasm_tests.sh` (CI). |
| 111 | + |
| 112 | +Full `tools/run_codegen_wasm_tests.sh` run: *all* codegen WASM tests pass |
| 113 | +(slices 1, 2, and 3 string harnesses), no sibling regressions. |
| 114 | + |
| 115 | +== Corpus impact |
| 116 | + |
| 117 | +The runtime-length allocation + byte-copy loop are now established string |
| 118 | +primitives. This unblocks substring extraction directly, and gives the next |
| 119 | +slices their building blocks: |
| 120 | + |
| 121 | +* *Concatenation* (`++` on strings, `string_concat`) — allocate |
| 122 | + `4 + la + lb`, copy both byte ranges (two copy loops, like list `++`). |
| 123 | +* *Case-folding* (`to_lowercase` / `to_uppercase`) — allocate `4 + slen`, |
| 124 | + copy with a per-byte transform in the loop body. |
| 125 | +* *Scans* (`startsWith`, `string_find`) — comparison loops over slice-1 |
| 126 | + indexing; no allocation. |
| 127 | + |
| 128 | +== Next slices (variable-string backend wall) |
| 129 | + |
| 130 | +. *Concatenation* — `string_concat` / `++` on strings (two copy loops into a |
| 131 | + `4 + la + lb` allocation). |
| 132 | +. *Scans* — `string_find` (substring search returning an index), the read-side |
| 133 | + loop with no allocation. |
| 134 | +. *Case-folding* — `to_lowercase` / `to_uppercase` (copy-with-transform). |
| 135 | +. *`slice`* — the polymorphic (array|string) JS-semantics variant with |
| 136 | + negative-index normalisation; needs compile-time array-vs-string dispatch. |
| 137 | + |
| 138 | +Each slice: add the codegen arm, an interp-parity e2e group, and a |
| 139 | +`tests/codegen/*.mjs` executable check, then re-run the census and drop the |
| 140 | +gated count. |
0 commit comments