|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +// SPDX-FileCopyrightText: 2025-2026 hyperpolymath |
| 3 | += Phase F — string-wall slice 6: string_find (evidence) |
| 4 | +:toc: macro |
| 5 | + |
| 6 | +[IMPORTANT] |
| 7 | +==== |
| 8 | +*Slice landed: substring search* (read-side, no allocation). `string_find(haystack, |
| 9 | +needle)` — already wired in resolve/typecheck/interp — gained a wasm-backend |
| 10 | +lowering: a nested scan returning the first-occurrence index or -1. |
| 11 | +
|
| 12 | +*Also fixes an interp bug:* the interp's `string_find` called |
| 13 | +`String.get needle 0` *before* its `nlen = 0` guard, so an empty needle |
| 14 | +crashed the interpreter with an uncaught `Invalid_argument` (the `nlen = 0` |
| 15 | +branch was dead code). The guard is now reordered so an empty needle returns |
| 16 | +`0` (standard) — making the two backends agree. |
| 17 | +
|
| 18 | +Sixth slice of the *variable-string backend* wall |
| 19 | +(`proposals/MIGRATION-PLAN.adoc` §"The two walls", Phase F). With this, all |
| 20 | +the *name-dispatched* string builtins that have a faithful trap-free wasm |
| 21 | +analogue are lowered. |
| 22 | +==== |
| 23 | + |
| 24 | +toc::[] |
| 25 | + |
| 26 | +== Two changes |
| 27 | + |
| 28 | +. *`lib/interp.ml` — bugfix.* Move the length guards (`nlen = 0 -> 0`, |
| 29 | + `nlen > hlen -> -1`) ahead of `String.get needle 0`. Before: |
| 30 | ++ |
| 31 | +---- |
| 32 | +match String.index_opt haystack (String.get needle 0) with (* raises on "" *) |
| 33 | +| None -> -1 |
| 34 | +| Some _ -> let hlen = ... and nlen = ... in |
| 35 | + if nlen = 0 then 0 else ... (* dead code *) |
| 36 | +---- |
| 37 | ++ |
| 38 | +After: compute `hlen`/`nlen` first; `if nlen = 0 then 0 else if nlen > hlen |
| 39 | +then -1 else (index_opt-guarded scan)`. Behaviour is identical for every |
| 40 | +*non-empty* needle (the `index_opt` fast-path is preserved); only the empty |
| 41 | +needle changes — from *uncaught crash* to `0`. |
| 42 | + |
| 43 | +. *`lib/codegen.ml` — wasm lowering.* Nested scan, no allocation: |
| 44 | ++ |
| 45 | +---- |
| 46 | +hlen = haystack[0]; nlen = needle[0]; res = -1; i = 0 |
| 47 | +loop (outer): |
| 48 | + if res != -1: break ;; already found |
| 49 | + if i > hlen - nlen: break ;; past last start (covers nlen>hlen: bound < 0) |
| 50 | + m = 1; j = 0 |
| 51 | + loop (inner): if j >= nlen: break |
| 52 | + m = m & (haystack[i+j] == needle[j]) ;; AND-accumulate, no early break |
| 53 | + j++ |
| 54 | + res = m ? i : res ;; branchless Select; first match only |
| 55 | + i++ |
| 56 | +return res |
| 57 | +---- |
| 58 | + |
| 59 | +*Control flow is flat-safe* — there is no `Br` out of an `If`. The inner loop |
| 60 | +AND-accumulates the match over all `nlen` bytes (rather than breaking early), |
| 61 | +the outer loop's two exits are top-of-loop `BrIf`s, and the result update is a |
| 62 | +branchless `Select`. Because the outer loop exits on `res != -1` at the top of |
| 63 | +the *next* iteration, `res` keeps the FIRST match. For `nlen = 0` the inner |
| 64 | +loop runs zero times (`m` stays 1) and the first outer iteration sets |
| 65 | +`res = 0`, matching the fixed oracle; for `nlen > hlen` the bound is negative |
| 66 | +so the outer loop exits immediately with `res = -1`. |
| 67 | + |
| 68 | +== Gate evidence |
| 69 | + |
| 70 | +=== Gate 1 — builds |
| 71 | + |
| 72 | +`dune build bin/main.exe` exit 0. |
| 73 | + |
| 74 | +=== Gate 2 — parity (wasm vs interpreter oracle) |
| 75 | + |
| 76 | +Same inputs through both backends agree across found / not-found / |
| 77 | +first-occurrence / needle-longer / empty-needle / empty-haystack. Interp |
| 78 | +oracle (post-fix) confirmed against the real library API; wasm executed under |
| 79 | +Node. |
| 80 | + |
| 81 | +[cols="3,1,1",options="header"] |
| 82 | +|=== |
| 83 | +| Expression | interp | wasm |
| 84 | +| `string_find("hello", "ll")` | 2 | 2 |
| 85 | +| `string_find("hello", "o")` | 4 | 4 |
| 86 | +| `string_find("hello", "xyz")` | -1 | -1 |
| 87 | +| `string_find("abcabc", "bc")` (first occ) | 1 | 1 |
| 88 | +| `string_find("hello", "hellox")` (longer) | -1 | -1 |
| 89 | +| `string_find("hello", "")` (was a crash) | 0 | 0 |
| 90 | +| `string_find("", "x")` (empty haystack) | -1 | -1 |
| 91 | +| `string_find("", "")` (both empty) | 0 | 0 |
| 92 | +|=== |
| 93 | + |
| 94 | +The packed fixture `tests/codegen/string_find.affine` returns `73811` (each |
| 95 | +result `+1` packed as a 4-bit nibble); both backends produce it. |
| 96 | + |
| 97 | +== Tests added |
| 98 | + |
| 99 | +* `test/test_e2e.ml` — group *"E2E String-wall slice 6 (string_find)"*: seven |
| 100 | + interp-oracle cases, including the empty-needle case that previously crashed |
| 101 | + the interpreter. Runs under `dune runtest`. |
| 102 | +* `tests/codegen/string_find.affine` + `tests/codegen/test_string_find.mjs` — |
| 103 | + executable wasm parity, run by `tools/run_codegen_wasm_tests.sh` (CI). |
| 104 | + |
| 105 | +Full `tools/run_codegen_wasm_tests.sh` run: *all* codegen WASM tests pass |
| 106 | +(slices 1-6 string harnesses), no sibling regressions. |
| 107 | + |
| 108 | +== Corpus impact |
| 109 | + |
| 110 | +Substring search (delimiter detection, `contains`-style predicates, simple |
| 111 | +parsing) now lowers to wasm. With slices 1-6, every *name-dispatched* string |
| 112 | +builtin with a faithful trap-free wasm analogue is lowered: `string_length`, |
| 113 | +`string_char_code_at`, `char_to_int`, `string_from_char_code`, `string_sub`, |
| 114 | +`to_lowercase`, `to_uppercase`, `trim`, `string_find`. |
| 115 | + |
| 116 | +== What remains (and the decision still pending) |
| 117 | + |
| 118 | +* *`int_to_string` / `float_to_string`* — name-dispatched decimal rendering. |
| 119 | + `int_to_string` is the obvious next autonomous slice (digit loop + sign; |
| 120 | + `INT_MIN` is the one i32 edge — handle it by extracting digits in negative |
| 121 | + space to avoid the un-negatable magnitude). `float_to_string` needs the |
| 122 | + float story, so it stays host-side under the float-wall convention. |
| 123 | +* *Concatenation (`++` / `OpConcat`) and polymorphic `slice`* — still gated on |
| 124 | + the *type-access* design decision (`lib/codegen.ml` has no per-expression |
| 125 | + type information; `++` already lowers to *list* concat). Owner call: thread |
| 126 | + typed-AST into codegen, or add a dedicated `string_concat` builtin. See |
| 127 | + `proposals/EVIDENCE-stringwall-slice5.adoc` §"remaining string ops". |
| 128 | +* *`string_get` / `int_to_char`* — deferred (raise on out-of-range; no |
| 129 | + trap-free wasm analogue without a declared-clamp convention). |
| 130 | + |
| 131 | +== Next slices (variable-string backend wall) |
| 132 | + |
| 133 | +. *`int_to_string`* — decimal rendering (autonomous; the last clean |
| 134 | + name-dispatched op). |
| 135 | +. *Concatenation / `slice`* — gated on the type-access design decision. |
| 136 | + |
| 137 | +Then the *effect-codegen wall* (module-state / `Date.now` / `Console.log`). |
0 commit comments