From 411ff65bbbab1617b504710c8f9b82c2c2f85c1c Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Mon, 13 Jul 2026 22:30:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(builtins):=20vectorized=20buffer=20kernels?= =?UTF-8?q?=20=E2=80=94=20buf=5Fmix/buf=5Fscale=5Frange/buf=5Ffill/buf=5Fp?= =?UTF-8?q?eak/buf=5Fdot=20(#597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeslanStudio's render mix-down measured 9.6s for a 5s arrangement on the N3350 (stems: 0.16s, riding C-backed buf_copy) because the mix kernel dst[i] += src[j] * gain was an interpreted per-sample loop — the first native-performance demand from a product consumer. Add C bulk ops over explicit [off, count) windows of numeric buffers: - buf_mix of [dst, src, dst_off, src_off, count, gain] — in-place mix (the ab_mix_into kernel); same-buffer overlap runs forward in index order - buf_scale_range of [b, off, count, gain] — in-place range multiply - buf_fill of [b, off, count, value] — bulk store over a range - buf_peak of [b, off, count] — max |x| (normalize/meter scans) - buf_dot of [a, b, a_off, b_off, count] — windowed dot product (YIN autocorrelation), dot's unspecified-association contract Arithmetic mirrors the VM (num_guard per step) so each builtin is exactly equal to the equivalent interpreted loop — pinned by a differential suite leg on seeded pseudo-random buffers (test_buf_vectorized.eigs, suite section "vectorized buffer kernels (#597)"). Bounds are loud (no silent truncation — a clamped mix is a silently wrong render): negative count / non-numeric args raise `value`, out-of-range windows raise `index_range`, overflow-immune subtraction-form checks; count 0 is a valid no-op. buf_copy's silent-null bad-bounds path is upgraded to raise the same way (the #490–#512 direction); test_builtin_overflow.sh's probe now pins the catchable-raise property. All five join the sandbox pure-compute allowlist (argument-only, no allocation, tape-neutral — nothing nondeterministic to record). Perf (tests/bench_buf_mix.eigs, 220,500 stereo samples = 5s @ 44.1kHz, n=5 medians on the N3350): interpreted mix pass 104 ms (JIT) / 198 ms (interpreter) vs buf_mix 2.23 ms/pass — ~47x / ~89x. Closes #597 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 ++++ docs/BUILTINS.md | 7 +- src/builtins.c | 212 ++++++++++++++++++++++++++++++--- tests/bench_buf_mix.eigs | 49 ++++++++ tests/run_all_tests.sh | 5 + tests/test_buf_vectorized.eigs | 207 ++++++++++++++++++++++++++++++++ tests/test_builtin_overflow.sh | 19 ++- 7 files changed, 500 insertions(+), 22 deletions(-) create mode 100644 tests/bench_buf_mix.eigs create mode 100644 tests/test_buf_vectorized.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index 03157ce4..1df05846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to EigenScript are documented here. ## [Unreleased] ### Added +- **Vectorized buffer kernels — `buf_mix`, `buf_scale_range`, `buf_fill`, + `buf_peak`, `buf_dot` (#597).** DeslanStudio's render mix-down measured + 9.6 s for a 5 s arrangement on the N3350 because the mix kernel + (`dst[i] += src[j] * gain`) was an interpreted per-sample loop, while + stem rendering took 0.16 s riding the C-backed `buf_copy` — the first + native-performance demand from a product consumer. These are C bulk + ops over explicit `[off, count)` windows of numeric buffers: in-place + mix (`buf_mix`), range multiply (`buf_scale_range`), range store + (`buf_fill`), max-|x| scan (`buf_peak`), and windowed dot product + (`buf_dot`, the YIN-autocorrelation kernel, under `dot`'s + unspecified-association contract). Arithmetic mirrors the VM + (`num_guard` per step) so each builtin is **exactly equal to the + equivalent interpreted loop** — pinned by a differential suite leg on + seeded pseudo-random buffers. Bounds are loud: negative count / + out-of-range windows raise (`value`/`index_range`) with + overflow-immune subtraction-form checks; count 0 is a no-op. + `buf_copy`'s silent-null bad-bounds path upgraded to raise the same + way (the #490–#512 direction). All five join the sandbox pure-compute + allowlist (argument-only, no allocation, tape-neutral). + `tests/bench_buf_mix.eigs` (220,500 stereo samples, n=5 medians, + N3350): interpreted mix pass 104 ms (JIT) / 198 ms (interpreter) vs + `buf_mix` 2.23 ms — **~47x / ~89x**; a 5 s mix-down's kernel cost + drops from seconds to milliseconds. - **lib/ui: per-widget button colors + hover/pressed shades (#566, first slice of #594).** `_render_button` honors optional `bg` / `text_color` fields on the button dict with theme fallback (the diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 0ae2ad8a..125e66ad 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -137,7 +137,12 @@ Compact typed arrays of doubles with O(1) indexed access. Iterable with | `buf_set` | `buf_set of [buf, index, value]` | Write element | | `buf_len` | `buf_len of buf` | Return buffer element count | | `buf_from_list` | `buf_from_list of list` | Convert numeric list to buffer | -| `buf_copy` | `buf_copy of [src, src_off, dst, dst_off, count]` | Bulk copy between buffers | +| `buf_copy` | `buf_copy of [src, src_off, dst, dst_off, count]` | Bulk copy between buffers (`memmove`, overlap-safe). Out-of-range windows / negative count raise (`index_range`/`value`); count 0 is a no-op | +| `buf_mix` | `buf_mix of [dst, src, dst_off, src_off, count, gain]` | In-place mix: `dst[dst_off+i] += src[src_off+i] * gain` over the window — the audio mix-down kernel (#597). VM-identical arithmetic (byte-equal to the interpreted loop); same-buffer overlap runs forward in index order. Bad windows raise | +| `buf_scale_range` | `buf_scale_range of [b, off, count, gain]` | In-place multiply over a window: `b[off+i] *= gain` (fades, normalize). Bad windows raise | +| `buf_fill` | `buf_fill of [b, off, count, value]` | Bulk store over a window: `b[off+i] = value`. Bad windows raise | +| `buf_peak` | `buf_peak of [b, off, count]` | Max absolute value over a window (normalize/meter scans); 0 for an empty window. Bad windows raise | +| `buf_dot` | `buf_dot of [a, b, a_off, b_off, count]` | Windowed dot product: sum of `a[a_off+i] * b[b_off+i]` (YIN autocorrelation). Like `dot`, summation order/association is **unspecified** (backends may reassociate); no-NaN/Inf preserved. Bad windows raise | | `read_bytes_buf` | `read_bytes_buf of path` | Read binary file as buffer (10MB cap) | | `write_bytes` | `write_bytes of [path, {, append}]` | Write raw bytes to a file. Binary-clean (NUL written verbatim, unlike `write_text`). `append` (default 0): 0 truncates, nonzero appends. Returns bytes written, 0 on failure. | | `rename` | `rename of [old, new]` | Rename/replace a file. Atomic on POSIX (`rename(2)`) — a crash leaves either the old or the new file whole, never a mix; basis for crash-safe swaps. Returns 1/0. | diff --git a/src/builtins.c b/src/builtins.c index 4dfc7854..d92e9f47 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -3919,7 +3919,8 @@ static const char *SANDBOX_ALLOW[] = { "str", "str_lower", "str_replace", "str_upper", "substr", "trim", "regex_find", "regex_match", "regex_replace", /* buffers + text builders (in-memory only) */ - "buffer", "buf_copy", "buf_from_list", "buf_get", "buf_len", "buf_set", + "buffer", "buf_copy", "buf_dot", "buf_fill", "buf_from_list", "buf_get", + "buf_len", "buf_mix", "buf_peak", "buf_scale_range", "buf_set", "str_from_bytes", "text_builder_new", "text_builder_append", "text_builder_append_line", "text_builder_extend", "text_builder_clear", "text_builder_to_string", "text_builder_part_count", @@ -5695,28 +5696,204 @@ Value* builtin_write_bytes(Value *arg) { } #endif /* !EIGENSCRIPT_FREESTANDING */ -/* buf_copy of [src, src_off, dst, dst_off, count] — bulk copy between buffers */ +/* ---- Vectorized buffer kernels (#597) ---- + * Shared window validation for the bulk buf_* family. All of these read + * offsets/counts as 64-bit and bound with subtraction (off > n - count), + * never addition (off + count > n): the int-add form let two large + * offsets overflow negative, pass both checks, and drive memmove out of + * bounds. Bounds failures RAISE (index_range / value), matching the + * #490-#512 direction (buf_get/buf_set/set_at) — no silent truncation: + * a clamped audio mix is a silently wrong render. */ +static int buf_count_arg(const char *who, Value *cnt_val, long long *out) { + if (!cnt_val || cnt_val->type != VAL_NUM) { + rt_error(EK_VALUE, 0, "%s: count must be a number", who); + return 0; + } + long long c = (long long)cnt_val->data.num; + if (c < 0) { + rt_error(EK_VALUE, 0, "%s: count must be non-negative (got %lld)", + who, c); + return 0; + } + *out = c; + return 1; +} + +static int buf_num_arg(const char *who, const char *what, Value *v, + double *out) { + if (!v || v->type != VAL_NUM) { + rt_error(EK_VALUE, 0, "%s: %s must be a number", who, what); + return 0; + } + *out = v->data.num; + return 1; +} + +/* Validate one (buffer, offset, count) window. On success writes the + * offset and returns 1; on failure raises and returns 0. count must + * already be validated non-negative (buf_count_arg). */ +static int buf_window_arg(const char *who, Value *buf, Value *off_val, + long long count, long long *out_off) { + if (!buf || buf->type != VAL_BUFFER) { + rt_error(EK_TYPE, 0, "%s: expected a buffer", who); + return 0; + } + if (!off_val || off_val->type != VAL_NUM) { + rt_error(EK_VALUE, 0, "%s: offset must be a number", who); + return 0; + } + long long off = (long long)off_val->data.num; + long long n = buf->data.buffer.count; + if (off < 0 || off > n - count) { + rt_error(EK_INDEX, 0, + "%s: window [%lld, %lld) out of range (length %lld)", + who, off, off + count, n); + return 0; + } + *out_off = off; + return 1; +} + +/* buf_copy of [src, src_off, dst, dst_off, count] — bulk copy between buffers. + * #597: bad bounds used to return null silently; they now raise like the + * rest of the family (the crash-safety guarantee — no OOB memmove — holds + * either way). count 0 is a valid no-op. */ Value* builtin_buf_copy(Value *arg) { - if (!arg || arg->type != VAL_LIST || arg->data.list.count < 5) return make_null(); + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 5) { + rt_error(EK_TYPE, 0, "buf_copy requires [src, src_off, dst, dst_off, count]"); + return make_null(); + } Value *src = arg->data.list.items[0]; Value *dst = arg->data.list.items[2]; - if (!src || src->type != VAL_BUFFER || !dst || dst->type != VAL_BUFFER) return make_null(); - /* Read as 64-bit and bound with subtraction (off > count - n), never - * addition (off + n > count): the int-add form let two large offsets - * overflow negative, pass both checks, and drive memmove out of bounds. */ - long long src_off = (long long)arg->data.list.items[1]->data.num; - long long dst_off = (long long)arg->data.list.items[3]->data.num; - long long count = (long long)arg->data.list.items[4]->data.num; - long long src_n = src->data.buffer.count; - long long dst_n = dst->data.buffer.count; - if (count <= 0) return make_null(); - if (src_off < 0 || src_off > src_n - count) return make_null(); - if (dst_off < 0 || dst_off > dst_n - count) return make_null(); + long long count, src_off, dst_off; + if (!buf_count_arg("buf_copy", arg->data.list.items[4], &count) || + !buf_window_arg("buf_copy", src, arg->data.list.items[1], count, &src_off) || + !buf_window_arg("buf_copy", dst, arg->data.list.items[3], count, &dst_off)) + return make_null(); + if (count == 0) return make_null(); memmove(&dst->data.buffer.data[dst_off], &src->data.buffer.data[src_off], (size_t)count * sizeof(double)); return make_null(); } +/* buf_mix of [dst, src, dst_off, src_off, count, gain] — + * dst[dst_off+i] += src[src_off+i] * gain, in place. The audio mix-down + * kernel (DeslanStudio's ab_mix_into): one C loop instead of ~441k + * dispatched VM iterations per stem pass. Arithmetic mirrors the VM + * (num_guard per step) so the result is byte-identical to the + * equivalent interpreted loop. dst and src may be the same buffer with + * overlapping windows; the loop runs forward in index order (documented, + * deterministic). Returns null. */ +Value* builtin_buf_mix(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 6) { + rt_error(EK_TYPE, 0, "buf_mix requires [dst, src, dst_off, src_off, count, gain]"); + return make_null(); + } + Value *dst = arg->data.list.items[0]; + Value *src = arg->data.list.items[1]; + long long count, dst_off, src_off; + double gain; + if (!buf_count_arg("buf_mix", arg->data.list.items[4], &count) || + !buf_window_arg("buf_mix", dst, arg->data.list.items[2], count, &dst_off) || + !buf_window_arg("buf_mix", src, arg->data.list.items[3], count, &src_off) || + !buf_num_arg("buf_mix", "gain", arg->data.list.items[5], &gain)) + return make_null(); + double *dd = &dst->data.buffer.data[dst_off]; + double *sd = &src->data.buffer.data[src_off]; + for (long long i = 0; i < count; i++) + dd[i] = num_guard(dd[i] + num_guard(sd[i] * gain)); + return make_null(); +} + +/* buf_scale_range of [b, off, count, gain] — in-place multiply over a + * window: b[off+i] *= gain (num_guard per element, VM-identical). + * Fades/normalize. Returns null. */ +Value* builtin_buf_scale_range(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 4) { + rt_error(EK_TYPE, 0, "buf_scale_range requires [buffer, off, count, gain]"); + return make_null(); + } + Value *buf = arg->data.list.items[0]; + long long count, off; + double gain; + if (!buf_count_arg("buf_scale_range", arg->data.list.items[2], &count) || + !buf_window_arg("buf_scale_range", buf, arg->data.list.items[1], count, &off) || + !buf_num_arg("buf_scale_range", "gain", arg->data.list.items[3], &gain)) + return make_null(); + double *d = &buf->data.buffer.data[off]; + for (long long i = 0; i < count; i++) + d[i] = num_guard(d[i] * gain); + return make_null(); +} + +/* buf_fill of [b, off, count, value] — bulk store over a window: + * b[off+i] = value (stored verbatim, like buf_set). Silence gaps, + * click-free zeroing. Returns null. */ +Value* builtin_buf_fill(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 4) { + rt_error(EK_TYPE, 0, "buf_fill requires [buffer, off, count, value]"); + return make_null(); + } + Value *buf = arg->data.list.items[0]; + long long count, off; + double val; + if (!buf_count_arg("buf_fill", arg->data.list.items[2], &count) || + !buf_window_arg("buf_fill", buf, arg->data.list.items[1], count, &off) || + !buf_num_arg("buf_fill", "value", arg->data.list.items[3], &val)) + return make_null(); + double *d = &buf->data.buffer.data[off]; + for (long long i = 0; i < count; i++) + d[i] = val; + return make_null(); +} + +/* buf_peak of [b, off, count] — max |x| over a window (normalize and + * meter scans). An empty window peaks at 0. */ +Value* builtin_buf_peak(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { + rt_error(EK_TYPE, 0, "buf_peak requires [buffer, off, count]"); + return make_num(0); + } + Value *buf = arg->data.list.items[0]; + long long count, off; + if (!buf_count_arg("buf_peak", arg->data.list.items[2], &count) || + !buf_window_arg("buf_peak", buf, arg->data.list.items[1], count, &off)) + return make_num(0); + double *d = &buf->data.buffer.data[off]; + double m = 0.0; + for (long long i = 0; i < count; i++) { + double a = d[i] < 0 ? -d[i] : d[i]; + if (a > m) m = a; + } + return make_num(m); +} + +/* buf_dot of [a, b, a_off, b_off, count] — windowed dot product: + * sum over i of a[a_off+i] * b[b_off+i]. The YIN-autocorrelation + * kernel. Same contract as `dot`: the summation ORDER / ASSOCIATION is + * UNSPECIFIED (a backend may reassociate across SIMD lanes) — programs + * needing a strict left-to-right reduction write the explicit loop. + * no-NaN/Inf is preserved (num_guard at each step). */ +Value* builtin_buf_dot(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 5) { + rt_error(EK_TYPE, 0, "buf_dot requires [a, b, a_off, b_off, count]"); + return make_num(0); + } + Value *a = arg->data.list.items[0]; + Value *b = arg->data.list.items[1]; + long long count, a_off, b_off; + if (!buf_count_arg("buf_dot", arg->data.list.items[4], &count) || + !buf_window_arg("buf_dot", a, arg->data.list.items[2], count, &a_off) || + !buf_window_arg("buf_dot", b, arg->data.list.items[3], count, &b_off)) + return make_num(0); + double *ad = &a->data.buffer.data[a_off]; + double *bd = &b->data.buffer.data[b_off]; + double s = 0.0; + for (long long i = 0; i < count; i++) + s = num_guard(s + num_guard(ad[i] * bd[i])); + return make_num(s); +} + /* sign_extend of [val, bits] — sign-extend val from given bit width. * E.g. sign_extend of [0xFF, 8] → -1 */ Value* builtin_sign_extend(Value *arg) { @@ -6196,6 +6373,11 @@ void register_builtins(Env *env) { env_set_local_owned(env, "f64_to_bytes", make_builtin(builtin_f64_to_bytes)); env_set_local_owned(env, "f64_from_bytes", make_builtin(builtin_f64_from_bytes)); env_set_local_owned(env, "buf_copy", make_builtin(builtin_buf_copy)); + env_set_local_owned(env, "buf_mix", make_builtin(builtin_buf_mix)); + env_set_local_owned(env, "buf_scale_range", make_builtin(builtin_buf_scale_range)); + env_set_local_owned(env, "buf_fill", make_builtin(builtin_buf_fill)); + env_set_local_owned(env, "buf_peak", make_builtin(builtin_buf_peak)); + env_set_local_owned(env, "buf_dot", make_builtin(builtin_buf_dot)); env_set_local_owned(env, "sign_extend", make_builtin(builtin_sign_extend)); env_set_local_owned(env, "sort", make_builtin(builtin_sort)); env_set_local_owned(env, "list_truncate", make_builtin(builtin_list_truncate)); diff --git a/tests/bench_buf_mix.eigs b/tests/bench_buf_mix.eigs new file mode 100644 index 00000000..d0a4434d --- /dev/null +++ b/tests/bench_buf_mix.eigs @@ -0,0 +1,49 @@ +# bench_buf_mix.eigs — the #597 mix-down micro-bench. +# +# Workload: mixing 220,500 stereo samples (5 s @ 44.1 kHz interleaved = +# 441,000 doubles), the DeslanStudio arr_mix_stems / ab_mix_into kernel +# that measured 9.6 s for a 5 s arrangement on the N3350. +# +# Modes (first CLI arg; time each externally, n=5 medians): +# base setup only — the startup+alloc floor to subtract +# interp 1 interpreted per-sample mix pass (the pre-#597 kernel) +# builtin 100 buf_mix passes (x100 so the C kernel rises above the +# startup floor; per-pass = (t_builtin - t_base) / 100) +# +# Both legs compute the identical dst[i] += src[i] * gain; the peak +# printed at the end is the same for mode interp and one pass of +# builtin (differential sanity is test_buf_vectorized.eigs's job). + +mode is "interp" +a is args of null +if (len of a) > 0: + mode is a[0] + +frames is 220500 +n is frames * 2 +gain is 0.7071 + +src is buffer of n +unobserved: + i is 0 + loop while i < n: + src[i] is ((i % 2000) / 1000.0) - 1.0 + i is i + 1 + +dst is buffer of n + +if mode == "interp": + unobserved: + i is 0 + loop while i < n: + dst[i] is dst[i] + src[i] * gain + i is i + 1 + +if mode == "builtin": + unobserved: + pass_i is 0 + loop while pass_i < 100: + buf_mix of [dst, src, 0, 0, n, gain] + pass_i is pass_i + 1 + +print of f"{mode} n={n} peak={buf_peak of [dst, 0, n]}" diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 6f4bb1fe..a8e32731 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2684,6 +2684,11 @@ check_eigs_suite "split empty" test_split_empty.eigs "split empty: all passed" 1 check_eigs_suite "split hard" test_split_hard.eigs "split hard: all passed" 1 check_eigs_suite "tensor overflow guard" test_tensor_overflow.eigs "PASS: tensor overflow guard" 1 check_eigs_suite "flat-buffer tensors" test_flat_buffer_tensor.eigs "PASS: flat-buffer tensors" 1 +# #597: vectorized buffer kernels (buf_mix/buf_scale_range/buf_fill/buf_peak/ +# buf_dot + buf_copy loud bounds) — correctness, raise-on-bad-window, and the +# differential leg (builtin exactly equals the interpreted per-sample loop on +# seeded pseudo-random buffers). +check_eigs_suite "vectorized buffer kernels (#597)" test_buf_vectorized.eigs "BUF_VEC_OK" 5 check_eigs_suite "lab" test_lab.eigs "All tests passed." 1 check_eigs_suite "data" test_data.eigs "All tests passed." 1 check_eigs_suite "experiment" test_experiment.eigs "All tests passed." 1 diff --git a/tests/test_buf_vectorized.eigs b/tests/test_buf_vectorized.eigs new file mode 100644 index 00000000..3f72a34b --- /dev/null +++ b/tests/test_buf_vectorized.eigs @@ -0,0 +1,207 @@ +# ============================================================ +# Vectorized buffer kernels (#597) — buf_mix / buf_scale_range / +# buf_fill / buf_peak / buf_dot, plus buf_copy's upgraded loud bounds. +# ============================================================ +# Three legs: +# 1. correctness on known values +# 2. loud bounds — bad windows RAISE (index_range / value / +# type_mismatch), never silently truncate +# 3. differential — each builtin's result exactly equals the +# equivalent interpreted per-sample loop on seeded pseudo-random +# buffers (the byte-identical-arithmetic contract) + +fails is 0 + +define check(name, ok) as: + if ok == 0: + print of f"FAIL: {name}" + fails is fails + 1 + +# expect_kind of [name, kind, thunk] — thunk must raise with e.kind == kind +define expect_kind(name, kind, thunk) as: + local got is "none" + try: + thunk of [] + got is "no-raise" + catch e: + got is e.kind + if got != kind: + print of f"FAIL: {name} (wanted {kind}, got {got})" + fails is fails + 1 + +# ---- Park-Miller LCG: deterministic buffers, exact in doubles ---- +lcg_state is 42 +define lcg_next as: + lcg_state is (lcg_state * 16807) % 2147483647 + return lcg_state + +define rand_buf(count) as: + local b is buffer of count + for i in range of count: + b[i] is ((lcg_next of []) % 2000) / 1000.0 - 1.0 + return b + +define bufs_equal(a, b) as: + if (buf_len of a) != (buf_len of b): + return 0 + for i in range of (buf_len of a): + if a[i] != b[i]: + return 0 + return 1 + +# ============================================================ +# 1. Correctness on known values +# ============================================================ + +d is buf_from_list of ([1.0, 2.0, 3.0, 4.0]) +s is buf_from_list of ([10.0, 20.0, 30.0, 40.0]) +buf_mix of [d, s, 1, 2, 2, 0.5] +check of ["buf_mix known values", (d[0] == 1) and (d[1] == 17) and (d[2] == 23) and (d[3] == 4)] + +buf_scale_range of [d, 0, 2, 2.0] +check of ["buf_scale_range window", (d[0] == 2) and (d[1] == 34) and (d[2] == 23)] + +buf_fill of [d, 2, 2, 9.0] +check of ["buf_fill window", (d[1] == 34) and (d[2] == 9) and (d[3] == 9)] + +p is buf_from_list of ([-5.0, 3.0, 4.0]) +check of ["buf_peak whole", (buf_peak of [p, 0, 3]) == 5] +check of ["buf_peak sub-window", (buf_peak of [p, 1, 2]) == 4] +check of ["buf_peak empty window", (buf_peak of [p, 3, 0]) == 0] + +check of ["buf_dot windowed", (buf_dot of [p, p, 0, 1, 2]) == (0 - 3)] +check of ["buf_dot empty window", (buf_dot of [p, p, 0, 0, 0]) == 0] + +# same-buffer overlapping mix runs forward in index order: +# o = [1,1,1,1]; o[1..2] += o[0..1] * 1 -> o[1] = 1+o[0] = 2, o[2] = 1+o[1] = 3 +o is buf_from_list of ([1.0, 1.0, 1.0, 1.0]) +buf_mix of [o, o, 1, 0, 2, 1.0] +check of ["buf_mix overlap forward order", (o[1] == 2) and (o[2] == 3)] + +# count 0 is a valid no-op everywhere, including offset == len +buf_mix of [d, s, 4, 4, 0, 1.0] +buf_copy of [s, 4, d, 4, 0] +check of ["count-0 no-op at end", d[3] == 9] + +# ============================================================ +# 2. Loud bounds — raises, no silent truncation +# ============================================================ + +define bad_mix_window as: + buf_mix of [d, s, 3, 0, 2, 1.0] +expect_kind of ["buf_mix dst window overflow", "index_range", bad_mix_window] + +define bad_mix_src as: + buf_mix of [d, s, 0, 3, 2, 1.0] +expect_kind of ["buf_mix src window overflow", "index_range", bad_mix_src] + +define bad_neg_off as: + buf_scale_range of [d, 0 - 1, 2, 1.0] +expect_kind of ["negative offset raises", "index_range", bad_neg_off] + +define bad_neg_count as: + buf_fill of [d, 0, 0 - 1, 0.0] +expect_kind of ["negative count raises", "value", bad_neg_count] + +define bad_type as: + buf_peak of [123, 0, 1] +expect_kind of ["non-buffer raises", "type_mismatch", bad_type] + +define bad_gain as: + buf_mix of [d, s, 0, 0, 1, "loud"] +expect_kind of ["non-numeric gain raises", "value", bad_gain] + +define bad_dot as: + buf_dot of [p, p, 2, 0, 2] +expect_kind of ["buf_dot window overflow", "index_range", bad_dot] + +# buf_copy joins the family: the huge-offset overflow probe (the +# test_builtin_overflow.sh case) and a plain overrun both raise now. +define bad_copy_overflow as: + buf_copy of [d, 2000000000, s, 2000000000, 2000000000] +expect_kind of ["buf_copy offset/count overflow raises", "index_range", bad_copy_overflow] + +define bad_copy_window as: + buf_copy of [s, 2, d, 0, 3] +expect_kind of ["buf_copy src overrun raises", "index_range", bad_copy_window] + +# ============================================================ +# 3. Differential — builtin == interpreted per-sample loop, exactly +# ============================================================ + +N is 512 + +# --- buf_mix vs interpreted mix loop --- +dst_a is rand_buf of N +src_a is rand_buf of N +dst_b is buffer of N +buf_copy of [dst_a, 0, dst_b, 0, N] +gain is 0.7071 +d_off is 3 +s_off is 7 +cnt is N - 10 +buf_mix of [dst_a, src_a, d_off, s_off, cnt, gain] +unobserved: + i is 0 + loop while i < cnt: + dst_b[d_off + i] is dst_b[d_off + i] + src_a[s_off + i] * gain + i is i + 1 +check of ["differential: buf_mix == interpreted loop", bufs_equal of [dst_a, dst_b]] + +# --- buf_scale_range vs interpreted scale loop --- +sc_a is rand_buf of N +sc_b is buffer of N +buf_copy of [sc_a, 0, sc_b, 0, N] +buf_scale_range of [sc_a, 5, N - 9, 1.618] +unobserved: + i is 0 + loop while i < N - 9: + sc_b[5 + i] is sc_b[5 + i] * 1.618 + i is i + 1 +check of ["differential: buf_scale_range == interpreted loop", bufs_equal of [sc_a, sc_b]] + +# --- buf_fill vs interpreted fill loop --- +fl_a is rand_buf of N +fl_b is buffer of N +buf_copy of [fl_a, 0, fl_b, 0, N] +buf_fill of [fl_a, 9, N - 20, 0.25] +unobserved: + i is 0 + loop while i < N - 20: + fl_b[9 + i] is 0.25 + i is i + 1 +check of ["differential: buf_fill == interpreted loop", bufs_equal of [fl_a, fl_b]] + +# --- buf_peak vs interpreted scan --- +pk is rand_buf of N +want_peak is 0.0 +unobserved: + i is 2 + loop while i < N - 4: + local av is pk[i] + if av < 0: + av is 0 - av + if av > want_peak: + want_peak is av + i is i + 1 +check of ["differential: buf_peak == interpreted scan", (buf_peak of [pk, 2, N - 6]) == want_peak] + +# --- buf_dot vs interpreted accumulation (left-to-right) --- +# NOTE: buf_dot's association is unspecified (like `dot`), but the +# current implementation IS the left-to-right loop — this pins the +# byte-identical claim for the shipped kernel. +da is rand_buf of N +db is rand_buf of N +want_dot is 0.0 +unobserved: + i is 0 + loop while i < N - 12: + want_dot is want_dot + da[4 + i] * db[8 + i] + i is i + 1 +check of ["differential: buf_dot == interpreted accumulation", (buf_dot of [da, db, 4, 8, N - 12]) == want_dot] + +# ============================================================ +if fails == 0: + print of "BUF_VEC_OK: all vectorized buffer tests passed" +else: + print of f"BUF_VEC_FAILURES: {fails}" diff --git a/tests/test_builtin_overflow.sh b/tests/test_builtin_overflow.sh index a15d1107..95cdc45a 100755 --- a/tests/test_builtin_overflow.sh +++ b/tests/test_builtin_overflow.sh @@ -4,7 +4,8 @@ # offset bounds in `int`, which overflowed on adversarial sizes — undersizing # an allocation (then overrunning it) or slipping a huge offset past the bounds # check into an out-of-bounds memmove. Both now compute in wide/size_t math: -# str_replace caps the result with a catchable error; buf_copy returns null. +# str_replace caps the result with a catchable error; buf_copy raises +# index_range (#597 — was a silent null). set -u TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" BIN="$(cd "$TESTS_DIR/.." && pwd)/src/eigenscript" @@ -44,13 +45,19 @@ print of caught' "1" run_case "str_replace normal correctness" \ 'print of (str_replace of ["banana", "a", "X"])' "bXnXnX" -# buf_copy: huge offsets/count that overflowed the int bounds check. Now the -# subtraction-form check rejects them and it returns null — no OOB memmove. -run_case "buf_copy offset/count overflow → null" \ +# buf_copy: huge offsets/count that overflowed the int bounds check. The +# subtraction-form check rejects them — no OOB memmove. #597 upgraded the +# rejection from a silent null to a catchable index_range (the crash-safety +# property — survive without corruption — is what this pins). +run_case "buf_copy offset/count overflow → catchable, no OOB" \ 'a is buffer of 4 b is buffer of 4 -buf_copy of [a, 2000000000, b, 2000000000, 2000000000] -print of "survived"' "survived" +caught is 0 +try: + buf_copy of [a, 2000000000, b, 2000000000, 2000000000] +catch e: + caught is 1 +print of f"survived {caught}"' "survived 1" # matmul: the result element count ar*bc overflowed int into a tiny allocation # while the kernel wrote ar*bc doubles (a 65536x1 by 1x65536 product). Now the