From f947fa96fbcf9496d1f16cf22c8088545baabd7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:34:09 +0000 Subject: [PATCH 01/51] ci: bump actions/checkout from 7.0.0 to 7.0.1 Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5df6697c..6c7ed4c44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: zig-dir: zig-x86_64-linux-0.16.0 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 954f807d6..8bf5432e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: artifact: cljw-linux-x86_64 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Zig 0.16.0 shell: bash From a82be3d5128ef0687d806472639197e4c3ad36d3 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 13:46:32 -0300 Subject: [PATCH 02/51] bench: cells 40-45 for numeric seq path (dot 1k/100k, prime trial division, double-array reduce, float loop, byte-array from seq) --- bench/benchmarks/40_seq_dot_1k/bench.clj | 9 +++++++++ bench/benchmarks/40_seq_dot_1k/meta.yaml | 4 ++++ bench/benchmarks/41_seq_dot_100k/bench.clj | 9 +++++++++ bench/benchmarks/41_seq_dot_100k/meta.yaml | 4 ++++ bench/benchmarks/42_prime_trial_division/bench.clj | 13 +++++++++++++ bench/benchmarks/42_prime_trial_division/meta.yaml | 4 ++++ bench/benchmarks/43_double_array_reduce/bench.clj | 8 ++++++++ bench/benchmarks/43_double_array_reduce/meta.yaml | 4 ++++ bench/benchmarks/44_float_loop_recur/bench.clj | 7 +++++++ bench/benchmarks/44_float_loop_recur/meta.yaml | 4 ++++ bench/benchmarks/45_byte_array_from_seq/bench.clj | 6 ++++++ bench/benchmarks/45_byte_array_from_seq/meta.yaml | 4 ++++ 12 files changed, 76 insertions(+) create mode 100644 bench/benchmarks/40_seq_dot_1k/bench.clj create mode 100644 bench/benchmarks/40_seq_dot_1k/meta.yaml create mode 100644 bench/benchmarks/41_seq_dot_100k/bench.clj create mode 100644 bench/benchmarks/41_seq_dot_100k/meta.yaml create mode 100644 bench/benchmarks/42_prime_trial_division/bench.clj create mode 100644 bench/benchmarks/42_prime_trial_division/meta.yaml create mode 100644 bench/benchmarks/43_double_array_reduce/bench.clj create mode 100644 bench/benchmarks/43_double_array_reduce/meta.yaml create mode 100644 bench/benchmarks/44_float_loop_recur/bench.clj create mode 100644 bench/benchmarks/44_float_loop_recur/meta.yaml create mode 100644 bench/benchmarks/45_byte_array_from_seq/bench.clj create mode 100644 bench/benchmarks/45_byte_array_from_seq/meta.yaml diff --git a/bench/benchmarks/40_seq_dot_1k/bench.clj b/bench/benchmarks/40_seq_dot_1k/bench.clj new file mode 100644 index 000000000..0df4a6f60 --- /dev/null +++ b/bench/benchmarks/40_seq_dot_1k/bench.clj @@ -0,0 +1,9 @@ +;; Seq Dot Product 1k: the idiomatic dot product, (reduce + (map * xs xs)), +;; over a 1000-element vector of doubles, 1000 times. The 2-coll `map` arm is +;; unchunked, so this times one lazy cell + one fn call per element. +;; Prints the integral result as a long (exact: sum of i^2 below 2^53). +(def xs (vec (map double (range 1000)))) +(loop [i 0 r 0.0] + (if (< i 1000) + (recur (inc i) (reduce + (map * xs xs))) + (println (long r)))) diff --git a/bench/benchmarks/40_seq_dot_1k/meta.yaml b/bench/benchmarks/40_seq_dot_1k/meta.yaml new file mode 100644 index 000000000..2963354a3 --- /dev/null +++ b/bench/benchmarks/40_seq_dot_1k/meta.yaml @@ -0,0 +1,4 @@ +name: "Seq Dot Product 1k" +category: numeric +expected_output: "332833500" +description: "(reduce + (map * xs xs)) over a 1000-double vector x1000 — per-element cost of the 2-coll lazy map + reduce walk" diff --git a/bench/benchmarks/41_seq_dot_100k/bench.clj b/bench/benchmarks/41_seq_dot_100k/bench.clj new file mode 100644 index 000000000..0bfaa055b --- /dev/null +++ b/bench/benchmarks/41_seq_dot_100k/bench.clj @@ -0,0 +1,9 @@ +;; Seq Dot Product 100k: the 40_seq_dot_1k workload over a 100000-element +;; vector, 10 times. Same per-element work; the live set is 100x larger, so +;; the gap to 40_seq_dot_1k per element is the GC / cache cost of the heap. +;; Prints the integral result as a long (exact: sum of i^2 below 2^53). +(def xs (vec (map double (range 100000)))) +(loop [i 0 r 0.0] + (if (< i 10) + (recur (inc i) (reduce + (map * xs xs))) + (println (long r)))) diff --git a/bench/benchmarks/41_seq_dot_100k/meta.yaml b/bench/benchmarks/41_seq_dot_100k/meta.yaml new file mode 100644 index 000000000..f07a2ee85 --- /dev/null +++ b/bench/benchmarks/41_seq_dot_100k/meta.yaml @@ -0,0 +1,4 @@ +name: "Seq Dot Product 100k" +category: numeric +expected_output: "333328333350000" +description: "(reduce + (map * xs xs)) over a 100000-double vector x10 — the 1k cell at a heap 100x larger (GC working set)" diff --git a/bench/benchmarks/42_prime_trial_division/bench.clj b/bench/benchmarks/42_prime_trial_division/bench.clj new file mode 100644 index 000000000..89e53cbb1 --- /dev/null +++ b/bench/benchmarks/42_prime_trial_division/bench.clj @@ -0,0 +1,13 @@ +;; Prime Trial Division: the 1000th prime by trial division up to sqrt(n). +;; Nested loop/recur over fixnums plus one fn call per candidate. +(defn prime? [n] + (loop [d 2] + (cond (> (* d d) n) true + (zero? (rem n d)) false + :else (recur (inc d))))) +(defn nth-prime [k] + (loop [n 2 c 0] + (if (prime? n) + (if (= (inc c) k) n (recur (inc n) (inc c))) + (recur (inc n) c)))) +(println (nth-prime 1000)) diff --git a/bench/benchmarks/42_prime_trial_division/meta.yaml b/bench/benchmarks/42_prime_trial_division/meta.yaml new file mode 100644 index 000000000..1e09431b2 --- /dev/null +++ b/bench/benchmarks/42_prime_trial_division/meta.yaml @@ -0,0 +1,4 @@ +name: "Prime Trial Division" +category: numeric +expected_output: "7919" +description: "1000th prime by trial division — nested loop/recur, fixnum (* d d) / rem / inc, fn call per candidate" diff --git a/bench/benchmarks/43_double_array_reduce/bench.clj b/bench/benchmarks/43_double_array_reduce/bench.clj new file mode 100644 index 000000000..71cf9a16d --- /dev/null +++ b/bench/benchmarks/43_double_array_reduce/bench.clj @@ -0,0 +1,8 @@ +;; Double Array Reduce: (reduce + a) over a 100000-element double-array, 10 +;; times. Times reduce's walk over a primitive-array source. +;; Prints the integral result as a long (exact below 2^53). +(def a (double-array (range 100000))) +(loop [i 0 r 0.0] + (if (< i 10) + (recur (inc i) (reduce + a)) + (println (long r)))) diff --git a/bench/benchmarks/43_double_array_reduce/meta.yaml b/bench/benchmarks/43_double_array_reduce/meta.yaml new file mode 100644 index 000000000..32f7d0954 --- /dev/null +++ b/bench/benchmarks/43_double_array_reduce/meta.yaml @@ -0,0 +1,4 @@ +name: "Double Array Reduce" +category: numeric +expected_output: "4999950000" +description: "(reduce + a) over a 100000-element double-array x10 — reduce over a primitive array source" diff --git a/bench/benchmarks/44_float_loop_recur/bench.clj b/bench/benchmarks/44_float_loop_recur/bench.clj new file mode 100644 index 000000000..37dbdfa07 --- /dev/null +++ b/bench/benchmarks/44_float_loop_recur/bench.clj @@ -0,0 +1,7 @@ +;; Float Loop Recur: a 1M-iteration loop/recur whose accumulator is a double +;; and whose counter is a long, so every step does mixed double/long `*` and +;; `+`. Prints the result as a long (every partial sum is exact below 2^53). +(println (long (loop [i 0 acc 0.0] + (if (< i 1000000) + (recur (inc i) (+ acc (* 1.5 i))) + acc)))) diff --git a/bench/benchmarks/44_float_loop_recur/meta.yaml b/bench/benchmarks/44_float_loop_recur/meta.yaml new file mode 100644 index 000000000..067e07541 --- /dev/null +++ b/bench/benchmarks/44_float_loop_recur/meta.yaml @@ -0,0 +1,4 @@ +name: "Float Loop Recur" +category: numeric +expected_output: "749999250000" +description: "1M-iteration loop/recur accumulating (+ acc (* 1.5 i)) — mixed double/long arithmetic in a tight loop" diff --git a/bench/benchmarks/45_byte_array_from_seq/bench.clj b/bench/benchmarks/45_byte_array_from_seq/bench.clj new file mode 100644 index 000000000..e6f164800 --- /dev/null +++ b/bench/benchmarks/45_byte_array_from_seq/bench.clj @@ -0,0 +1,6 @@ +;; Byte Array From Seq: (byte-array (repeat 8000 1)), 20 times. Times filling +;; a primitive array from a lazy seq source. Prints the total length. +(loop [i 0 n 0] + (if (< i 20) + (recur (inc i) (+ n (alength (byte-array (repeat 8000 1))))) + (println n))) diff --git a/bench/benchmarks/45_byte_array_from_seq/meta.yaml b/bench/benchmarks/45_byte_array_from_seq/meta.yaml new file mode 100644 index 000000000..31db5be8b --- /dev/null +++ b/bench/benchmarks/45_byte_array_from_seq/meta.yaml @@ -0,0 +1,4 @@ +name: "Byte Array From Seq" +category: collection +expected_output: "160000" +description: "(byte-array (repeat 8000 1)) x20 — building a primitive array from a lazy seq" From 5133ed6c55695856946bf0c3096e0a8c49a83798 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 14:58:21 -0300 Subject: [PATCH 03/51] perf(eval): inline f64 arith/compare fast path in the VM intrinsics (O-065) fastBinaryFixnum becomes fastBinaryNumber: a double paired with a double or fixnum computes + - * < <= > >= in f64, exactly what promote.*Promoting and math.pairwise do once either operand is a double. = not= mod rem quot still defer to the builtin (cross-category equality, float sign-of-zero rules). The float arm sits out of line (fastNonFixnum) so the fixnum dispatch keeps its shape; inline, it cost 04_arith_loop +9%. diff_test pins 16 float cases VM == TreeWalk. User instructions, ReleaseSafe -Dwasm, taskset -c 2, min of 2-3, O-064 commit -> this commit: 40 seq_dot_1k 13.25G -> 11.65G -12% 41 seq_dot_100k 13.74G -> 12.21G -11% 44 float_loop_recur 2.52G -> 1.86G -26% 02 fib_loop 67.0M -> 66.8M 0% 04 arith_loop 778.0M -> 810.8M +4.2% (accepted: dispatch shape) 01 fib_recursive 355.8M -> 362.4M +1.8% Card: CLJW-NUMERIC-SEQ-PERF (20261021235636-3459e0ce). --- .dev/optimizations.md | 1 + src/eval/backend/intrinsic.zig | 148 ++++++++++++++++++++++++--------- src/eval/backend/vm.zig | 10 +-- src/eval/backend/vm/opcode.zig | 13 +-- src/lang/diff_test.zig | 26 +++++- 5 files changed, 145 insertions(+), 53 deletions(-) diff --git a/.dev/optimizations.md b/.dev/optimizations.md index 9f4ed0db1..34ba7426b 100644 --- a/.dev/optimizations.md +++ b/.dev/optimizations.md @@ -103,6 +103,7 @@ | O-039 | `runtime/numeric/promote.zig` (`operandManaged`/`OwnedManaged` + add/sub/mul BigInt else-branches) (ADR-0148; bigint_factorial) | The integer/BigInt arithmetic else-branch (reached when an operand is a heap BigInt) called `coerceToManaged` on BOTH operands, which **clones** a `.big_int` operand via `cloneWithDifferentAllocator` (alloc + limb memcpy) — yet `r.add`/`sub`/`mul` only READ them. `bigint_factorial` (`(reduce *' …)`, acc grows to ~9 limbs) clones the growing accumulator on every `*`. Same waste O-037 fixed for ratios, single-Managed flavour. | `operandManaged(rt, v, *OwnedManaged)` returns `*const Managed`: a BigInt aliases its stored Managed (zero alloc); an immediate Long materialises into a caller-stable `OwnedManaged` local (`deinit` only when active). The 3 else-branches use it; the `@addWithOverflow` both-int sub-paths keep `coerceToManaged` (both small, nothing to alias). | Removes the per-op accumulator clone. **`32_bigint_factorial` ReleaseSafe 26.4→22.4 ms (12 runs; 1.49×→~1.27× vs Babashka 17.7 ms).** ratio_sum unchanged (separate path). Broad win for all BigInt `+ - *`. | `zig build test -Dwasm` ×2 (diff oracle) + corpus + smoke; output unchanged (158 = digit count of 100!) | O-037, ADR-0148, D-450 | | O-038 | `runtime/numeric/promote.zig` (`ratioArith` scratch) + `runtime/numeric/ratio.zig` (`allocFromManagedPair` reduce-scratch) (ADR-0148; ratio_sum) | Each ratio op allocated its transient cross-multiply + gcd/divTrunc scratch as individual `Managed.init(rt.gc.infra)` calls — `ratioArith` 2-4 (rn/rd/lhs/rhs) + `allocFromManagedPair` 4 (gcd/r_num/r_den/rem) — so ~8 GPA malloc/free per `+`. `sample` profiling showed `libsystem_malloc` (`_xzm_*malloc`/`_xzm_free`/`memset`/`bzero`) ≈ 20% of leaf self-time. | Each function opens ONE `std.heap.ArenaAllocator` over `rt.gc.infra` and allocates all its scratch Managed from it; `defer arena.deinit()` bulk-frees on return. The two arenas don't alias (rn/rd live on `ratioArith`'s arena, read inside `allocFromManagedPair` which has its own). Result BigInts (numer/denom) still clone onto `gc.infra` (GC-managed, outlive the call). | Collapses ~8 individual malloc/free into ~2 arena chunk alloc/free per op. **`33_ratio_sum` 103.3→82.9 ms (12 runs); combined with O-037 108.1→82.9 (23%).** Re-profile: mem-leaf 227/1950 ≈ 11.6% (alloc no longer dominant; residual is interpreter dispatch + bignum compute → D-386). Broad win for all ratio arithmetic. | `zig build test -Dwasm` ×2 (diff oracle) + corpus 3157/3157 + smoke gate; output unchanged | O-037, ADR-0148, D-450 | | O-056 | `lang/clj/clojure/core.clj` `pop` (vector arm) + `lang/primitive/collection.zig` `__vector-pop` (new) | `(pop v)` on a vector was `(into [] (take (dec (count coll)) coll))` — an O(n) REBUILD of a whole fresh trie. Meanwhile `runtime/collection/vector.zig::pop` already implements the JVM `PersistentVector.pop()` algorithm correctly (tail fast path; else pull the last leaf out of the root and collapse a level), and was reachable ONLY from `java.util.ArrayDeque`. A stratified-design ABSTRACTION-BARRIER violation (Grokking Simplicity ch.8): the layer above re-implemented a barrier operation with high-level seq ops instead of calling through it | Expose the existing barrier fn as the `__vector-pop` primitive and have `pop` call it. No new lower-layer code — the "minimal interface" move: the barrier already had the op | O(n) -> O(log32 n), and it now SHARES structure with the parent instead of allocating a fresh trie. Measured 2000 pops, ReleaseSafe: n=1000 539.5 -> 1.14 ms; n=4000 2151.5 -> 1.30 ms; **n=16000 8278.3 -> 2.47 ms (~3352x)**. Reference JVM clj 1.12.4 on the same box/probe: 1.93 ms — cljw was ~4288x SLOWER than clj here and is now within ~1.3x | `scripts/check_corpus_regression.sh` **4347/4347** golden clj outputs reproduced + e2e `phase14_peek_pop`, `phase14_deftype_collection_base`, `phase14_deftype_object`, `phase14_catch_internal` + spot checks (meta preserved, list arm -> rest, queue arm -> `__queue-pop`, empty still raises IllegalStateException) | D-044, ADR-0148, `.dev/bench/` | +| O-065 | `eval/backend/intrinsic.zig` (`fastBinaryNumber`, was `fastBinaryFixnum`; out-of-line `fastNonFixnum` / `fastFloat`) + `eval/backend/vm.zig` (the op_add family, `*_local_const`, `*_locals`, fused compare-branch) | A double operand made every VM arith/compare intrinsic fall back to the cached builtin Var (var deref, varargs dispatch, numeric-tower switch) for one IEEE op | A double paired with a double or fixnum computes `+ - * < <= > >=` in f64, as `promote.*Promoting` / `math.pairwise` do. `= not= mod rem quot` still defer (cross-category equality, float sign-of-zero rules). The float arm is out of line so the fixnum dispatch keeps its shape | User instructions, ReleaseSafe -Dwasm, `taskset -c 2`, over O-064: seq_dot_1k 13.25G -> 11.65G (-12%), seq_dot_100k 13.74G -> 12.21G (-11%), float_loop_recur 2.52G -> 1.86G (-26%); arith_loop +4.2%, fib_recursive +1.8% (dispatch layout) | intrinsic.zig units (float arm, NaN, defer cases) + `diff_test` 16 float cases VM == TreeWalk + smoke | card CLJW-NUMERIC-SEQ-PERF | | O-064 | `eval/backend/tree_walk.zig` (`uninitScratch`; `callMethodImpl` locals, `evalCall` args + arg locs, `evalRecur`, `evalConstructorCall`) + `eval/backend/vm.zig` (`eval` handler stack) | ReleaseSafe fills every `= undefined` local with 0xAA, not only Debug. The per-call scratch windows (`[MAX_LOCALS]Value`, about 2 KB each) were memset on every Clojure fn call: 19% of instructions in `(reduce + (map * xs xs))`, 18-59% of samples in transduce/map after ADR-0205 | `uninitScratch(T)` returns `undefined` with runtime safety off outside Debug. Every user reads only a prefix it has just written, so the poison caught nothing; Debug keeps it, and the callers' own checks stay on | User instructions, ReleaseSafe -Dwasm, `taskset -c 2`: seq_dot_1k 16.02G -> 13.15G (-18%), lazy_chain -38%, multimethod_dispatch -35%, ratio_sum -27%, protocol_dispatch -26%, transduce -24%, gc_large_heap -50%, startup 74.2M -> 66.9M (-10%); no cell of 01-45 regressed | `test/run_all.sh --smoke` (unit VM + TreeWalk, corpus, clj suites, e2e) | card CLJW-PERF-UNDEFINED-SCRATCH, knowledge 20261022011049-02b6329c | | O-060 | `runtime/collection/string_seq.zig` (NEW, `.string_seq` A13 — a day-1 reservation with no producer) + `lang/primitive/sequence.zig` (`seqFn`/`countFn`/`firstFn`/`restFn`/`nextFn` + rev-walk `.string` arm → view) + `.string_seq` arms across `equal.zig` (isSequential via the interface_membership SSOT / isCountable / seqLen / valueHash / isSeqKeyValue) / `print.zig` (`printStringSeq`, ports-threaded) / `higher_order.zig` (fuse-base + chunkClassify) / `clojure_lang_method.zig` (native-collection arms) / `lazy_seq.zig` / `metadata.zig` / `meta.zig` / `runtime.zig` (GC trace registration) | `(seq s)` built an EAGER n-cell codepoint cons chain by asking `charset.codepointAt(s, i)` per element, and that restarts a `Utf8Iterator` from byte 0 every call — n calls at O(i) each = **O(n²)** overall. Measured cljw eager path: 2,000 chars 27 ms → 146,670 chars **89,910 ms**; downstream, bb-mcp paid ~79 s/boot asking `(seq value)` as a truthiness test on a 146,673-char nREPL response | A byte-offset VIEW satisfying the seq protocol: `StringSeq{backing, offset}`, two words. The cursor is a BYTE offset, not a codepoint index (an index would reintroduce the byte-0 walk): `first` decodes in place at `offset`, `rest` advances by the width it just read, `count` is a single forward byte scan (UTF-8 gives no length arithmetic — unlike the vector view's subtraction — but the scan allocates nothing and is no worse than `(count s)`). Consumers (`printStringSeq`, both `equal.zig` cursors) take the byte walk too rather than `nth(i)` (`nth` on UTF-8 is O(i) however the view is built, so the index-walk shape copied from array_seq would rebuild the quadratic inside the fix). `decodeAt` is total (malformed byte → width-1 raw), so a walk always advances — a view can never wedge or loop. Slot from the F-004 A13 day-1 reservation (already declared across interface_membership + class_name displayClassName), NO `heap_tag.unallocated` slot spent — the same position `.array_seq` (O-058) was in | **O(n²) → O(n).** NEW cljw ReleaseSafe end-to-end (incl. process startup + O(n) string build): `(count (seq (str-of-n "x")))` n=100k **0.13 s**, n=500k **0.60 s** (linear); the eager path's 89,910 ms at 146k chars is now sub-second (**>10,000× at that size**). clj JVM isolates the same seq+count to ~0 ms (StringSeq view + JIT); cljw's view is likewise O(1)/step, its residual wall time being the O(n) string construction, not the seq walk. Broad win: every hand-written `(seq s)` / `doseq`/`for` over a string and every `(seq value)` truthiness probe on a large string | `zig build -Dwasm -Doptimize=ReleaseSafe` (string-seq binary compiles) + corpus `string_seq.txt` (33 exprs vs clj, `33/33 reproduced`) + the 47-assertion observable-surface pin set, landed as e2e `string_seq_view.sh` and since migrated verbatim to `test/clj/suites/string_seq_view_test.clj` (vs clj: prints-as-seq, seq?/sequential?/coll?/not-string/not-vector, class=StringSeq, UTF-8 count/first/rest/nth/last, second/fnext/nthrest, `=`/hash with list AND vector + map-key + set-dedup + length-mismatch loses, vec/into/apply-str/reverse/take/drop/cons/concat, map/filter/reduce, empty→nil, scale count 20k + utf8 10k) | D-179, O-058, F-004, `feat/string-seq-view` | | O-059 | `runtime/collection/sub_vector.zig` (NEW, `.sub_vector` D11) + `lang/clj/clojure/core.clj` `subvec` (fast-path) + `lang/primitive/collection.zig` (`__subvec` prim + `conjOne`/`getFn`/`nthFn`/`assocFn`/`containsQFn`/`__vector-pop`/`isEntrySeqTag`) + `.sub_vector` arms across `interface_membership.zig` (16 arrays) / `sequence.zig` / `core.zig` / `equal.zig` / `print.zig` / `compare.zig` / `lookup.zig` + `tree_walk.zig` / `meta.zig` / `metadata.zig` / `class_name.zig` / `lazy_seq.zig` / `walk.zig` / `json.zig` / `higher_order.zig` / `intrinsic.zig` / `serialize.zig` / `clojure_lang_method.zig` / `multimethod.zig` / `transient_array_map.zig` / `array_seq.zig` (backing arm). `vector.subvec`→`copyRange` (materializing; LBQ compaction) | `clojure.core/subvec` on a vector was `(into [] (take (- end start) (drop start v)))` — an eager O(n) rebuild of a whole fresh trie per call, the LAST catastrophic collection gap (measured 7480x slower than clj). JVM never copies: `subvec` returns an `APersistentVector$SubVector` sharing the parent's root+tail (D-044/D-583). The Zig `vector.subvec` (eager) was reachable only from LBQ compaction, which genuinely wants a copy — so the view had to be a NEW representation, not a rewrite of it | A new REPRESENTATION satisfying the existing IPersistentVector protocol: `SubVector{start,end,parent,meta}`, 32 bytes fixed whatever the span. `count = end-start` (O(1)), `nth(i) = parent.nth(start+i)`. JVM `SubVector` semantics: FLATTEN on nest (parent is always a plain `.vector`), never-empty (empty range → EMPTY plain vector), write ops stay SubVectors via `parent.assocN` (`conj`/`assoc`/`pop`, structural sharing — never materialize). Slot came from the F-004 `box`→`sub_vector` amendment (user-selected, ADR-0179), 64-slot layout unchanged. Interface membership matches JVM ground truth exactly: `.sub_vector` joins every array `.vector` is in EXCEPT `EDITABLE_TAGS` (no transient) and `IREDUCEINIT_TAGS` (SubVector is not IReduceInit) — verified live against clj 1.11.1. seq/rest/next route through the O-058 `.array_seq` view (its backing switch gained a `.sub_vector` arm), so a subvec's seq is O(1) too. `=`/hash reuse the equal.zig `.vec` cursor by init'ing it with the parent + absolute [start,end) indices — zero new cursor code | O(n) alloc + trie rebuild -> O(1). Measured 2000 subvec ops on a 16000-element vector, ReleaseSafe: **9215 -> 0.77 ms (~11967x)**. Reference JVM clj 1.11.1, same box/probe: 1.06 ms — cljw was ~8693x SLOWER than clj and is now at PARITY (slightly faster). Broad win: `subvec` is the idiomatic vector slice, and it now shares structure instead of rebuilding | `zig build test -Dwasm` ×2 (VM + tree_walk diff oracle, all Values equal) + new `sub_vector.zig` units (layout, flatten-on-nest, never-empty, conj/assoc/pop parent-untouched, with-meta) + the 52-assertion observable-surface pin set (vector?/hash-eq/map-key/set-dedup/compare/into-{}/walk/nested/structural-sharing/meta/class=SubVector/empty+full-range/OOB-throws), landed as e2e `subvec_view.sh` and since migrated verbatim to `test/clj/suites/subvec_view_test.clj` + corpus `subvec.txt` (31 exprs) + `phase14_subvec.sh` (bounds) still green | D-583, D-044, O-058, ADR-0179, F-004, `private/notes/subvec-seqview-design.md` | diff --git a/src/eval/backend/intrinsic.zig b/src/eval/backend/intrinsic.zig index 2de69860e..d01969e21 100644 --- a/src/eval/backend/intrinsic.zig +++ b/src/eval/backend/intrinsic.zig @@ -3,16 +3,19 @@ //! //! The bytecode VM and the TreeWalk backend both route a 2-arg call to a //! canonical `clojure.core` arithmetic/comparison Var through -//! `fastBinaryFixnum` BEFORE the generic builtin dispatch. Sharing this single +//! `fastBinaryNumber` BEFORE the generic builtin dispatch. Sharing this single //! function makes dual-backend parity (ADR-0036 / F-012) STRUCTURAL — there is //! exactly one fast path, so the differential oracle cannot catch a //! fast-path-vs-builtin divergence. //! -//! `fastBinaryFixnum` fires ONLY when both operands are inline fixnums -//! (`.integer`); anything else (float / ratio / bigint / heap-Long / non-number) -//! returns `null`, and the caller falls back to the cached builtin Var via -//! `vt.callFn` — identical to the generic path, including the builtin's -//! arg-precise error translation (F-011). add/sub/mul compute the result inline +//! `fastBinaryNumber` fires ONLY for two inline fixnums (`.integer`), or a +//! `.float` paired with a float or fixnum; anything else (ratio / bigint / +//! big-decimal / heap-Long / non-number) returns `null`, and the caller falls +//! back to the cached builtin Var via `vt.callFn` — identical to the generic +//! path, including the builtin's arg-precise error translation (F-011). The +//! float arm computes exactly what the builtins do once either operand is a +//! double: `promote.{add,sub,mul}Promoting` and `math.pairwise` both convert +//! to f64 and apply the IEEE op (O-065). add/sub/mul compute the result inline //! (`@add/sub/mulWithOverflow` on i64); a result outside the i48 fixnum window //! (i64 overflow OR > i48) returns `null` so the slow builtin path produces the //! heap-Long / BigInt per F-005 — never a silent wrap, never a float. @@ -123,7 +126,7 @@ pub fn localConstVariant(op: Opcode) ?Opcode { } /// D-386 (O-018): the `ArithOp` a `*_local_const` opcode computes (the VM -/// dispatch arm reuses `fastBinaryFixnum`). `null` for a non-fused opcode. +/// dispatch arm reuses `fastBinaryNumber`). `null` for a non-fused opcode. pub fn fromLocalConstOpcode(op: Opcode) ?ArithOp { return switch (op) { .op_add_local_const => .add, @@ -315,12 +318,47 @@ pub fn fastNth2(coll: Value, i_val: Value) ?Value { return if (is_sub) sub_vector_mod.nth(coll, @intCast(idx)) else vector_mod.nth(coll, @intCast(idx)); } -/// Fixnum fast path. Returns `null` unless BOTH operands are inline fixnums, so -/// the caller defers every other case (incl. all error cases) to the builtin. -pub fn fastBinaryFixnum(_: *Runtime, op: ArithOp, a: Value, b: Value) !?Value { - if (a.tag() != .integer or b.tag() != .integer) return null; - const ai: i64 = a.asInteger(); - const bi: i64 = b.asInteger(); +/// Binary arith/comparison fast path. Returns `null` unless the operands are +/// two inline fixnums or a float with a float/fixnum, so the caller defers +/// every other case (incl. all error cases) to the builtin. +pub fn fastBinaryNumber(_: *Runtime, op: ArithOp, a: Value, b: Value) !?Value { + if (a.tag() != .integer or b.tag() != .integer) return fastNonFixnum(op, a, b); + return fastFixnum(op, a.asInteger(), b.asInteger()); +} + +/// Out of line so the fixnum dispatch in the VM's `stepOnce` keeps the shape it +/// had before the float arm existed. +// PERF: double arithmetic inline instead of the `+`/`*`/`<` builtin call (one is a float here) [refs: O-065] +noinline fn fastNonFixnum(op: ArithOp, a: Value, b: Value) ?Value { + const at = a.tag(); + const bt = b.tag(); + if ((at == .float or at == .integer) and (bt == .float or bt == .integer)) return fastFloat(op, asF64(a), asF64(b)); + return null; +} + +/// An inline fixnum or float as f64 (`promote.toF64` for these two tags). +fn asF64(v: Value) f64 { + return if (v.tag() == .float) v.asFloat() else @floatFromInt(@as(i64, v.asInteger())); +} + +/// The double arm: `initFloat` canonicalises a NaN result the way the builtins' +/// `initFloat` does. `=`/`not=` defer: clj `=` is false across categories +/// (`(= 1 1.0)`) and the builtin owns that rule. `mod`/`rem`/`quot` defer: their +/// float semantics (sign of zero, truncation) live in the builtin. +fn fastFloat(op: ArithOp, x: f64, y: f64) ?Value { + return switch (op) { + .add => Value.initFloat(x + y), + .sub => Value.initFloat(x - y), + .mul => Value.initFloat(x * y), + .lt => Value.initBoolean(x < y), + .le => Value.initBoolean(x <= y), + .gt => Value.initBoolean(x > y), + .ge => Value.initBoolean(x >= y), + .eq, .ne, .mod, .rem, .quot => null, + }; +} + +fn fastFixnum(op: ArithOp, ai: i64, bi: i64) ?Value { const res: i64, const ov: u1 = switch (op) { .add => @addWithOverflow(ai, bi), .sub => @subWithOverflow(ai, bi), @@ -381,17 +419,17 @@ const Fixture = struct { } }; -test "fastBinaryFixnum add/sub/mul on two fixnums" { +test "fastBinaryNumber add/sub/mul on two fixnums" { var fix = Fixture.init(); defer fix.deinit(); const a = Value.initInteger(7); const b = Value.initInteger(5); - try testing.expectEqual(@as(i48, 12), (try fastBinaryFixnum(&fix.rt, .add, a, b)).?.asInteger()); - try testing.expectEqual(@as(i48, 2), (try fastBinaryFixnum(&fix.rt, .sub, a, b)).?.asInteger()); - try testing.expectEqual(@as(i48, 35), (try fastBinaryFixnum(&fix.rt, .mul, a, b)).?.asInteger()); + try testing.expectEqual(@as(i48, 12), (try fastBinaryNumber(&fix.rt, .add, a, b)).?.asInteger()); + try testing.expectEqual(@as(i48, 2), (try fastBinaryNumber(&fix.rt, .sub, a, b)).?.asInteger()); + try testing.expectEqual(@as(i48, 35), (try fastBinaryNumber(&fix.rt, .mul, a, b)).?.asInteger()); } -test "fastBinaryFixnum mul overflowing i48 defers (null) — slow path promotes" { +test "fastBinaryNumber mul overflowing i48 defers (null) — slow path promotes" { var fix = Fixture.init(); defer fix.deinit(); const a = Value.initInteger((1 << 47) - 1); // i48 max @@ -399,34 +437,62 @@ test "fastBinaryFixnum mul overflowing i48 defers (null) — slow path promotes" // it returns null so the slow builtin path produces the heap-Long / BigInt // (F-005). Observable `(* i48max 2)` → heap-Long is locked by the VM e2e + // diff oracle (TreeWalk already routes overflow through the builtin). - try testing.expect((try fastBinaryFixnum(&fix.rt, .mul, a, Value.initInteger(2))) == null); + try testing.expect((try fastBinaryNumber(&fix.rt, .mul, a, Value.initInteger(2))) == null); } -test "fastBinaryFixnum comparisons are exact" { +test "fastBinaryNumber comparisons are exact" { var fix = Fixture.init(); defer fix.deinit(); const one = Value.initInteger(1); const two = Value.initInteger(2); - try testing.expect((try fastBinaryFixnum(&fix.rt, .lt, one, two)).? == Value.true_val); - try testing.expect((try fastBinaryFixnum(&fix.rt, .gt, one, two)).? == Value.false_val); - try testing.expect((try fastBinaryFixnum(&fix.rt, .le, two, two)).? == Value.true_val); - try testing.expect((try fastBinaryFixnum(&fix.rt, .ge, one, two)).? == Value.false_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .lt, one, two)).? == Value.true_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .gt, one, two)).? == Value.false_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .le, two, two)).? == Value.true_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .ge, one, two)).? == Value.false_val); // O-031: `=` / `not=` are mirror fixnum fast paths. - try testing.expect((try fastBinaryFixnum(&fix.rt, .eq, two, two)).? == Value.true_val); - try testing.expect((try fastBinaryFixnum(&fix.rt, .ne, two, two)).? == Value.false_val); - try testing.expect((try fastBinaryFixnum(&fix.rt, .ne, one, two)).? == Value.true_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .eq, two, two)).? == Value.true_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .ne, two, two)).? == Value.false_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .ne, one, two)).? == Value.true_val); } -test "fastBinaryFixnum defers (null) when an operand is not an inline fixnum" { +test "fastBinaryNumber float arm: a double with a double or fixnum computes in f64 (O-065)" { var fix = Fixture.init(); defer fix.deinit(); const i = Value.initInteger(3); const f = Value.initFloat(1.5); - try testing.expect((try fastBinaryFixnum(&fix.rt, .add, i, f)) == null); - try testing.expect((try fastBinaryFixnum(&fix.rt, .add, f, i)) == null); + // clj: (+ 3 1.5)=4.5 (- 1.5 3)=-1.5 (* 1.5 3)=4.5 (* 1.5 1.5)=2.25 + try testing.expectEqual(@as(f64, 4.5), (try fastBinaryNumber(&fix.rt, .add, i, f)).?.asFloat()); + try testing.expectEqual(@as(f64, -1.5), (try fastBinaryNumber(&fix.rt, .sub, f, i)).?.asFloat()); + try testing.expectEqual(@as(f64, 4.5), (try fastBinaryNumber(&fix.rt, .mul, f, i)).?.asFloat()); + try testing.expectEqual(@as(f64, 2.25), (try fastBinaryNumber(&fix.rt, .mul, f, f)).?.asFloat()); + // A double result stays a double even when integral: (+ 1.5 1.5)=3.0. + try testing.expect((try fastBinaryNumber(&fix.rt, .add, f, f)).?.isFloat()); + // clj: (< 1.5 3)=true (>= 1.5 3)=false (<= 3 3.0)=true + try testing.expect((try fastBinaryNumber(&fix.rt, .lt, f, i)).? == Value.true_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .ge, f, i)).? == Value.false_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .le, i, Value.initFloat(3.0))).? == Value.true_val); + // NaN: every ordering is false, arithmetic yields the canonical NaN. + const nan = Value.initFloat(std.math.nan(f64)); + try testing.expect((try fastBinaryNumber(&fix.rt, .lt, nan, i)).? == Value.false_val); + try testing.expect((try fastBinaryNumber(&fix.rt, .ge, nan, i)).? == Value.false_val); + try testing.expect(std.math.isNan((try fastBinaryNumber(&fix.rt, .add, nan, f)).?.asFloat())); +} + +test "fastBinaryNumber defers (null) for equality, division family and non-numbers" { + var fix = Fixture.init(); + defer fix.deinit(); + const i = Value.initInteger(3); + const f = Value.initFloat(3.0); + // (= 3 3.0) is false in clj; the builtin owns cross-category equality. + try testing.expect((try fastBinaryNumber(&fix.rt, .eq, i, f)) == null); + try testing.expect((try fastBinaryNumber(&fix.rt, .ne, f, f)) == null); + try testing.expect((try fastBinaryNumber(&fix.rt, .quot, f, i)) == null); + // A non-number operand defers so the builtin raises its arg-precise error. + try testing.expect((try fastBinaryNumber(&fix.rt, .add, f, Value.nil_val)) == null); + try testing.expect((try fastBinaryNumber(&fix.rt, .lt, Value.true_val, i)) == null); } -test "fastBinaryFixnum mod/rem/quot positive-divisor fast path matches clj" { +test "fastBinaryNumber mod/rem/quot positive-divisor fast path matches clj" { var fix = Fixture.init(); defer fix.deinit(); const Case = struct { a: i48, b: i48, mod: i48, rem: i48, quot: i48 }; @@ -441,24 +507,24 @@ test "fastBinaryFixnum mod/rem/quot positive-divisor fast path matches clj" { for (cases) |c| { const a = Value.initInteger(c.a); const b = Value.initInteger(c.b); - try testing.expectEqual(c.mod, (try fastBinaryFixnum(&fix.rt, .mod, a, b)).?.asInteger()); - try testing.expectEqual(c.rem, (try fastBinaryFixnum(&fix.rt, .rem, a, b)).?.asInteger()); - try testing.expectEqual(c.quot, (try fastBinaryFixnum(&fix.rt, .quot, a, b)).?.asInteger()); + try testing.expectEqual(c.mod, (try fastBinaryNumber(&fix.rt, .mod, a, b)).?.asInteger()); + try testing.expectEqual(c.rem, (try fastBinaryNumber(&fix.rt, .rem, a, b)).?.asInteger()); + try testing.expectEqual(c.quot, (try fastBinaryNumber(&fix.rt, .quot, a, b)).?.asInteger()); } } -test "fastBinaryFixnum mod/rem/quot defer (null) on non-positive divisor + non-fixnum" { +test "fastBinaryNumber mod/rem/quot defer (null) on non-positive divisor + non-fixnum" { var fix = Fixture.init(); defer fix.deinit(); const seven = Value.initInteger(7); // divide-by-zero → null (builtin raises divide_by_zero with the arg caret) - try testing.expect((try fastBinaryFixnum(&fix.rt, .mod, seven, Value.initInteger(0)) == null)); - try testing.expect((try fastBinaryFixnum(&fix.rt, .rem, seven, Value.initInteger(0)) == null)); - try testing.expect((try fastBinaryFixnum(&fix.rt, .quot, seven, Value.initInteger(0)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .mod, seven, Value.initInteger(0)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .rem, seven, Value.initInteger(0)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .quot, seven, Value.initInteger(0)) == null)); // negative divisor → null (builtin computes the correct sign; avoids the // @divTrunc(i48min,-1) i64-overflow corner + Zig negative-denominator semantics) - try testing.expect((try fastBinaryFixnum(&fix.rt, .mod, seven, Value.initInteger(-3)) == null)); - try testing.expect((try fastBinaryFixnum(&fix.rt, .quot, Value.initInteger((-1) << 47), Value.initInteger(-1)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .mod, seven, Value.initInteger(-3)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .quot, Value.initInteger((-1) << 47), Value.initInteger(-1)) == null)); // non-fixnum operand → null - try testing.expect((try fastBinaryFixnum(&fix.rt, .mod, seven, Value.initFloat(2.0)) == null)); + try testing.expect((try fastBinaryNumber(&fix.rt, .mod, seven, Value.initFloat(2.0)) == null)); } diff --git a/src/eval/backend/vm.zig b/src/eval/backend/vm.zig index 8f4edd447..d29976bde 100644 --- a/src/eval/backend/vm.zig +++ b/src/eval/backend/vm.zig @@ -727,7 +727,7 @@ inline fn stepOnce( }, .op_add, .op_sub, .op_mul, .op_lt, .op_le, .op_gt, .op_ge, .op_eq, .op_mod, .op_rem, .op_quot, .op_ne => { // Binary arith/comparison intrinsic (ADR-0130 cache/deopt contract). - // Fixnum fast path skips + // Fixnum/float fast path skips // var-resolve + BuiltinFn dispatch; any other case (incl. errors) // defers to the cached builtin Var for full parity. `pristine` is // cleared by alter-var-root on a cached op (deopt → always builtin). @@ -737,7 +737,7 @@ inline fn stepOnce( const b = stack[sp + 1]; const aop = intrinsic.fromOpcode(instr_op).?; const fast: ?Value = if (rt.core_arith_pristine) - try intrinsic.fastBinaryFixnum(rt, aop, a, b) + try intrinsic.fastBinaryNumber(rt, aop, a, b) else null; const result = fast orelse blk: { @@ -867,7 +867,7 @@ inline fn stepOnce( const b = chunk.constants[cidx]; const aop = intrinsic.fromLocalConstOpcode(instr_op).?; const fast: ?Value = if (rt.core_arith_pristine) - try intrinsic.fastBinaryFixnum(rt, aop, a, b) + try intrinsic.fastBinaryNumber(rt, aop, a, b) else null; const result = fast orelse blk: { @@ -898,7 +898,7 @@ inline fn stepOnce( const b = locals[sb]; const aop = intrinsic.fromLocalsOpcode(instr_op).?; const fast: ?Value = if (rt.core_arith_pristine) - try intrinsic.fastBinaryFixnum(rt, aop, a, b) + try intrinsic.fastBinaryNumber(rt, aop, a, b) else null; const result = fast orelse blk: { @@ -943,7 +943,7 @@ inline fn stepOnce( break :bblk locals[sb]; }; const fast: ?Value = if (rt.core_arith_pristine) - try intrinsic.fastBinaryFixnum(rt, info.cmp, a, b) + try intrinsic.fastBinaryNumber(rt, info.cmp, a, b) else null; const cmp_result = fast orelse blk: { diff --git a/src/eval/backend/vm/opcode.zig b/src/eval/backend/vm/opcode.zig index a1526d73a..bf0d737e1 100644 --- a/src/eval/backend/vm/opcode.zig +++ b/src/eval/backend/vm/opcode.zig @@ -233,15 +233,16 @@ pub const Opcode = enum(u8) { /// ADR-0130: `(+ a b)` arithmetic intrinsic — emitted by the compiler when /// the callee resolves (by Var pointer identity) to canonical `clojure.core/+` /// with exactly 2 args. No operand. Stack on entry: `[…, a, b]`; pops both, - /// pushes the sum. Dispatch: if `rt.core_arith_pristine` and both operands are - /// inline fixnums → `intrinsic.fastBinaryFixnum(.add)` (= `promote.addPromoting`, - /// F-005 overflow→bigint); else `vt.callFn` the cached `+` builtin Var (full + /// pushes the sum. Dispatch: if `rt.core_arith_pristine` and the operands are + /// two inline fixnums or a float with a float/fixnum → + /// `intrinsic.fastBinaryNumber(.add)` (= `promote.addPromoting`, F-005 + /// overflow→bigint by deferring); else `vt.callFn` the cached `+` builtin Var (full /// numeric-tower + arg-precise error parity). Net stack effect −1 (not a pure /// push). First cut of the intrinsic family (op_sub/op_mul/op_lt… follow). op_add = 0x28, /// ADR-0130 + am1: the rest of the binary arith/comparison intrinsic family, - /// same shape as op_add (no operand; pop a,b; fixnum fast path via - /// `intrinsic.fastBinaryFixnum`, else defer to the cached builtin Var). `=` + /// same shape as op_add (no operand; pop a,b; fixnum/float fast path via + /// `intrinsic.fastBinaryNumber`, else defer to the cached builtin Var). `=` /// (op_eq) is fixnum-only — any non-(fixnum,fixnum) pair defers to the builtin /// `=` for full value-equality semantics. op_sub = 0x29, @@ -310,7 +311,7 @@ pub const Opcode = enum(u8) { /// O-030 (ADR-0146 redirect / 9.2.S): fixnum `mod`/`rem`/`quot` intrinsics, /// same shape as the op_add family (no operand; pop a,b; fixnum fast path via - /// `intrinsic.fastBinaryFixnum` — `@mod`/`@rem`/`@divTrunc` — else defer to the + /// `intrinsic.fastBinaryNumber` — `@mod`/`@rem`/`@divTrunc` — else defer to the /// cached builtin Var for the full numeric tower / divide-by-zero raise). Net /// stack effect −1. op_mod = 0x47, diff --git a/src/lang/diff_test.zig b/src/lang/diff_test.zig index 9906b16db..70bd34abf 100644 --- a/src/lang/diff_test.zig +++ b/src/lang/diff_test.zig @@ -271,6 +271,30 @@ test "diff: arith intrinsic family (ADR-0130 am1) — sub/mul/comparisons/= (inl try f.checkEqual("(let* [< -] (< 10 3))"); } +test "diff: float arith/compare intrinsic (O-065) — VM inline f64 ≡ builtin" { + var f: Fixture = undefined; + try Fixture.init(&f, testing.allocator); + defer f.deinit(); + // A double on either side takes the VM's f64 arm; TreeWalk always runs the + // builtin, so each pair below checks the inline arm against the tower. + try f.checkEqual("(+ 1.5 2)"); + try f.checkEqual("(+ 2 1.5)"); + try f.checkEqual("(- 1.5 3)"); + try f.checkEqual("(* 1.5 1.5)"); + try f.checkEqual("(+ 1.5 1.5)"); // integral double stays a double + try f.checkEqual("(* 0.1 3)"); + try f.checkEqual("(< 1.5 2)"); + try f.checkEqual("(<= 2 2.0)"); + try f.checkEqual("(> 2.0 2)"); + try f.checkEqual("(>= 1.5 2)"); + try f.checkEqual("(< ##NaN 1)"); // NaN orders false + try f.checkEqual("(>= 1 ##NaN)"); + try f.checkEqual("(= 2 2.0)"); // = still defers: false across categories + try f.checkEqual("(let* [x 1.5] (* x 2))"); // (local, const) → op_mul_local_const + try f.checkEqual("(let* [x 1.5 y 4] (< x y))"); // (local, local) → op_lt_locals + try f.checkEqual("(loop* [i 0 acc 0.0] (if (< i 10) (recur (+ i 1) (+ acc (* 1.5 i))) acc))"); +} + test "diff: mod/rem/quot intrinsic (O-030) — VM op_mod/rem/quot ≡ builtin" { var f: Fixture = undefined; try Fixture.init(&f, testing.allocator); @@ -295,7 +319,7 @@ test "diff: mod/rem/quot intrinsic (O-030) — VM op_mod/rem/quot ≡ builtin" { // only the Zig builtins + macros (no core.clj), and `not=` is a `.clj`-defined fn // (core.clj), so it is unresolved in this fixture and op_ne never fires here. // op_ne's fixnum fast path is unit-tested directly (intrinsic.zig -// "fastBinaryFixnum comparisons are exact"); its end-to-end clj-parity is locked by +// "fastBinaryNumber comparisons are exact"); its end-to-end clj-parity is locked by // the `not_equal` clj corpus (test/diff/clj_corpus/) on the real (core-loaded) // binary. The VM≡TreeWalk parity is structural: op_ne mirrors op_eq (diff-tested // above), and the non-fixnum path defers to the same .clj `not=` on both backends. From 236af578f0297a4722ac2eb33c9629a7b18fab6b Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 19:49:50 -0300 Subject: [PATCH 04/51] test(e2e): migrate 17 phase16 scripts to clojure.test suites agent, locking, concurrency_stress, the java.io family, reducers, repl, json_fs, tokenizer_long_input, extend_via_metadata and bfs_queue_gc now run in the shared suite image. shutdown-agents cases stay in a child cljw since the call is process-global. Divergence pins repointed to the suites; e2e ceiling 322 -> 305. Smell-audited: 0: no smells; scripts deleted with their run_all rows and fixtures --- .dev/accepted_divergences.yaml | 6 +- .scratch/RESUME.md | 19 +- scripts/check_e2e_count.sh | 2 +- test/{e2e => clj}/fixtures/bfs_queue_gc.clj | 0 test/clj/suites/agent_test.clj | 171 +++++++++++ test/clj/suites/core_libs_phase16_test.clj | 96 +++++++ test/clj/suites/gc_heap_return_test.clj | 7 +- test/clj/suites/java_io_family_test.clj | 298 ++++++++++++++++++++ test/clj/suites/locking_test.clj | 60 ++++ test/e2e/phase16_agent.sh | 250 ---------------- test/e2e/phase16_bfs_queue_gc.sh | 52 ---- test/e2e/phase16_cljw_json_fs.sh | 60 ---- test/e2e/phase16_clojure_core_reducers.sh | 65 ----- test/e2e/phase16_clojure_java_io.sh | 109 ------- test/e2e/phase16_clojure_java_io_copy.sh | 51 ---- test/e2e/phase16_clojure_java_io_streams.sh | 105 ------- test/e2e/phase16_clojure_repl.sh | 27 -- test/e2e/phase16_concurrency_stress.sh | 60 ---- test/e2e/phase16_extend_via_metadata.sh | 66 ----- test/e2e/phase16_file_io_errors.sh | 47 --- test/e2e/phase16_fs_jail.sh | 49 ---- test/e2e/phase16_host_stream.sh | 60 ---- test/e2e/phase16_java_io_file.sh | 71 ----- test/e2e/phase16_locking.sh | 75 ----- test/e2e/phase16_special_file_read.sh | 44 --- test/e2e/phase16_tokenizer_long_input.sh | 34 --- test/run_all.sh | 17 -- 27 files changed, 645 insertions(+), 1256 deletions(-) rename test/{e2e => clj}/fixtures/bfs_queue_gc.clj (100%) create mode 100644 test/clj/suites/agent_test.clj create mode 100644 test/clj/suites/core_libs_phase16_test.clj create mode 100644 test/clj/suites/java_io_family_test.clj create mode 100644 test/clj/suites/locking_test.clj delete mode 100755 test/e2e/phase16_agent.sh delete mode 100755 test/e2e/phase16_bfs_queue_gc.sh delete mode 100755 test/e2e/phase16_cljw_json_fs.sh delete mode 100755 test/e2e/phase16_clojure_core_reducers.sh delete mode 100755 test/e2e/phase16_clojure_java_io.sh delete mode 100755 test/e2e/phase16_clojure_java_io_copy.sh delete mode 100755 test/e2e/phase16_clojure_java_io_streams.sh delete mode 100755 test/e2e/phase16_clojure_repl.sh delete mode 100755 test/e2e/phase16_concurrency_stress.sh delete mode 100644 test/e2e/phase16_extend_via_metadata.sh delete mode 100755 test/e2e/phase16_file_io_errors.sh delete mode 100755 test/e2e/phase16_fs_jail.sh delete mode 100755 test/e2e/phase16_host_stream.sh delete mode 100755 test/e2e/phase16_java_io_file.sh delete mode 100755 test/e2e/phase16_locking.sh delete mode 100644 test/e2e/phase16_special_file_read.sh delete mode 100755 test/e2e/phase16_tokenizer_long_input.sh diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml index bc1a2dacb..c5147d958 100644 --- a/.dev/accepted_divergences.yaml +++ b/.dev/accepted_divergences.yaml @@ -680,7 +680,7 @@ accepted: `release-pending-sends` are implemented for real; only the executor surface diverges, because it presupposes a subsystem cljw deliberately lacks. derives_from: "ADR-0093 (per-agent worker, no shared pool) + ADR-0155 (D-442 classification) + the no-`java.util.concurrent`-executor model + provisional_marker.md permanent-no-op-forbidden." - pin: "test/e2e/phase16_agent.sh — agent_send_via_raises / agent_set_executor_raises / agent_set_off_executor_raises (all three raise the no-configurable-executor message)." + pin: "test/clj/suites/agent_test.clj, deftest agent-executor-fns-raise — agent_send_via_raises / agent_set_executor_raises / agent_set_off_executor_raises (all three raise the no-configurable-executor message)." - id: "AD-046" summary: "After `(shutdown-agents)`, a dropped `send` does NOT fire an agent's `:error-handler` — cljw silently drops the action; clj routes the swallowed `RejectedExecutionException` to the handler if one is set. The COMMON case (no handler) MATCHES clj exactly: send returns the agent, no throw, action never runs." example: '(do (shutdown-agents) (let [a (agent 0 :error-handler h)] (send a inc))) => cljw drops, h NOT called / clj drops, h called with a RejectedExecutionException. Without a handler both runtimes drop silently (identical).' @@ -706,7 +706,7 @@ accepted: Kind differs, AD-007 covers it" claim was FACTUALLY WRONG (it assumed clj throws to the caller; the oracle shows it does not) and is corrected here. derives_from: "F-011 (clj-parity authoritative; the no-handler path matches exactly) + ADR-0093 (per-agent worker, no java.util.concurrent executor / no RejectedExecutionException type) + ADR-0155 (D-442 shutdown-agents classification) + provisional_marker.md permanent-no-op-forbidden (the silent drop is the clj-faithful terminal no-op, audited)." - pin: "test/e2e/phase16_agent.sh — shutdown_agents_send_returns_agent (send returns the agent, no throw), shutdown_agents_action_dropped (state unchanged), shutdown_agents_no_handler_fire (cljw does NOT fire the :error-handler on the dropped send: log stays empty)." + pin: "test/clj/suites/agent_test.clj, deftest shutdown-agents-is-process-global (each case in a child cljw, since shutdown-agents is process-global) — shutdown_agents_send_returns_agent (send returns the agent, no throw), shutdown_agents_action_dropped (state unchanged), shutdown_agents_no_handler_fire (cljw does NOT fire the :error-handler on the dropped send: log stays empty)." - id: "AD-047" summary: "A host java.util collection's `str`/`.toString` renders in CLOJURE content form (`[1 2]`, `{:a 1, :b 2}`, `#{1 2}`), not JVM toString form (`[1, 2]`, `{:b=2, :a=1}` with `=` pairs + JVM iteration order). The `pr`/`pr-str`/REPL form MATCHES clj exactly (`[1 2]` / `#{1 2}` / `{:a 1}`) — D-468; only the str-form (Java toString) diverges." example: '(str (doto (java.util.HashMap.) (.put :a 1) (.put :b 2))) => cljw "{:a 1, :b 2}" / clj "{:b=2, :a=1}"; (str (java.util.ArrayList. [1 2])) => cljw "[1 2]" / clj "[1, 2]"' @@ -892,7 +892,7 @@ accepted: has shipped the sequential shape for a decade. The seam is two functions (`foldvec`, `Cat`'s `coll-fold`), so a later parallelisation is local. derives_from: "ADR-0093 (no ForkJoinPool / java.util.concurrent executor surface) + ADR-0059 (no-JVM) + the ClojureScript precedent (clojure/core/reducers.cljs ships the same sequential fold)." - pin: "test/e2e/phase16_clojure_core_reducers.sh — `fold-value-on-a-large-vector` (a 10,000-element vector, well past any plausible cutoff n) and `fold-with-a-distinct-combiner` (the 4-arity arm, where a combiner that is not the reducef must still be honoured). Both assert the VALUE, so a later parallelisation cannot change it. Plus test/diff/clj_corpus/reducers.txt, whose fold rows are golden pairs against real clj." + pin: "test/clj/suites/core_libs_phase16_test.clj, deftest reducers-ns — `fold-value-on-a-large-vector` (a 10,000-element vector, well past any plausible cutoff n) and `fold-with-a-distinct-combiner` (the 4-arity arm, where a combiner that is not the reducef must still be honoured). Both assert the VALUE, so a later parallelisation cannot change it. Plus test/diff/clj_corpus/reducers.txt, whose fold rows are golden pairs against real clj." - id: "AD-059" area: "clojure.set" summary: |- diff --git a/.scratch/RESUME.md b/.scratch/RESUME.md index 97afa1c7a..009e8f302 100644 --- a/.scratch/RESUME.md +++ b/.scratch/RESUME.md @@ -1,8 +1,11 @@ -# CLJW-PERF-STARTUP resume (card 20261022150542-05d73990) -State: O-090 (serialize.zig quoted-list scratch from ctx.allocator) built, measured, ledgered; D-610 records the residual. Commit pending on a green smoke (/tmp/ps_smoke.log). -Measured (perf stat -r 20, load 10-45 so task-clock not wall): cljw -e nil task-clock 38.8 -> 27.3 ms (bb 29.1), faults 2741 -> 1792 (bb 4868). Pinned taskset -c 2: kernel instr 29.3M -> 12.3M (bb 26.8M); user 61.6M unchanged (bb 16.2M). -CLJW_PROFILE_STARTUP: prefix 1-7 ms (noisy), runEnvelope 13-24 ms. -Drafted, NOT measured (build-lock wait), reverted: referAllImpl getOrPut+reserve. Worth a real A/B. -Next levers (D-610): vm_arena 868 KB threadlocal makes TLS 1.17 MB, memset twice (vm.zig, out of scope); shared clojure.core refer table; readCollection; lazy ns replay during eager. -Binaries: /tmp/ps/cljw-base (before), /tmp/ps/cljw-o091 (after), /tmp/ps/prof2 (-Dprofile). -PR: https://github.com/BuddhiLW/ClojureWasm/pull/87 (opened 2026-10-22). Pending: confirm the final smoke (/tmp/ps_smoke.log) and note the result on the PR. +# CLJW-E2E-SWEEP phase16 resume (card 20260909231443-121d741e, branch swarm/e2e-phase16) + +Commit 1: 17 phase16 scripts migrated (agent, locking, concurrency_stress, java-io family, +reducers / repl / json_fs / tokenizer, extend_via_metadata, bfs_queue_gc) into the suites +agent_test, locking_test, java_io_family_test, core_libs_phase16_test, gc_heap_return_test. +Baseline 322 -> 305. Smoke test_clj_suites green (881 tests, 156 suites). + +Remaining phase16 (19): eval_budget gc_torture http_client http_server load_file_source +repl_classpath stdin_read_line vm_error_loc_sidecar wasm_* (12), zig_native (already marked). +Next: merge origin/staging; classify each remaining script as in-process (migrate) or +process-boundary (mark with `# e2e-process-boundary: `). diff --git a/scripts/check_e2e_count.sh b/scripts/check_e2e_count.sh index bfcd955c2..be7438544 100755 --- a/scripts/check_e2e_count.sh +++ b/scripts/check_e2e_count.sh @@ -23,7 +23,7 @@ # bash 3.2 compatible (the macOS host runs the system bash). set -uo pipefail -BASELINE=322 +BASELINE=305 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../test/e2e}" diff --git a/test/e2e/fixtures/bfs_queue_gc.clj b/test/clj/fixtures/bfs_queue_gc.clj similarity index 100% rename from test/e2e/fixtures/bfs_queue_gc.clj rename to test/clj/fixtures/bfs_queue_gc.clj diff --git a/test/clj/suites/agent_test.clj b/test/clj/suites/agent_test.clj new file mode 100644 index 000000000..5f5529607 --- /dev/null +++ b/test/clj/suites/agent_test.clj @@ -0,0 +1,171 @@ +;; Agents (ADR-0093, ADR-0155): send / send-off / await, error modes, watches, +;; *agent*, release-pending-sends, the executor-shaped fns that raise, and +;; binding conveyance. Engine: runtime/agent.zig (single-drainer handoff); +;; surface: lang/primitive/agent.zig + core.clj `await`. +;; +;; Migrated from test/e2e/phase16_agent.sh, which paid one process per case. +;; The script's marker names are kept in the assertion messages. +;; +;; `shutdown-agents` is PROCESS-GLOBAL: run in this shared suite image it would +;; drop every later suite's sends. Those cases (and only those) run in a child +;; cljw, which is what the script's "each -e is a fresh process" relied on. +(ns suites.agent-test + (:require [clojure.test :refer [deftest is testing]] + [clojure.java.shell :refer [sh]])) + +(defn- await-failed + "Poll until `a` has failed, bounded: synchronises with the async drainer." + [a] + (loop [n 0] + (when (and (nil? (agent-error a)) (< n 400)) + (Thread/sleep 5) + (recur (inc n)))) + a) + +(defn- boom [_] (throw (ex-info "boom" {}))) + +(deftest agent-send-and-await + (is (= 5 @(agent 5)) "agent_deref") + (is (= 1 (let [a (agent 0)] (send a inc) (await a) @a)) "agent_send_await") + (is (= 100 (let [a (agent 0)] (dotimes [_ 100] (send a inc)) (await a) @a)) + "agent_serial_order") + (is (= 15 (let [a (agent 0)] (send a + 5) (send a * 3) (await a) @a)) + "agent_send_args") + (is (= '(2 1) (let [a (agent (list))] (send-off a conj 1) (send-off a conj 2) (await a) @a)) + "agent_send_off")) + +(deftest agent-concurrency + (testing "agent_concurrent_sends: 4 threads x 100 sends to ONE agent all land" + (is (= 400 (let [a (agent 0)] + (run! deref (mapv (fn [_] (future (dotimes [_ 100] (send a inc)))) (range 4))) + (await a) + @a)))) + (testing "agent_concurrent_agents: different agents run independently" + (is (= [50 50 50 50 50 50 50 50] + (let [as (mapv (fn [_] (agent 0)) (range 8))] + (run! (fn [a] (dotimes [_ 50] (send a inc))) as) + (run! await as) + (mapv deref as)))))) + +(deftest agent-options + (is (= 1 (let [a (agent 0 :validator number?)] (send a inc) (await a) @a)) + "agent_options_validator") + (is (= {:a 1} (meta (agent 0 :meta {:a 1}))) "agent_options_meta") + (is (thrown-with-msg? IllegalStateException #"Invalid reference state" + (agent -1 :validator pos?)) + "agent_options_validator_reject: the INITIAL value is validated") + (is (thrown-with-msg? ClassCastException #"expected agent" (send 5 inc)) + "agent_send_nonagent_error")) + +(deftest agent-error-modes + (is (some? (agent-error (await-failed (doto (agent 0) (send boom))))) + "agent_fail_default: a thrown action fails the agent") + (is (= :threw (let [a (await-failed (doto (agent 0) (send boom)))] + (try (send a inc) :no-throw (catch Throwable _ :threw)))) + "agent_send_to_failed_throws") + (is (= [11 nil] (let [a (await-failed (doto (agent 0) (send boom)))] + (restart-agent a 10) (send a inc) (await a) + [@a (agent-error a)])) + "agent_restart") + (is (= [1 nil :continue] + (let [a (agent 0)] + (set-error-mode! a :continue) + (send a boom) (send a inc) (await a) + [@a (agent-error a) (error-mode a)])) + "agent_continue_mode") + (is (= :fail (error-mode (agent 0))) "agent_error_mode_default") + (is (nil? (agent-errors (agent 0))) "agent_errors_nil") + (is (= [nil 0] (let [a (await-failed (doto (agent 0 :error-mode :fail) (send boom)))] + (clear-agent-errors a) + [(agent-error a) @a])) + "clear_agent_errors_failed")) + +(deftest agent-nested-send-and-predicate + ;; The nested send is DEFERRED until the action completes (D-388); two awaits + ;; observe the full drain deterministically on clj and cljw alike. + (is (= 2 (let [a (agent 0)] (send a (fn [s] (send a inc) (inc s))) (await a) (await a) @a)) + "agent_nested_send") + (is (= [true false false] [(agent? (agent 0)) (agent? 5) (agent? (atom 0))]) + "agent_predicate")) + +(deftest agent-watches + (is (= [[0 1] [1 2] [2 2]] + (let [log (atom []) a (agent 0)] + (add-watch a :k (fn [_ _ o n] (swap! log conj [o n]))) + (send a inc) (send a inc) (await a) + @log)) + "agent_add_watch") + (is (= [1 1] + (let [log (atom []) a (agent 0)] + (add-watch a :k (fn [_ _ _ n] (swap! log conj n))) + (send a inc) (await a) (remove-watch a :k) (send a inc) (await a) + @log)) + "agent_remove_watch") + (is (true? (let [seen (atom nil) a (agent 10)] + (add-watch a :k (fn [_ r _ _] (reset! seen (= r a)))) + (send a inc) (await a) + @seen)) + "agent_watch_ref_arg")) + +(deftest agent-executor-fns-raise + ;; AD-045 / D-442: cljw has no configurable executor; accepting one silently + ;; would be a lie. + (is (thrown-with-msg? IllegalArgumentException #"no configurable executor" + (send-via :ex (agent 0) inc)) + "agent_send_via_raises") + (is (thrown-with-msg? IllegalArgumentException #"no configurable executor" + (set-agent-send-executor! :ex)) + "agent_set_executor_raises") + (is (thrown-with-msg? IllegalArgumentException #"no configurable executor" + (set-agent-send-off-executor! :ex)) + "agent_set_off_executor_raises")) + +(deftest agent-star-and-pending-sends + (is (true? (let [a (agent nil)] (send a (fn [_] *agent*)) (await a) (= @a a))) + "agent_star_in_action") + (is (nil? *agent*) "agent_star_outside_nil") + (is (true? (let [seen (atom nil) a (agent 0)] + (add-watch a :k (fn [_ r _ _] (reset! seen (= r *agent*)))) + (send a inc) (await a) + @seen)) + "agent_star_in_watch") + (is (= [1 1] (let [cnt (atom nil) b (agent 0) a (agent 0)] + (send a (fn [s] (send b inc) (reset! cnt (release-pending-sends)) s)) + (await a) (await b) + [@cnt @b])) + "release_pending_sends_in_action") + (is (= 0 (release-pending-sends)) "release_pending_sends_outside")) + +(def ^:dynamic *agent-test-cv* :unset) + +(deftest agent-binding-conveyance + (is (= :caller (let [a (agent nil)] + (binding [*agent-test-cv* :caller] (send a (fn [_] *agent-test-cv*))) + (await a) @a)) + "agent_send_conveys_bindings") + (is (= :caller (let [a (agent nil)] + (binding [*agent-test-cv* :caller] (send-off a (fn [_] *agent-test-cv*))) + (await a) @a)) + "agent_sendoff_conveys_bindings") + (is (= :unset (let [a (agent nil)] (send a (fn [_] *agent-test-cv*)) (await a) @a)) + "agent_send_no_conveyance_sees_root")) + +(defn- child-value + "Evaluate `script` in a child cljw and answer the last stdout line." + [script] + (let [{:keys [out]} (sh "zig-out/bin/cljw" "-e" script)] + (last (clojure.string/split-lines out)))) + +(deftest shutdown-agents-is-process-global + ;; `-e` prints the value of the form, so the child's last line IS the result. + (is (= "nil" (child-value "(shutdown-agents)")) "shutdown_agents_returns_nil") + (is (= "true" (child-value "(do (shutdown-agents) (let [a (agent 0)] (identical? a (send a inc))))")) + "shutdown_agents_send_returns_agent: the dropped send does not throw") + (is (= "0" (child-value "(do (shutdown-agents) (let [a (agent 0)] (send a inc) (Thread/sleep 100) @a))")) + "shutdown_agents_action_dropped") + ;; AD-046: cljw has no RejectedExecutionException to route, so the dropped + ;; send does NOT fire the :error-handler. + (is (= "[[] 0]" + (child-value (str "(let [log (atom []) a (agent 0 :error-handler (fn [_ _] (swap! log conj :fired)))]" + " (shutdown-agents) (send a inc) (Thread/sleep 100) [@log @a])"))) + "shutdown_agents_no_handler_fire")) diff --git a/test/clj/suites/core_libs_phase16_test.clj b/test/clj/suites/core_libs_phase16_test.clj new file mode 100644 index 000000000..9211c5567 --- /dev/null +++ b/test/clj/suites/core_libs_phase16_test.clj @@ -0,0 +1,96 @@ +;; Bundled namespaces and reader limits: clojure.core.reducers (D-513 (1)), +;; clojure.repl (D-513 finding 2), defprotocol :extend-via-metadata (D-314), +;; tokenizer long-input DoS guard, and the D-556-class BFS queue GC pin. +;; +;; Migrated from test/e2e/phase16_{clojure_core_reducers,clojure_repl, +;; extend_via_metadata,tokenizer_long_input,bfs_queue_gc}.sh; marker names are +;; kept in the assertion messages. Two cases need a fresh process and run in a +;; child cljw: the REPL's refer of doc into user, and the BFS run under +;; CLJW_GC_THRESHOLD_MB=1 (the GC threshold is read once at startup). +(ns suites.core-libs-phase16-test + (:require [clojure.test :refer [deftest is testing]] + [clojure.string :as str] + [clojure.set] + [clojure.java.shell :refer [sh]] + [clojure.core.reducers :as r] + [clojure.repl :as repl])) + +;; ---- clojure.core.reducers ---- + +(deftest reducers-ns + (is (= [2 3 4] (into [] (r/map inc [1 2 3]))) "require-and-map") + (is (= [2 4] (into [] ((r/filter even?) [1 2 3 4]))) "curried-arity") + ;; fold is SEQUENTIAL (AD-058): pin the VALUE so a later parallelisation + ;; cannot change it. + (is (= 49995000 (r/fold + (vec (range 10000)))) "fold-value-on-a-large-vector") + (is (= 4950 (r/fold 4 + (fn ([] 0) ([a b] (+ a b))) (vec (range 100)))) + "fold-with-a-distinct-combiner") + (is (= 500 (count (r/foldcat (vec (range 500))))) "foldcat-counts") + (is (= 2 (let [a (r/cat)] (r/append! a 1) (r/append! a 2) (count a))) + "append!-returns-the-accumulator") + (is (= 4 (reduce (fn [a b] (if (> a 3) (reduced a) (+ a b))) 0 (r/mapcat (fn [x] [x x]) [1 2 3 4]))) + "reduced-terminates-through-mapcat")) + +;; ---- clojure.repl ---- + +(deftest clojure-repl-ns + (let [d (with-out-str (repl/doc interpose))] + (is (str/includes? d "clojure.core/interpose") "doc_header") + (is (str/includes? d "([sep] [sep coll])") "doc_arglists") + (is (str/includes? d "separated by sep") "doc_text")) + (is (= 'clojure.core/interpose (first (repl/apropos "interpos"))) "apropos") + (is (pos? (count (repl/dir-fn 'clojure.set))) "dir_fn") + (is (= "my-fn?" (repl/demunge "my_fn_QMARK_")) "demunge") + (is (str/includes? (with-out-str (repl/find-doc "separated by sep")) "clojure.core/interpose") "find_doc") + (is (thrown-with-msg? Exception #"not available" (repl/source-fn 'map)) "source_throws")) + +(deftest clojure-repl-refers-at-the-prompt-only + ;; The interactive REPL refers doc/dir/apropos into user (clojure.main parity); + ;; a script run deliberately does not. Both are properties of a fresh process. + (is (str/includes? (:out (sh "zig-out/bin/cljw" "repl" :in "(doc interpose)\n")) "clojure.core/interpose") + "repl_bare_doc") + (let [{:keys [out err]} (sh "zig-out/bin/cljw" "-e" "(doc interpose)")] + (is (str/includes? (str out err) "doc") "script_no_doc"))) + +;; ---- defprotocol :extend-via-metadata (D-314) ---- +;; The method key is the protocol-defining-ns-qualified symbol. + +(defprotocol EvmSized :extend-via-metadata true (evm-sized [x])) +(defprotocol EvmVecSized :extend-via-metadata true (evm-vsized [x])) +(extend-protocol EvmVecSized clojure.lang.IPersistentVector (evm-vsized [_] :extend)) +(defprotocol EvmMapSized :extend-via-metadata true (evm-msized [x])) +(extend-protocol EvmMapSized clojure.lang.IPersistentMap (evm-msized [_] :extend)) +(defprotocol EvmPlain (evm-psize [x])) + +(deftest extend-via-metadata + (let [k (fn [s] (symbol "suites.core-libs-phase16-test" s))] + (is (= 42 (evm-sized (with-meta {} {(k "evm-sized") (fn [_] 42)}))) "meta_dispatch") + (is (false? (satisfies? EvmSized (with-meta {} {(k "evm-sized") (fn [_] 42)}))) + "satisfies_ignores_meta") + (is (= [:meta-wins :extend] + [(evm-vsized (with-meta [] {(k "evm-vsized") (fn [_] :meta-wins)})) (evm-vsized [])]) + "meta_beats_extend") + (is (= [:extend :meta :extend] + [(evm-msized {}) (evm-msized (with-meta {} {(k "evm-msized") (fn [_] :meta)})) (evm-msized {})]) + "cache_bypass: per-value metadata does not poison the per-type cache") + (is (thrown? Throwable (evm-psize (with-meta {} {(k "evm-psize") (fn [_] 99)}))) + "flag_gates: no flag, no metadata dispatch"))) + +;; ---- tokenizer long input (u16 column / token length overflow) ---- + +(deftest tokenizer-long-input + (is (= 42 (load-string (str ";; " (apply str (repeat 80000 "x")) "\n(+ 40 2)"))) + "tok-long-line-no-panic: a line past 65535 columns") + (is (= 70000 (count (read-string (str "\"" (apply str (repeat 70000 "a")) "\"")))) + "tok-long-token-no-panic: a token past 65535 chars, not truncated")) + +;; ---- BFS queue GC pin (D-556 class) ---- + +(def ^:private bfs-fixture "test/clj/fixtures/bfs_queue_gc.clj") +(def ^:private bfs-won "WON path= [3 3 3 3 3 3 3 3 3 3]") + +(deftest bfs-queue-gc + (is (= bfs-won (str/trim (with-out-str (load-file bfs-fixture)))) "bfs_queue_default") + (is (= bfs-won (str/trim (:out (sh "zig-out/bin/cljw" bfs-fixture + :env (assoc (into {} (System/getenv)) "CLJW_GC_THRESHOLD_MB" "1"))))) + "bfs_queue_thr1mb: frequent collects")) diff --git a/test/clj/suites/gc_heap_return_test.clj b/test/clj/suites/gc_heap_return_test.clj index 270277a7b..14081677a 100644 --- a/test/clj/suites/gc_heap_return_test.clj +++ b/test/clj/suites/gc_heap_return_test.clj @@ -11,9 +11,12 @@ (defn- committed [] (.totalMemory (Runtime/getRuntime))) (defn- burst - "Allocate a ~8.5 MB vector trie (1M elements) and drop it." + "Allocate a ~34 MB vector trie (4M elements) and drop it. Larger than any + free cell space the suites loaded before this one leave behind: an 8.5 MB + burst was absorbed by those holes in the full suite image, so the heap never + grew and the test measured nothing." [] - (count (vec (range 1000000)))) + (count (vec (range 4000000)))) (deftest requested-collect-returns-empty-pages (System/gc) diff --git a/test/clj/suites/java_io_family_test.clj b/test/clj/suites/java_io_family_test.clj new file mode 100644 index 000000000..e81d8f58e --- /dev/null +++ b/test/clj/suites/java_io_family_test.clj @@ -0,0 +1,298 @@ +;; The java.io / clojure.java.io family (ADR-0126 Cycles 1-5) over the +;; buffer-backed host_stream: java.io.File, the Coercions / IOFactory fns, +;; reader / writer / streams, copy, line-seq, slurp / spit options and errors, +;; the cljw.json / cljw.fs wrappers, and size-0 special files (procfs). +;; +;; Migrated from test/e2e/phase16_{java_io_file,clojure_java_io, +;; clojure_java_io_copy,clojure_java_io_streams,host_stream,file_io_errors, +;; cljw_json_fs,special_file_read,fs_jail}.sh, which paid one process per case. +;; The scripts' marker names are kept in the assertion messages. +;; +;; The FS-jail cases (CLJW_FS_ROOT) configure the PROCESS, so they run in a +;; child cljw with that variable set; nothing else here leaves the process. +;; +;; A cljw class value prints like the JVM class but is not `=` to the class +;; symbol's value for the host stream families, so class identity is asserted +;; by name. +(ns suites.java-io-family-test + (:require [clojure.test :refer [deftest is testing use-fixtures]] + [clojure.java.io :as io] + [clojure.string :as str] + [clojure.java.shell :refer [sh]] + [cljw.json] + [cljw.fs])) + +(def ^:private tmp (str "/tmp/cljw_java_io_family_" (System/currentTimeMillis))) + +(defn- p [& parts] (str/join "/" (cons tmp parts))) + +(defn- rm-tree! [f] + (doseq [x (reverse (file-seq (io/file f)))] (.delete x))) + +(defn- class-name [x] (.getName (class x))) + +(use-fixtures :once + (fn [run] + (rm-tree! tmp) + (.mkdirs (io/file (p "sub"))) + (try (run) (finally (rm-tree! tmp))))) + +;; ---- java.io.File (Cycle 1) ---- + +(deftest java-io-file-type + (is (= java.io.File (class (java.io.File. "/tmp"))) "class") + (is (instance? java.io.File (java.io.File. "/tmp")) "instance_pos") + (is (not (instance? java.io.File 5)) "instance_neg")) + +(deftest java-io-file-path-methods + (is (= "c.txt" (.getName (java.io.File. "/a/b/c.txt"))) "getName") + (is (= "/a/b/c.txt" (.getPath (java.io.File. "/a/b/c.txt"))) "getPath") + (is (= "/a/b" (.getParent (java.io.File. "/a/b/c.txt"))) "getParent") + (is (nil? (.getParent (java.io.File. "x"))) "getParent_nil") + (is (true? (.isAbsolute (java.io.File. "/a"))) "isAbsolute_t") + (is (false? (.isAbsolute (java.io.File. "a/b"))) "isAbsolute_f") + (is (= "/a/b" (str (java.io.File. "/a/b"))) "toString") + (is (= "b" (.getName (.getParentFile (java.io.File. "/a/b/c")))) "getParentFile") + (is (= "/a/b/c.txt" (.getAbsolutePath (java.io.File. "/a/b/c.txt"))) "absPath_abs") + (is (.isAbsolute (java.io.File. (.getAbsolutePath (java.io.File. "rel/x")))) "absPath_rel") + (is (= "/a/c" (.getCanonicalPath (java.io.File. "/a/./b/../c"))) "canon_dotdot") + (is (= "/a/b/c.txt" (.getPath (java.io.File. "/a/b" "c.txt"))) "ctor2") + (is (= "/a/b" (.getPath (java.io.File. (java.io.File. "/a") "b"))) "ctor2_file")) + +(deftest java-io-file-fs-methods + (spit (p "a.txt") "hello\n") + (is (true? (.exists (java.io.File. (p "a.txt")))) "exists_t") + (is (false? (.exists (java.io.File. (p "nope")))) "exists_f") + (is (true? (.isFile (java.io.File. (p "a.txt")))) "isFile_t") + (is (false? (.isFile (java.io.File. (p "sub")))) "isFile_dir") + (is (true? (.isDirectory (java.io.File. (p "sub")))) "isDir_t") + (is (false? (.isDirectory (java.io.File. (p "a.txt")))) "isDir_file") + (is (= 6 (.length (java.io.File. (p "a.txt")))) "length") + (is (true? (.canRead (java.io.File. (p "a.txt")))) "canRead") + (is (true? (.mkdir (java.io.File. (p "d1")))) "mkdir") + (is (true? (.mkdirs (java.io.File. (p "list" "d3" "d4")))) "mkdirs") + (is (true? (.delete (java.io.File. (p "d1")))) "delete") + (is (false? (.delete (java.io.File. (p "never")))) "delete_gone") + (spit (p "list" "z.txt") "z") + (is (= ["d3" "z.txt"] (sort (seq (.list (java.io.File. (p "list")))))) "list")) + +;; ---- clojure.java.io file family (Cycle 2) ---- + +(deftest cji-file-and-as-file + (is (= "/a/b" (.getPath (io/file "/a/b"))) "file_1") + (is (= "/a/b/c.txt" (.getPath (io/file "/a" "b/c.txt"))) "file_2") + (is (= "/a/b/c" (.getPath (io/file "/a" "b" "c"))) "file_3") + (is (instance? java.io.File (io/file "/a")) "file_class") + (is (= "/x/y" (.getPath (io/as-file "/x/y"))) "as_file_str") + (is (nil? (io/as-file nil)) "as_file_nil") + (is (= "/z" (.getPath (io/as-file (io/file "/z")))) "as_file_idem") + (is (= "a/b" (io/as-relative-path "a/b")) "rel_ok") + (is (thrown? Throwable (io/as-relative-path "/abs")) "rel_abs_throws")) + +(deftest cji-delete-and-make-parents + (spit (p "del.txt") "x") + (is (true? (io/delete-file (p "del.txt"))) "delete_ok") + (is (= :skipped (io/delete-file (p "gone") :skipped)) "delete_silently") + (is (thrown? Throwable (io/delete-file (p "gone"))) "delete_throws") + (is (true? (io/make-parents (io/file tmp "d1/d2/leaf.txt"))) "make_parents") + (is (true? (.isDirectory (io/file (p "d1" "d2")))) "parents_made")) + +(deftest cji-as-url-and-resource + (is (nil? (io/as-url nil)) "as_url_nil") + (is (= "http://x/y" (str (io/as-url "http://x/y"))) "as_url_str") + (is (= "file:///a/b" (str (io/as-url (io/file "/a/b")))) "as_url_file") + (is (instance? java.net.URI (io/as-url "http://x")) "as_url_uri") + (is (thrown? Throwable (io/as-url 42)) "as_url_bad") + (spit (p "uri.txt") "uri-body") + (is (= "uri-body" (slurp (io/reader (java.net.URI. (str "file://" (p "uri.txt")))))) + "reader_file_uri") + (is (nil? (io/resource "config.edn")) "resource_nil") + (is (= :none (if-let [_ (io/resource "x")] :found :none)) "resource_gd")) + +(deftest spit-options-and-file-args + (let [ap (p "ap.txt") ap2 (p "ap2.txt") cc (p "cc.txt")] + (spit ap "1") (spit ap "2" :append true) (spit ap "3" :append true) + (is (= "123" (slurp ap)) "spit_append_options: appended") + (spit ap2 "X" :append true) + (is (= "X" (slurp ap2)) "spit_append_options: append to a missing file creates it") + (spit ap "reset" :append false) + (is (= "reset" (slurp ap)) "spit_append_options: :append false truncates") + (spit ap "enc" :encoding "UTF-8") + (is (= "enc" (slurp ap)) "spit_append_options: :encoding accepted") + (spit cc 42) + (is (= "42" (slurp cc)) "spit_append_options: content is str-coerced") + (spit cc {:a 1}) + (is (= "{:a 1}" (slurp cc)))) + (spit (io/file (p "farg.txt")) "filebody") + (is (= "filebody" (slurp (io/file (p "farg.txt")))) "slurp_spit_file_arg") + (spit (io/file tmp "sub.txt") "nested") + (is (= "nested" (slurp (io/file (p "sub.txt")))) "slurp_spit_file_arg: multi-segment File")) + +;; ---- reader / writer / streams (Cycle 4) ---- + +(deftest cji-stream-coercions + (spit (p "lines.txt") "alpha\nbeta\ngamma\n") + (let [lines (p "lines.txt")] + (is (= "java.io.BufferedReader" (class-name (io/reader lines))) "reader_class") + (is (instance? java.io.Writer (io/writer (p "w.txt"))) "writer_inst") + (is (instance? java.io.InputStream (io/input-stream lines)) "instream_inst") + (is (instance? java.io.OutputStream (io/output-stream (p "o.dat"))) "outstream_inst") + (is (let [r (io/reader lines)] (identical? r (io/reader r))) "reader_idem") + (is (= "java.io.BufferedReader" (class-name (io/reader (io/file lines)))) "reader_of_file") + (is (thrown? Throwable (io/reader 42)) "reader_bad") + (testing "instance? follows the clj class chain (D-358)" + (is (instance? java.io.BufferedReader (io/reader lines)) "r_concrete") + (is (instance? java.io.Reader (io/reader lines)) "r_super") + (is (not (instance? java.io.FileReader (io/reader lines))) "r_sibling") + (is (not (instance? java.io.PrintWriter (io/writer (p "w2.txt")))) "w_sibling") + (is (instance? java.io.FilterInputStream (io/input-stream lines)) "is_filter") + (is (not (instance? java.io.FileInputStream (io/input-stream lines))) "is_sibling") + (is (not (instance? java.io.OutputStream (io/reader lines))) "r_cross")))) + +(deftest imported-simple-class-names + (spit (p "imp.txt") "x\n") + (testing "import_simple: (import ...) as its own form, then a bare class name" + (is (true? (binding [*ns* *ns*] + (eval '(ns java-io-family-imp)) + (eval '(import (quote java.io.BufferedReader))) + (eval (list 'instance? 'BufferedReader (list 'clojure.java.io/reader (p "imp.txt")))))))) + (testing "import_ns_form + import_cross_ns: a fn closing over an (:import) resolves lexically" + (binding [*ns* *ns*] + (eval '(ns java-io-family-imp-ns (:import [java.io BufferedReader]))) + (eval '(defn r? [x] (instance? BufferedReader x)))) + (is (true? ((ns-resolve 'java-io-family-imp-ns 'r?) (io/reader (p "imp.txt"))))))) + +(deftest line-seq-and-with-open + (spit (p "ls.txt") "alpha\nbeta\ngamma\n") + (is (= ["alpha" "beta" "gamma"] (vec (line-seq (io/reader (p "ls.txt"))))) "line_seq") + (with-open [w (io/writer (p "out.txt"))] (.write w "wrote ") (.write w "it")) + (is (= "wrote it" (slurp (p "out.txt"))) "with_open_writer") + (spit (p "blank.txt") "a\n\nb\n") + (is (= 3 (count (line-seq (io/reader (p "blank.txt"))))) "line_seq_blank: blank lines count") + (spit (p "471.txt") "l1\nl2") + (is (= "l2" (with-open [r (io/reader (p "471.txt"))] (.readLine r) (slurp r))) + "slurp_reader_rest: slurp drains the unread remainder") + (with-open [w (io/writer (p "471b.txt"))] (spit w "via-writer")) + (is (= "via-writer" (slurp (p "471b.txt"))) "spit_writer")) + +;; ---- copy (Cycle 5) ---- + +(deftest cji-copy + (spit (p "src.txt") "file-source-bytes") + (io/copy "from-string" (p "o_str.txt")) + (is (= "from-string" (slurp (p "o_str.txt"))) "str_to_file") + (io/copy (io/file (p "src.txt")) (io/file (p "o_file.txt"))) + (is (= "file-source-bytes" (slurp (p "o_file.txt"))) "file_to_file") + (with-open [w (io/writer (p "o_pass.txt"))] (io/copy (io/reader (p "src.txt")) w)) + (is (= "file-source-bytes" (slurp (p "o_pass.txt"))) "reader_to_passed_writer") + (with-open [o (io/output-stream (p "o_bin.txt"))] (io/copy (io/input-stream (p "src.txt")) o)) + (is (= "file-source-bytes" (slurp (p "o_bin.txt"))) "instream_to_outstream") + (is (thrown? Throwable (io/copy 1 (p "x"))) "copy_bad_in") + (is (thrown? Throwable (io/copy "ok" 2)) "copy_bad_out")) + +;; ---- host_stream primitives (Cycle 3) ---- + +(deftest host-stream-primitives + (is (= ["a" "bb" "ccc" nil] + (let [r (cljw.internal/__string-reader "a\nbb\nccc")] + [(.readLine r) (.readLine r) (.readLine r) (.readLine r)])) + "readLine_seq") + (is (= [65 66 -1] (let [r (cljw.internal/__string-reader "AB")] [(.read r) (.read r) (.read r)])) + "read_byte") + (is (= "hello world" (cljw.internal/__stream-slurp (cljw.internal/__string-reader "hello world"))) + "slurp_string") + (is (= ["a" "" "b"] (let [r (cljw.internal/__string-reader "a\n\nb")] [(.readLine r) (.readLine r) (.readLine r)])) + "blank_lines") + (is (= "java.io.BufferedReader" (class-name (cljw.internal/__string-reader "x"))) "class_reader") + (is (instance? java.io.Reader (cljw.internal/__string-reader "x")) "inst_reader") + (is (not (instance? java.io.Reader 5)) "inst_reader_neg") + (is (instance? java.io.Writer (cljw.internal/__open-writer (p "hs_w.txt"))) "inst_writer") + (is (instance? java.io.OutputStream (cljw.internal/__open-output-stream (p "hs_o.dat"))) "inst_outstream") + (let [w (cljw.internal/__open-writer (p "hs_out.txt"))] (.write w "hello ") (.write w "world") (.close w)) + (is (= "hello world" (slurp (p "hs_out.txt"))) "writer_flush") + (is (= "hello world" (cljw.internal/__stream-slurp (cljw.internal/__open-reader (p "hs_out.txt")))) + "reader_file") + (let [in (cljw.internal/__string-reader "copied!") out (cljw.internal/__open-writer (p "hs_copy.txt"))] + (cljw.internal/__stream-copy in out) + (.close out)) + (is (= "copied!" (slurp (p "hs_copy.txt"))) "stream_copy") + (spit (p "crlf.txt") "x\r\ny\r\n") + (is (= ["x" "y" nil] (let [r (cljw.internal/__open-reader (p "crlf.txt"))] [(.readLine r) (.readLine r) (.readLine r)])) + "crlf: readLine strips CRLF")) + +;; ---- slurp / spit errors are catchable (io_error Kind) ---- + +(deftest file-io-errors + (let [missing "/tmp/cljw_no_such_file_zzz.edn"] + (is (thrown? Throwable (slurp missing)) "slurp_missing_catchable") + (is (thrown? java.io.IOException (slurp missing)) "slurp_missing_ioexception") + (is (thrown? java.io.FileNotFoundException (slurp missing)) "slurp_missing_filenotfound")) + (is (thrown? Throwable (spit "/cljw_no_such_dir_zzz/x" "y")) "spit_baddir_catchable") + (is (thrown? java.io.FileNotFoundException (spit "/cljw_no_such_dir_zzz/x" "y")) "spit_baddir_filenotfound") + (spit (p "rt.txt") "hello") + (is (= "hello" (slurp (p "rt.txt"))) "slurp_spit_roundtrip")) + +;; ---- size-0 special files (CLJW-PROCFS-READ) ---- + +(deftest special-file-read + (if-not (.exists (io/file "/proc/version")) + (is true "no /proc on this host: the file_io unit test covers the shape") + (do + (is (pos? (count (slurp "/proc/loadavg"))) "slurp-loadavg") + (is (pos? (count (slurp "/proc/version"))) "slurp-version") + (is (pos? (count (with-open [r (io/reader "/proc/loadavg")] (doall (line-seq r))))) "reader-lines") + (is (pos? (count (slurp (io/file "/proc/version")))) "io-file")))) + +;; ---- cljw.json / cljw.fs (ADR-0126 Cycle 7) ---- + +(deftest cljw-json + (is (= "{\"a\":1,\"b\":[2,3]}" (cljw.json/encode {:a 1 :b [2 3]})) "json_encode") + (is (= {:name "x" :nested {:k 1}} (cljw.json/decode "{\"name\":\"x\",\"nested\":{\"k\":1}}")) "json_decode_kw") + (is (= "(+ 1 2)" (:code (cljw.json/decode "{\"code\":\"(+ 1 2)\"}"))) "json_decode_get") + (is (= 1 (get (cljw.json/decode-strict "{\"a\":1}") "a")) "json_decode_strict") + (is (= {:x [1 2 3]} (cljw.json/decode (cljw.json/encode {:x [1 2 3]}))) "json_roundtrip") + (is (= "X" (with-redefs [clojure.data.json/write-str (fn [& _] "X")] (cljw.json/write-str {:a 1}))) + "json_write_str_redefs: delegates at call time") + (is (= {:a 1} (cljw.json/read-str "{\"a\":1}" :key-fn keyword)) "json_read_str_opts") + (is (= '([x & options]) (:arglists (meta #'cljw.json/write-str))) "json_write_str_arglists") + (is (string? (:doc (meta #'cljw.json/read-str))) "json_read_str_doc")) + +(deftest cljw-fs + (spit (p "ten.txt") "0123456789") + (is (true? (cljw.fs/exists? (p "ten.txt"))) "fs_exists") + (is (false? (cljw.fs/exists? (p "nope"))) "fs_exists_no") + (is (true? (cljw.fs/regular-file? (p "ten.txt"))) "fs_regular") + (is (true? (cljw.fs/directory? (p "sub"))) "fs_dir") + (is (= "ten.txt" (cljw.fs/file-name (p "ten.txt"))) "fs_name") + (is (= 10 (cljw.fs/size (p "ten.txt"))) "fs_size") + (is (= (p "ten.txt") (cljw.fs/path (p "ten.txt"))) "fs_path") + (is (true? (cljw.fs/create-dirs (p "a" "b" "c"))) "fs_create_dirs") + (is (true? (cljw.fs/directory? (p "a" "b" "c"))) "fs_made")) + +;; ---- FS jail (ADR-0123): CLJW_FS_ROOT configures the process ---- + +(defn- jailed + "Run `expr` in a child cljw whose FS jail is `root`; answer its last stdout line." + [root expr] + (let [{:keys [out err]} (sh "zig-out/bin/cljw" "-e" expr + :env (assoc (into {} (System/getenv)) "CLJW_FS_ROOT" root))] + (or (last (str/split-lines out)) err))) + +(deftest fs-jail + (let [jail (p "jail")] + (.mkdirs (io/file jail)) + (spit (str jail "/inside.txt") "secret-inside") + (is (= "\"secret-inside\"" (jailed jail "(slurp \"inside.txt\")")) + "fsjail-confined-read: a jail-relative path resolves under the root") + (is (= ":escaped" (jailed jail "(try (slurp \"../../../../../etc/hosts\") (catch Throwable _ :escaped))")) + "fsjail-traversal-rejected") + (is (= ":escaped" (jailed jail "(try (slurp \"/etc/hosts\") (catch Throwable _ :escaped))")) + "fsjail-absolute-rejected") + (jailed jail "(spit \"out.txt\" \"written\")") + (is (= "written" (slurp (str jail "/out.txt"))) "fsjail-confined-write") + (is (= ":escaped" (jailed jail "(try (spit \"../escape.txt\" \"x\") (catch Throwable _ :escaped))")) + "fsjail-write-traversal-rejected") + (is (not (.exists (io/file (p "escape.txt")))) "fsjail-write-traversal-rejected: nothing written outside") + (is (= ":blocked" (jailed jail "(try (cljw.internal/__open-reader \"../../../etc/passwd\") (catch Throwable e :blocked))")) + "jail_escape: the host_stream open path is jailed too") + (is (= "secret-inside" (slurp (str jail "/inside.txt"))) "fsjail-off-local-unaffected"))) diff --git a/test/clj/suites/locking_test.clj b/test/clj/suites/locking_test.clj new file mode 100644 index 000000000..56f90920f --- /dev/null +++ b/test/clj/suites/locking_test.clj @@ -0,0 +1,60 @@ +;; `(locking obj body...)` (ADR-0009 lock_state bits + a threadlocal reentrancy +;; held-set, runtime/concurrency/object_monitor.zig). Immediates share one static +;; monitor; nil is refused (clj NPEs). Migrated from test/e2e/phase16_locking.sh, +;; plus the repeated-run stress invariants of phase16_concurrency_stress.sh, which +;; already looped inside ONE process and so lose nothing in a suite. +(ns suites.locking-test + (:require [clojure.test :refer [deftest is testing]])) + +(deftest locking-basics + (is (= 42 (locking (atom 0) 42)) "locking_basic") + (is (= 99 (let [a (atom 0)] (locking a (locking a 99)))) "locking_reentrant") + (is (= 15 (let [a (atom 5) x 10] (locking a (+ x @a)))) "locking_body_env") + (is (= 42 (locking 5 42)) "locking_immediate") + (is (thrown-with-msg? ClassCastException #"cannot lock nil" (eval '(locking nil 42))) + "locking_nil_errors") + (is (thrown-with-msg? Exception #"locking requires" (eval '(locking (atom 0)))) + "locking_no_body_errors") + (is (= 99 (let [o (atom 0)] + (try (locking o (throw (ex-info "x" {}))) (catch Throwable _ :c)) + (locking o 99))) + "locking_releases_on_throw")) + +(deftest locking-mutual-exclusion + ;; (reset! a (inc @a)) is a NON-atomic read-then-write; only the lock makes + ;; every increment land. + (is (= 400 (let [a (atom 0)] + (run! deref (mapv (fn [_] (future (dotimes [_ 100] (locking a (reset! a (inc @a)))))) + (range 4))) + @a)) + "locking_mutual_exclusion")) + +;; --- concurrency stress (memory ordering / lost updates) --- +;; Two real races (atom non-atomic swap!, STM doGet stale read) surfaced only +;; under ReleaseSafe with REPEATED runs, so each invariant loops N times here. +(def ^:private stress-n 20) + +(defn- every-iteration? [f] (every? (fn [_] (f)) (range stress-n))) + +(defn- fan-out! [f] (run! deref (mapv (fn [_] (future (f))) (range 4)))) + +(deftest concurrency-stress + (is (every-iteration? #(let [a (atom 0)] (fan-out! (fn [] (dotimes [_ 100] (swap! a inc)))) (= 400 @a))) + "stress_atom_swap") + (is (every-iteration? + #(let [a (atom 0)] + (fan-out! (fn [] (dotimes [_ 100] + (loop [] (let [o @a] (when-not (compare-and-set! a o (inc o)) (recur))))))) + (= 400 @a))) + "stress_atom_cas") + (is (every-iteration? #(let [c (ref 0)] (fan-out! (fn [] (dotimes [_ 100] (dosync (alter c inc))))) (= 400 @c))) + "stress_stm_alter") + (is (every-iteration? + #(let [a (ref 100) b (ref 0)] + (fan-out! (fn [] (dotimes [_ 50] (dosync (alter a dec) (alter b inc))))) + (= [-100 200] [@a @b]))) + "stress_stm_transfer") + (is (every-iteration? #(let [a (atom 0)] (fan-out! (fn [] (dotimes [_ 100] (locking a (reset! a (inc @a)))))) (= 400 @a))) + "stress_locking") + (is (every-iteration? #(let [a (agent 0)] (fan-out! (fn [] (dotimes [_ 100] (send a inc)))) (await a) (= 400 @a))) + "stress_agent_sends")) diff --git a/test/e2e/phase16_agent.sh b/test/e2e/phase16_agent.sh deleted file mode 100755 index fb096c3be..000000000 --- a/test/e2e/phase16_agent.sh +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_agent.sh -# -# Phase B #6 — agent (first slice): `(agent init)` + `(send a f & args)` / -# `(send-off a f & args)` + `@agent` (non-blocking atomic read) + `(await a)`. -# Actions on one agent run serially, in send order, on a worker thread; agents -# run concurrently. Engine: runtime/agent.zig (single-drainer handoff, leaf-lock -# queue); surface: lang/primitive/agent.zig + core.clj `await`. ADR-0093. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -last_line() { tail -n 1; } -assert_eq() { - local name="$1"; local got="$2"; local want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -# @agent is a non-blocking read of the current state. -got=$("$BIN" -e '@(agent 5)' 2>/dev/null | last_line) -assert_eq 'agent_deref' "$got" '5' - -# send dispatches an action; await blocks until it has run; @a reads the result. -got=$("$BIN" -e '(let [a (agent 0)] (send a inc) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_send_await' "$got" '1' - -# Actions run serially in send order: 100 increments all land = 100. -got=$("$BIN" -e '(let [a (agent 0)] (dotimes [_ 100] (send a inc)) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_serial_order' "$got" '100' - -# send with extra args, and ordering: (0 +5) then (* 3) = 15. -got=$("$BIN" -e '(let [a (agent 0)] (send a + 5) (send a * 3) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_send_args' "$got" '15' - -# send-off (same path as send in the first slice): conj 1 then 2 onto a list. -got=$("$BIN" -e '(let [a (agent (list))] (send-off a conj 1) (send-off a conj 2) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_send_off' "$got" '(2 1)' - -# Concurrency: 4 threads each send 100 (send a inc) to the SAME agent. The -# single-drainer handoff serialises them — every increment lands = 400. A -# stranded-action handoff race would land < 400. -got=$("$BIN" -e '(let [a (agent 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (send a inc)))) (range 4))) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_concurrent_sends' "$got" '400' - -# Different agents run concurrently: 8 agents each get 50 increments = all 50. -got=$("$BIN" -e '(let [as (mapv (fn [_] (agent 0)) (range 8))] (run! (fn [a] (dotimes [_ 50] (send a inc))) as) (run! await as) (mapv deref as))' 2>/dev/null | last_line) -assert_eq 'agent_concurrent_agents' "$got" '[50 50 50 50 50 50 50 50]' - -# Option kwargs (:validator / :meta / :error-handler / :error-mode) now ship -# (D-441). A passing validator lets actions through. -got=$("$BIN" -e '(let [a (agent 0 :validator number?)] (send a inc) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_options_validator' "$got" '1' - -# :meta is readable via (meta agent). -got=$("$BIN" -e '(meta (agent 0 :meta {:a 1}))' 2>/dev/null | last_line) -assert_eq 'agent_options_meta' "$got" '{:a 1}' - -# The validator validates the INITIAL value (clj ARef.setValidator) — a bad -# initial throws IllegalStateException "Invalid reference state". -diag=$("$BIN" -e '(agent -1 :validator pos?)' 2>&1 || true) -case "$diag" in - *"Invalid reference state"*) - echo "PASS agent_options_validator_reject -> diagnostic" ;; - *) - fail "agent_options_validator_reject: expected 'Invalid reference state', got '$diag'" ;; -esac - -# send to a non-agent is a clean type error. -diag=$("$BIN" -e '(send 5 inc)' 2>&1 || true) -case "$diag" in - *"expected agent"*|*"agent"*) - echo "PASS agent_send_nonagent_error -> diagnostic" ;; - *) - fail "agent_send_nonagent_error: expected an agent type error, got '$diag'" ;; -esac - -# --- error modes (slice 2) --- -# A poll-until-failed loop synchronises with the async drainer (no Thread/sleep). - -# :fail is the default (no error-handler) — a thrown action FAILS the agent, so -# agent-error returns the error (closing the prior silent-:continue F-011 gap). -got=$("$BIN" -e '(let [a (agent 0)] (send a (fn [_] (throw (ex-info "boom" {})))) (loop [i 0] (when (and (nil? (agent-error a)) (< i 500000)) (recur (inc i)))) (some? (agent-error a)))' 2>/dev/null | last_line) -assert_eq 'agent_fail_default' "$got" 'true' - -# A send to a failed agent throws (until restart-agent). -got=$("$BIN" -e '(let [a (agent 0)] (send a (fn [_] (throw (ex-info "boom" {})))) (loop [i 0] (when (and (nil? (agent-error a)) (< i 500000)) (recur (inc i)))) (try (send a inc) :no-throw (catch Throwable e :threw)))' 2>/dev/null | last_line) -assert_eq 'agent_send_to_failed_throws' "$got" ':threw' - -# restart-agent clears the error + sets state; the agent works again. -got=$("$BIN" -e '(let [a (agent 0)] (send a (fn [_] (throw (ex-info "boom" {})))) (loop [i 0] (when (and (nil? (agent-error a)) (< i 500000)) (recur (inc i)))) (restart-agent a 10) (send a inc) (await a) [@a (agent-error a)])' 2>/dev/null | last_line) -assert_eq 'agent_restart' "$got" '[11 nil]' - -# :continue mode — a thrown action is dropped, the agent does NOT fail, later -# actions still run. -got=$("$BIN" -e '(let [a (agent 0)] (set-error-mode! a :continue) (send a (fn [_] (throw (ex-info "boom" {})))) (send a inc) (await a) [@a (agent-error a) (error-mode a)])' 2>/dev/null | last_line) -assert_eq 'agent_continue_mode' "$got" '[1 nil :continue]' - -# error-mode default is :fail. -got=$("$BIN" -e '(error-mode (agent 0))' 2>/dev/null | last_line) -assert_eq 'agent_error_mode_default' "$got" ':fail' - -# An action may send to its OWN agent (clj allows nested send), and the nested -# send is DEFERRED until the action completes (clj `releasePendingSends`; cljw -# agent.zig `nested_pending`, D-388). We assert the post-FULL-DRAIN state: outer -# sets 0→1 and enqueues inc; a SECOND `(await a)` waits for that nested inc too, -# so @a is deterministically 2 on both clj and cljw. (A single `(await a)` here is -# clj-faithfully 1 — await waits for `outer` only, not the agent-thread-dispatched -# nested inc — but cljw's eager per-agent drainer makes that single-await result -# timing-dependent vs clj's executor pool; the two-await full-drain form is the -# deterministic capability check. See D-388.) -got=$("$BIN" -e '(let [a (agent 0)] (send a (fn [s] (send a inc) (inc s))) (await a) (await a) @a)' 2>/dev/null | last_line) -assert_eq 'agent_nested_send' "$got" '2' - -# agent? predicate. -got=$("$BIN" -e '[(agent? (agent 0)) (agent? 5) (agent? (atom 0))]' 2>/dev/null | last_line) -assert_eq 'agent_predicate' "$got" '[true false false]' - -# add-watch on an agent (IRef generalization): the watch fires (fn key agent old -# new) after each action stores a new state. `await` guarantees every fire ran -# (incl. its own count-down action's no-op fire `[2 2]`, matching JVM ARef). -got=$("$BIN" -e '(let [log (atom []) a (agent 0)] (add-watch a :k (fn [k r o n] (swap! log conj [o n]))) (send a inc) (send a inc) (await a) @log)' 2>/dev/null | last_line) -assert_eq 'agent_add_watch' "$got" '[[0 1] [1 2] [2 2]]' -# remove-watch stops further fires (the post-add send + its await fire, then none). -got=$("$BIN" -e '(let [log (atom []) a (agent 0)] (add-watch a :k (fn [k r o n] (swap! log conj n))) (send a inc) (await a) (remove-watch a :k) (send a inc) (await a) @log)' 2>/dev/null | last_line) -assert_eq 'agent_remove_watch' "$got" '[1 1]' -# the watch callback receives the agent itself as arg 2 (deref'able mid-watch). -got=$("$BIN" -e '(let [seen (atom nil) a (agent 10)] (add-watch a :k (fn [k r o n] (reset! seen (= r a)))) (send a inc) (await a) @seen)' 2>/dev/null | last_line) -assert_eq 'agent_watch_ref_arg' "$got" 'true' - -# ADR-0155 / AD-045 (D-442): the executor-shaped fns RAISE — cljw has no -# configurable executor (silently accepting the user's executor would be a lie). -out=$("$BIN" -e '(send-via :ex (agent 0) inc)' 2>&1 || true) -case "$out" in *"no configurable executor"*) echo "PASS agent_send_via_raises" ;; *) fail "send_via: expected executor error, got '$out'" ;; esac -out=$("$BIN" -e '(set-agent-send-executor! :ex)' 2>&1 || true) -case "$out" in *"no configurable executor"*) echo "PASS agent_set_executor_raises" ;; *) fail "set_executor: expected executor error, got '$out'" ;; esac -out=$("$BIN" -e '(set-agent-send-off-executor! :ex)' 2>&1 || true) -case "$out" in *"no configurable executor"*) echo "PASS agent_set_off_executor_raises" ;; *) fail "set_off_executor: expected executor error, got '$out'" ;; esac -# agent-errors (clj-deprecated) over the live agent-error: nil for a non-failed agent. -got=$("$BIN" -e '(prn (agent-errors (agent 0)))' 2>/dev/null | last_line) -assert_eq 'agent_errors_nil' "$got" 'nil' -# clear-agent-errors on a FAILED agent clears it (= clj restart-agent a @a). -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def a (agent 0 :error-mode :fail)) -(send a (fn [_] (throw (ex-info "boom" {})))) -(loop [n 0] (when (and (nil? (agent-error a)) (< n 200)) (Thread/sleep 5) (recur (inc n)))) -(clear-agent-errors a) -(prn [(agent-error a) @a]) -EOF -) -assert_eq 'clear_agent_errors_failed' "$got" '[nil 0]' - -# --- ADR-0155 / D-442 part 2: *agent* / release-pending-sends / shutdown-agents --- - -# *agent* derefs to the running agent INSIDE its own action (clj `binding -# [*agent* a]` conveyed to the worker). The action stores `(= *agent* a)`. -got=$("$BIN" -e '(let [a (agent nil)] (send a (fn [_] *agent*)) (await a) (= @a a))' 2>/dev/null | last_line) -assert_eq 'agent_star_in_action' "$got" 'true' - -# *agent* is nil OUTSIDE any action (the root binding). -got=$("$BIN" -e '(prn *agent*)' 2>/dev/null | last_line) -assert_eq 'agent_star_outside_nil' "$got" 'nil' - -# *agent* is also visible to a watch fn (the binding spans the whole action). -got=$("$BIN" -e '(let [seen (atom nil) a (agent 0)] (add-watch a :k (fn [k r o n] (reset! seen (= r *agent*)))) (send a inc) (await a) @seen)' 2>/dev/null | last_line) -assert_eq 'agent_star_in_watch' "$got" 'true' - -# release-pending-sends flushes the sends held during the action NOW and returns -# the count dispatched; the released send actually runs (b gets incremented). -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def cnt (atom nil)) -(def b (agent 0)) -(def a (agent 0)) -(send a (fn [s] (send b inc) (reset! cnt (release-pending-sends)) s)) -(await a) (await b) -(prn [@cnt @b]) -EOF -) -assert_eq 'release_pending_sends_in_action' "$got" '[1 1]' - -# release-pending-sends OUTSIDE an action does nothing and returns 0. -got=$("$BIN" -e '(release-pending-sends)' 2>/dev/null | last_line) -assert_eq 'release_pending_sends_outside' "$got" '0' - -# shutdown-agents returns nil and is process-global (each -e is a fresh process). -got=$("$BIN" -e '(shutdown-agents)' 2>/dev/null | last_line) -assert_eq 'shutdown_agents_returns_nil' "$got" 'nil' - -# After shutdown-agents a send is DROPPED (clj-faithful): send still RETURNS the -# agent (no throw), and the action never runs (state unchanged). clj swallows the -# executor RejectedExecutionException, so `send` does not throw to the caller. -got=$("$BIN" -e '(do (shutdown-agents) (let [a (agent 0)] (identical? a (send a inc))))' 2>/dev/null | last_line) -assert_eq 'shutdown_agents_send_returns_agent' "$got" 'true' -got=$("$BIN" -e '(do (shutdown-agents) (let [a (agent 0)] (send a inc) (Thread/sleep 100) @a))' 2>/dev/null | last_line) -assert_eq 'shutdown_agents_action_dropped' "$got" '0' - -# AD-046 divergence pin: clj routes the swallowed rejection to an agent's -# :error-handler; cljw has no RejectedExecutionException to synthesize, so the -# dropped send does NOT fire the handler (log stays empty). State still unchanged. -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def log (atom [])) -(def a (agent 0 :error-handler (fn [ag ex] (swap! log conj :fired)))) -(shutdown-agents) -(send a inc) -(Thread/sleep 100) -(prn [@log @a]) -EOF -) -assert_eq 'shutdown_agents_no_handler_fire' "$got" '[[] 0]' - -# --- Binding conveyance (clj parity, CLJW-BINDING-CONVEYANCE) --- -# A send / send-off action re-establishes the DISPATCHING thread's dynamic -# bindings (clj's binding-conveyor-fn); without it the action sees the var's root. -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def ^:dynamic *cv* :unset) -(def a (agent nil)) -(binding [*cv* :caller] (send a (fn [_] *cv*))) -(await a) -(prn @a) -EOF -) -assert_eq 'agent_send_conveys_bindings' "$got" ':caller' - -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def ^:dynamic *cv* :unset) -(def a (agent nil)) -(binding [*cv* :caller] (send-off a (fn [_] *cv*))) -(await a) -(prn @a) -EOF -) -assert_eq 'agent_sendoff_conveys_bindings' "$got" ':caller' - -# Dispatched OUTSIDE any binding → the action sees the var's ROOT (no leaked frame). -got=$("$BIN" - <<'EOF' 2>/dev/null | last_line -(def ^:dynamic *cv* :unset) -(def a (agent nil)) -(send a (fn [_] *cv*)) -(await a) -(prn @a) -EOF -) -assert_eq 'agent_send_no_conveyance_sees_root' "$got" ':unset' - -echo -echo "Phase B #6 agent (first slice + error modes) e2e: all green." diff --git a/test/e2e/phase16_bfs_queue_gc.sh b/test/e2e/phase16_bfs_queue_gc.sh deleted file mode 100755 index 68a8a1d0b..000000000 --- a/test/e2e/phase16_bfs_queue_gc.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_bfs_queue_gc.sh — D-556-class BFS queue-corruption pin -# (user report 2026-07-07, vm backend). -# -# A 2-list FIFO BFS queue ([state path] pairs pushed via `into` from a -# `for`+:let+:when lazy seq, requeued with `reverse`, next to a growing -# `visited` set) decayed mid-loop: `(first front)` returned a raw Long -# instead of the [state path] vector ("CORRUPT head= 3 type= Long", or -# downstream `-nth on Long` / `+: got nil`). Root cause was the D-556 -# unrooted-analysis-constants class: the vm's not-yet-executed fn literal -# pool was collectable, so a mid-loop collect recycled queue elements. -# Fixed by the persist-analysis-roots arc (2026-07-07); this pins the -# whole-program shape on the shipped binary, at the default GC threshold -# AND at CLJW_GC_THRESHOLD_MB=1 (frequent collects — the aggressive mode -# the original report reproduced under). -# -# `for` cannot live in the diff oracle (bootstrap-closure harness blind -# spot, D-234 note in src/lang/diff_test.zig), so this e2e + the -# bfs_queue clj corpus line are the standing coverage. -# -# Layer 2 (e2e CLI) per ADR-0021. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { local n="$1" g="$2" w="$3"; [[ "$g" == "$w" ]] || fail "$n: got '$g' want '$w'"; echo "PASS $n -> $w"; } - -# Portable bounded run: GNU `timeout`, else coreutils `gtimeout`, else -# unbounded (hosted mac runners ship neither; same pattern as -# scripts/corpus_regression.clj). -run_bounded() { - local secs="$1"; shift - if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" - elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" - else "$@"; fi -} - -FIXTURE="test/e2e/fixtures/bfs_queue_gc.clj" -WANT='WON path= [3 3 3 3 3 3 3 3 3 3]' - -# (1) default GC threshold — the shape the user's original report hit. -assert_eq 'bfs_queue_default' "$(run_bounded 120 "$BIN" "$FIXTURE")" "$WANT" - -# (2) 1 MB threshold — frequent collects; a resurfacing rooting hole in the -# queue / visited-set / fn-literal-pool path corrupts deterministically here. -assert_eq 'bfs_queue_thr1mb' "$(CLJW_GC_THRESHOLD_MB=1 run_bounded 120 "$BIN" "$FIXTURE")" "$WANT" - -echo "ALL phase16_bfs_queue_gc PASS" diff --git a/test/e2e/phase16_cljw_json_fs.sh b/test/e2e/phase16_cljw_json_fs.sh deleted file mode 100755 index 0a2df9566..000000000 --- a/test/e2e/phase16_cljw_json_fs.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_cljw_json_fs.sh -# -# ADR-0126 Cycle 7 — cljw.json + cljw.fs handy wrappers (require-able cljw.*). -# cljw.json: encode/decode(keywordized)/decode-strict over clojure.data.json. -# cljw.fs: babashka.fs-style predicates/ops over java.io.File. Both eager-loaded -# so fully-qualified names resolve without a require. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -run() { "$BIN" -e "(do (require (quote [cljw.json]) (quote [cljw.fs])) $1)" 2>&1 | tail -n 1; } -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_json_fs_test"; rm -rf "$TMP"; mkdir -p "$TMP/sub" -printf '0123456789' > "$TMP/ten.txt" - -# --- cljw.json --- -# encode returns a String; cljw -e prints it pr-quoted (inner quotes escaped). -assert_eq 'json_encode' "$(run '(cljw.json/encode {:a 1 :b [2 3]})')" '"{\"a\":1,\"b\":[2,3]}"' -assert_eq 'json_decode_kw' "$(run '(cljw.json/decode "{\"name\":\"x\",\"nested\":{\"k\":1}}")')" '{:name "x", :nested {:k 1}}' -assert_eq 'json_decode_get' "$(run '(:code (cljw.json/decode "{\"code\":\"(+ 1 2)\"}"))')" '"(+ 1 2)"' -assert_eq 'json_decode_strict' "$(run '(get (cljw.json/decode-strict "{\"a\":1}") "a")')" '1' -assert_eq 'json_roundtrip' "$(run '(cljw.json/decode (cljw.json/encode {:x [1 2 3]}))')" '{:x [1 2 3]}' -# write-str / read-str delegate at call time (not a def-alias): with-redefs of -# the data.json var reaches them, options pass through, and the vars document. -assert_eq 'json_write_str_redefs' "$(run '(with-redefs [clojure.data.json/write-str (fn [& _] "X")] (cljw.json/write-str {:a 1}))')" '"X"' -assert_eq 'json_read_str_opts' "$(run '(cljw.json/read-str "{\"a\":1}" :key-fn keyword)')" '{:a 1}' -assert_eq 'json_write_str_arglists' "$(run '(:arglists (meta (var cljw.json/write-str)))')" '([x & options])' -assert_eq 'json_read_str_doc' "$(run '(string? (:doc (meta (var cljw.json/read-str))))')" 'true' - -# --- cljw.fs --- -assert_eq 'fs_exists' "$(run "(cljw.fs/exists? \"$TMP/ten.txt\")")" 'true' -assert_eq 'fs_exists_no' "$(run "(cljw.fs/exists? \"$TMP/nope\")")" 'false' -assert_eq 'fs_regular' "$(run "(cljw.fs/regular-file? \"$TMP/ten.txt\")")" 'true' -assert_eq 'fs_dir' "$(run "(cljw.fs/directory? \"$TMP/sub\")")" 'true' -assert_eq 'fs_name' "$(run "(cljw.fs/file-name \"$TMP/ten.txt\")")" '"ten.txt"' -assert_eq 'fs_size' "$(run "(cljw.fs/size \"$TMP/ten.txt\")")" '10' -assert_eq 'fs_path' "$(run "(cljw.fs/path \"$TMP/ten.txt\")")" "\"$TMP/ten.txt\"" -assert_eq 'fs_create_dirs' "$(run "(cljw.fs/create-dirs \"$TMP/a/b/c\")")" 'true' -assert_eq 'fs_made' "$(run "(cljw.fs/directory? \"$TMP/a/b/c\")")" 'true' - -# --- :as alias path (user-facing), via a fixture file --- -cat > "$TMP/alias.clj" <&1 | tail -n 1)" 'true' - -rm -rf "$TMP" -echo "ALL PASS phase16_cljw_json_fs" diff --git a/test/e2e/phase16_clojure_core_reducers.sh b/test/e2e/phase16_clojure_core_reducers.sh deleted file mode 100755 index e2266369c..000000000 --- a/test/e2e/phase16_clojure_core_reducers.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_clojure_core_reducers.sh — clojure.core.reducers (D-513 (1)). -# -# The reducer ops are checked against the `clj` oracle in -# test/diff/clj_corpus/reducers.txt (replayed by `corpus_regression`). What THIS -# file covers is what a corpus line cannot: that the namespace resolves at all -# from a cold process, that `fold` is sequential-but-value-correct (AD-058), and -# that the mutable `foldcat` accumulator behaves like clj's rather than like a -# persistent conj. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null -fail() { echo "FAIL $1" >&2; exit 1; } - -ck() { # ck - local got - got="$("$BIN" -e "$2" 2>&1 | sed -n 1p)" - [[ "$got" == "$3" ]] || fail "$1: expected '$3', got '$got'" - echo "PASS $1 -> $got" -} - -# The namespace is LAZY (clj does not auto-load it either), so this also proves -# the require path resolves it rather than something having pre-interned it. -ck require-and-map \ - '(do (require (quote [clojure.core.reducers :as r])) (println (into [] (r/map inc [1 2 3]))))' \ - '[2 3 4]' - -# The curried 1-arity form — `defcurried` emits a real second arity, so -# ((r/map inc) coll) must equal (r/map inc coll). -ck curried-arity \ - '(do (require (quote [clojure.core.reducers :as r])) (println (into [] ((r/filter even?) [1 2 3 4]))))' \ - '[2 4]' - -# fold is SEQUENTIAL here (AD-058: no ForkJoinPool, ADR-0093) — this pins the -# VALUE contract, which a later parallelisation must not change. A large vector -# so the input is past any plausible cutoff `n`. -ck fold-value-on-a-large-vector \ - '(do (require (quote [clojure.core.reducers :as r])) (println (r/fold + (vec (range 10000)))))' \ - '49995000' - -# 4-arity fold with a non-reducef combiner: the arm that CLJS gets wrong by -# dropping combinef entirely (survey DIVERGENCE 4). -ck fold-with-a-distinct-combiner \ - '(do (require (quote [clojure.core.reducers :as r])) (println (r/fold 4 + (fn ([] 0) ([a b] (+ a b))) (vec (range 100)))))' \ - '4950' - -# foldcat rides a MUTABLE accumulator (java.util.ArrayList + append!), not -# conj — cw v0 substituted conj and silently changed `append!`'s contract. -ck foldcat-counts \ - '(do (require (quote [clojure.core.reducers :as r])) (println (count (r/foldcat (vec (range 500))))))' \ - '500' - -ck append!-returns-the-accumulator \ - '(do (require (quote [clojure.core.reducers :as r])) (let [a (r/cat)] (r/append! a 1) (r/append! a 2) (println (count a))))' \ - '2' - -# reduced must terminate the reduction. cljw unwraps `reduced` at the IReduce -# boundary exactly as JVM does at coll-reduce, so mapcat has to re-wrap -# (survey DIVERGENCE 3) — CLJS's body would run to completion here. -ck reduced-terminates-through-mapcat \ - '(do (require (quote [clojure.core.reducers :as r])) (println (reduce (fn [a b] (if (> a 3) (reduced a) (+ a b))) 0 (r/mapcat (fn [x] [x x]) [1 2 3 4]))))' \ - '4' - -echo "OK — phase16_clojure_core_reducers (7 cases) green" diff --git a/test/e2e/phase16_clojure_java_io.sh b/test/e2e/phase16_clojure_java_io.sh deleted file mode 100755 index 10a3c5e31..000000000 --- a/test/e2e/phase16_clojure_java_io.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_clojure_java_io.sh -# -# ADR-0126 Cycle 2 — clojure.java.io file family (Coercions + file / -# as-relative-path / delete-file / make-parents) over the java.io.File host -# type. cljw-style cond dispatch (no protocol-to-String); ex-info throws. -# -# clojure.java.io is eager-loaded (FILES), so the fully-qualified names resolve -# without a require. The `:as` alias path (the user-facing form) is covered by -# the fixture-file smoke at the end — an alias established by a runtime require -# is only visible to SUBSEQUENT top-level forms, so it cannot be exercised -# inside a single `-e (do (require …) …)` (the whole form analyses before eval). - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -run() { "$BIN" -e "$1" 2>&1 | tail -n 1; } - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_cji_test" -rm -rf "$TMP"; mkdir -p "$TMP" -echo "x" > "$TMP/del.txt" - -# --- file / as-file --- -assert_eq 'file_1' "$(run '(.getPath (clojure.java.io/file "/a/b"))')" '"/a/b"' -assert_eq 'file_2' "$(run '(.getPath (clojure.java.io/file "/a" "b/c.txt"))')" '"/a/b/c.txt"' -assert_eq 'file_3' "$(run '(.getPath (clojure.java.io/file "/a" "b" "c"))')" '"/a/b/c"' -assert_eq 'file_class' "$(run '(instance? java.io.File (clojure.java.io/file "/a"))')" 'true' -assert_eq 'as_file_str' "$(run '(.getPath (clojure.java.io/as-file "/x/y"))')" '"/x/y"' -assert_eq 'as_file_nil' "$(run '(clojure.java.io/as-file nil)')" 'nil' -assert_eq 'as_file_idem' "$(run '(.getPath (clojure.java.io/as-file (clojure.java.io/file "/z")))')" '"/z"' - -# --- as-relative-path --- -assert_eq 'rel_ok' "$(run '(clojure.java.io/as-relative-path "a/b")')" '"a/b"' -assert_eq 'rel_abs_throws' "$(run '(try (clojure.java.io/as-relative-path "/abs") (catch Throwable e :threw))')" ':threw' - -# --- delete-file --- -assert_eq 'delete_ok' "$(run "(clojure.java.io/delete-file \"$TMP/del.txt\")")" 'true' -assert_eq 'delete_silently' "$(run "(clojure.java.io/delete-file \"$TMP/gone\" :skipped)")" ':skipped' -assert_eq 'delete_throws' "$(run "(try (clojure.java.io/delete-file \"$TMP/gone\") (catch Throwable e :threw))")" ':threw' - -# --- make-parents (creates parent dirs of the target file) --- -assert_eq 'make_parents' "$(run "(clojure.java.io/make-parents (clojure.java.io/file \"$TMP\" \"d1/d2/leaf.txt\"))")" 'true' -assert_eq 'parents_made' "$(run "(.isDirectory (clojure.java.io/file \"$TMP/d1/d2\"))")" 'true' - -# --- as-url → java.net.URI (D-359); resource stays nil (no classpath) --- -assert_eq 'as_url_nil' "$(run '(clojure.java.io/as-url nil)')" 'nil' -assert_eq 'as_url_str' "$(run '(str (clojure.java.io/as-url "http://x/y"))')" '"http://x/y"' -assert_eq 'as_url_file' "$(run '(str (clojure.java.io/as-url (clojure.java.io/file "/a/b")))')" '"file:///a/b"' -assert_eq 'as_url_uri' "$(run '(instance? java.net.URI (clojure.java.io/as-url "http://x"))')" 'true' -assert_eq 'as_url_bad' "$(run '(try (clojure.java.io/as-url 42) (catch Throwable e :threw))')" ':threw' -# reader over a file: URI reads the file (http(s) URIs fetch via cljw.http.client). -printf 'uri-body' > "$TMP/uri.txt" -assert_eq 'reader_file_uri' "$(run "(cljw.internal/__stream-slurp (clojure.java.io/reader (java.net.URI. \"file://$TMP/uri.txt\")))")" '"uri-body"' -assert_eq 'resource_nil' "$(run '(clojure.java.io/resource "config.edn")')" 'nil' -assert_eq 'resource_gd' "$(run '(if-let [r (clojure.java.io/resource "x")] :found :none)')" ':none' - -# --- :as alias path (the user-facing form), via a fixture file (sequential eval) --- -FIX="$TMP/alias_smoke.clj" -cat > "$FIX" <&1 | tail -n 1)" '/a/b' - -# --- spit `& options`: :append (truthy → append; false → truncate) + :encoding -# (UTF-8 no-op). clj signature (spit f content & options). Sequential top-level -# forms via a fixture (a single -e analyses the whole form before eval). -APP="$TMP/append_smoke.clj" -cat > "$APP" <&1)" $'123\nX\nreset\nenc\n42\n{:a 1}' - -# --- D-471: slurp/spit accept a java.io.File arg (clojure.java.io/Coercions). -# cljw extracts the File's stored path (state[0]); clj routes via Coercions/IOFactory. -FARG="$TMP/farg_smoke.clj" -cat > "$FARG" <&1)" $'filebody\nnested' - -rm -rf "$TMP" -echo "ALL PASS phase16_clojure_java_io" diff --git a/test/e2e/phase16_clojure_java_io_copy.sh b/test/e2e/phase16_clojure_java_io_copy.sh deleted file mode 100755 index f0552dc26..000000000 --- a/test/e2e/phase16_clojure_java_io_copy.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_clojure_java_io_copy.sh -# -# ADR-0126 Cycle 5 — clojure.java.io/copy over the buffer-backed host_stream. -# Input: Reader/InputStream/File/String(content). Output: Writer/OutputStream/ -# File/String(path). Only copy-opened (File/String) streams are closed. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_cji_copy"; rm -rf "$TMP"; mkdir -p "$TMP" -printf 'file-source-bytes' > "$TMP/src.txt" - -# Multi-form copy round-trips via a fixture file (require + sequential eval). -cat > "$TMP/copy.clj" < File path -(io/copy "from-string" "$TMP/o_str.txt") -;; File -> File -(io/copy (io/file "$TMP/src.txt") (io/file "$TMP/o_file.txt")) -;; reader -> a passed writer (caller's with-open closes it) -(with-open [w (io/writer "$TMP/o_pass.txt")] - (io/copy (io/reader "$TMP/src.txt") w)) -;; input-stream -> output-stream (binary transport) -(with-open [o (io/output-stream "$TMP/o_bin.txt")] - (io/copy (io/input-stream "$TMP/src.txt") o)) -(println "done") -EOF -"$BIN" "$TMP/copy.clj" >/dev/null 2>&1 || fail "copy fixture run failed" - -assert_eq 'str_to_file' "$(cat "$TMP/o_str.txt")" 'from-string' -assert_eq 'file_to_file' "$(cat "$TMP/o_file.txt")" 'file-source-bytes' -assert_eq 'reader_to_passed_writer' "$(cat "$TMP/o_pass.txt")" 'file-source-bytes' -assert_eq 'instream_to_outstream' "$(cat "$TMP/o_bin.txt")" 'file-source-bytes' - -# bad args throw (single-form, fully-qualified) -assert_eq 'copy_bad_in' "$("$BIN" -e '(try (clojure.java.io/copy 1 "/tmp/x") (catch Throwable e :threw))' 2>&1 | tail -n1)" ':threw' -assert_eq 'copy_bad_out' "$("$BIN" -e '(try (clojure.java.io/copy "ok" 2) (catch Throwable e :threw))' 2>&1 | tail -n1)" ':threw' - -rm -rf "$TMP" -echo "ALL PASS phase16_clojure_java_io_copy" diff --git a/test/e2e/phase16_clojure_java_io_streams.sh b/test/e2e/phase16_clojure_java_io_streams.sh deleted file mode 100755 index c17aa1c37..000000000 --- a/test/e2e/phase16_clojure_java_io_streams.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_clojure_java_io_streams.sh -# -# ADR-0126 Cycle 4 — clojure.java.io reader/writer/input-stream/output-stream -# coercion fns + clojure.core/line-seq over the buffer-backed host_stream. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -run() { "$BIN" -e "$1" 2>&1 | tail -n 1; } -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_cji_streams"; rm -rf "$TMP"; mkdir -p "$TMP" -printf 'alpha\nbeta\ngamma\n' > "$TMP/lines.txt" - -# --- coercion fns return the right family (fully-qualified; eager ns) --- -# class identity = the clj-concrete buffered type (clj: io/reader -> BufferedReader). -assert_eq 'reader_class' "$(run "(class (clojure.java.io/reader \"$TMP/lines.txt\"))")" 'java.io.BufferedReader' -assert_eq 'writer_inst' "$(run "(instance? java.io.Writer (clojure.java.io/writer \"$TMP/w.txt\"))")" 'true' -assert_eq 'instream_inst' "$(run "(instance? java.io.InputStream (clojure.java.io/input-stream \"$TMP/lines.txt\"))")" 'true' -assert_eq 'outstream_inst' "$(run "(instance? java.io.OutputStream (clojure.java.io/output-stream \"$TMP/o.dat\"))")" 'true' -assert_eq 'reader_idem' "$(run "(let [r (clojure.java.io/reader \"$TMP/lines.txt\")] (identical? r (clojure.java.io/reader r)))")" 'true' -assert_eq 'reader_of_file' "$(run "(class (clojure.java.io/reader (clojure.java.io/file \"$TMP/lines.txt\")))")" 'java.io.BufferedReader' -assert_eq 'reader_bad' "$(run '(try (clojure.java.io/reader 42) (catch Throwable e :threw))')" ':threw' - -# --- instance? class identity (D-358, clj-faithful, verified vs clj) --- -# concrete + java.io superclass chain are true; sibling leaves are a KNOWN false -# (not class_name_unknown); cross-family false. Mirrors clj exactly (corpus -# test/diff/clj_corpus/io_stream_class.txt). -assert_eq 'r_concrete' "$(run "(instance? java.io.BufferedReader (clojure.java.io/reader \"$TMP/lines.txt\"))")" 'true' -assert_eq 'r_super' "$(run "(instance? java.io.Reader (clojure.java.io/reader \"$TMP/lines.txt\"))")" 'true' -assert_eq 'r_sibling' "$(run "(instance? java.io.FileReader (clojure.java.io/reader \"$TMP/lines.txt\"))")" 'false' -assert_eq 'w_sibling' "$(run "(instance? java.io.PrintWriter (clojure.java.io/writer \"$TMP/w2.txt\"))")" 'false' -assert_eq 'is_filter' "$(run "(instance? java.io.FilterInputStream (clojure.java.io/input-stream \"$TMP/lines.txt\"))")" 'true' -assert_eq 'is_sibling' "$(run "(instance? java.io.FileInputStream (clojure.java.io/input-stream \"$TMP/lines.txt\"))")" 'false' -assert_eq 'r_cross' "$(run "(instance? java.io.OutputStream (clojure.java.io/reader \"$TMP/lines.txt\"))")" 'false' - -# --- imported simple name resolves (both forms), via ns.imports (D-235) --- -# `(import …)` form as SEPARATE top-level forms (cljw processes top-level forms -# sequentially, so the import effect is visible to the next form's analysis). The -# old single `(do (import …) (instance? BareName …))` relied on top-level-`do` -# unrolling, which cljw lacks (D-374) and which the macro-era `instance?` masked -# by quoting the class; ADR-0128 made instance? a fn, so the bare imported class -# now resolves at analysis — use the realistic separate-forms shape (clj-faithful). -cat > "$TMP/imp.clj" <&1 | tail -n 1)" 'true' -cat > "$TMP/nsimp.clj" <&1 | tail -n 1)" 'true' -# cross-ns: a fn closing over an (:import …) resolves the simple class name -# LEXICALLY (analyze-time), so it works when called from a different ns (clj -# resolves class symbols at compile time). Was a class_name_unknown throw. -cat > "$TMP/crossns.clj" <&1 | tail -n 1)" 'true' - -# --- line-seq + with-open round-trips, via fixture files (sequential eval) --- -cat > "$TMP/lineseq.clj" <&1 | tail -n 1)" '[alpha beta gamma]' - -cat > "$TMP/withopen.clj" <&1 | tail -n 1)" 'wrote it' - -# line-seq counts blank lines (significant) -printf 'a\n\nb\n' > "$TMP/blank.txt" -cat > "$TMP/blankseq.clj" <&1 | tail -n 1)" '3' - -# D-471: slurp/spit accept open streams (the IOFactory arms clj routes -# through io/reader / io/writer). slurp drains the UNREAD remainder. -assert_eq 'slurp_reader_rest' "$("$BIN" -e '(do (spit "/tmp/cljw_e2e_471.txt" "l1\nl2") (with-open [r (clojure.java.io/reader "/tmp/cljw_e2e_471.txt")] (.readLine r) (slurp r)))' 2>&1 | tail -n 1)" '"l2"' -assert_eq 'spit_writer' "$("$BIN" -e '(do (with-open [w (clojure.java.io/writer "/tmp/cljw_e2e_471b.txt")] (spit w "via-writer")) (slurp "/tmp/cljw_e2e_471b.txt"))' 2>&1 | tail -n 1)" '"via-writer"' - -rm -rf "$TMP" -echo "ALL PASS phase16_clojure_java_io_streams" diff --git a/test/e2e/phase16_clojure_repl.sh b/test/e2e/phase16_clojure_repl.sh deleted file mode 100755 index e88d8bda5..000000000 --- a/test/e2e/phase16_clojure_repl.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_clojure_repl.sh — clojure.repl bundled ns (D-513 finding 2, -# unblocked by D-305's :doc/:arglists metadata): doc / dir-fn / apropos / -# demunge / find-doc; source throws (per-var source text is not retained). -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { local n="$1" g="$2" w="$3"; [[ "$g" == "$w" ]] || fail "$n: got '$g' want '$w'"; echo "PASS $n -> $w"; } -assert_has() { local n="$1" g="$2" w="$3"; [[ "$g" == *"$w"* ]] || fail "$n: '$g' lacks '$w'"; echo "PASS $n =~ $w"; } - -assert_has 'doc_header' "$("$BIN" -e '(require (quote clojure.repl)) (clojure.repl/doc interpose)' 2>&1)" 'clojure.core/interpose' -assert_has 'doc_arglists' "$("$BIN" -e '(require (quote clojure.repl)) (clojure.repl/doc interpose)' 2>&1)" '([sep] [sep coll])' -assert_has 'doc_text' "$("$BIN" -e '(require (quote clojure.repl)) (clojure.repl/doc interpose)' 2>&1)" 'separated by sep' -assert_eq 'apropos' "$("$BIN" -e '(require (quote clojure.repl)) (first (clojure.repl/apropos "interpos"))' 2>&1 | tail -1)" 'clojure.core/interpose' -assert_eq 'dir_fn' "$("$BIN" -e '(require (quote clojure.repl) (quote clojure.set)) (pos? (count (clojure.repl/dir-fn (quote clojure.set))))' 2>&1 | tail -1)" 'true' -assert_eq 'demunge' "$("$BIN" -e '(require (quote clojure.repl)) (clojure.repl/demunge "my_fn_QMARK_")' 2>&1 | tail -1)" '"my-fn?"' -assert_has 'find_doc' "$("$BIN" -e '(require (quote clojure.repl)) (clojure.repl/find-doc "separated by sep")' 2>&1)" 'clojure.core/interpose' -assert_has 'source_throws' "$("$BIN" -e '(require (quote clojure.repl)) (try (clojure.repl/source-fn (quote map)) (catch Throwable e (ex-message e)))' 2>&1 | tail -1)" 'not available' - -# The interactive REPL refers doc/dir/apropos into user (clj clojure.main -# parity) — bare (doc x) works at the prompt; script runs deliberately don't. -assert_has 'repl_bare_doc' "$(echo '(doc interpose)' | "$BIN" repl 2>/dev/null)" 'clojure.core/interpose' -assert_has 'script_no_doc' "$("$BIN" -e '(doc interpose)' 2>&1)" 'doc' - -echo "OK — phase16_clojure_repl (10 cases) green" diff --git a/test/e2e/phase16_concurrency_stress.sh b/test/e2e/phase16_concurrency_stress.sh deleted file mode 100755 index c23b568d8..000000000 --- a/test/e2e/phase16_concurrency_stress.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_concurrency_stress.sh -# -# Phase B concurrency stress — REGRESSION GUARD for the memory-ordering / lost- -# update class of bug. Two real races this campaign found (the atom non-atomic -# swap!, the STM doGet stale read) were INVISIBLE in Debug and surfaced only -# under ReleaseSafe with REPEATED runs (one was a ~6% flake). A single gate run -# of a concurrent case can miss such a rare race, so each invariant here is run -# in a tight loop INSIDE one cljw process; `every?` returns false the moment any -# iteration deviates, so a rare race reliably fails the step. -# -# The gate builds cljw ReleaseSafe (CLJW_OPT), so this runs against the optimised -# binary where the ordering bugs actually appear. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -N="${CLJW_STRESS_N:-20}" # iterations per invariant; raise locally to hunt flakes - -fail() { echo "FAIL $1" >&2; exit 1; } -last_line() { tail -n 1; } -assert_true() { - local name="$1"; local got="$2" - [[ "$got" == "true" ]] || fail "$name: expected every iteration to hold, got '$got'" - echo "PASS $name (x$N) -> all held" -} - -# atom swap! — 4×100 concurrent increments must ALWAYS total 400 (CAS-retry; a -# non-atomic read-modify-write loses updates). -got=$("$BIN" -e "(every? (fn [_] (let [a (atom 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (swap! a inc)))) (range 4))) (= 400 @a))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_atom_swap' "$got" - -# atom compare-and-set! user retry-loop — must also always total 400. -got=$("$BIN" -e "(every? (fn [_] (let [a (atom 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (loop [] (let [o @a] (when-not (compare-and-set! a o (inc o)) (recur))))))) (range 4))) (= 400 @a))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_atom_cas' "$got" - -# STM dosync — 4×100 (alter c inc) on one ref must ALWAYS total 400 (read-point -# conflict + retry; the doGet stale read would lose updates ~6% of runs). -got=$("$BIN" -e "(every? (fn [_] (let [c (ref 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (dosync (alter c inc))))) (range 4))) (= 400 @c))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_stm_alter' "$got" - -# STM multi-ref bank transfer — the sum invariant must ALWAYS hold ([-100 200]). -got=$("$BIN" -e "(every? (fn [_] (let [a (ref 100) b (ref 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 50] (dosync (alter a dec) (alter b inc))))) (range 4))) (= [-100 200] [@a @b]))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_stm_transfer' "$got" - -# locking — a non-atomic read-then-write under (locking a ...) must ALWAYS total -# 400 (mutual exclusion serialises the critical section). -got=$("$BIN" -e "(every? (fn [_] (let [a (atom 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (locking a (reset! a (inc @a)))))) (range 4))) (= 400 @a))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_locking' "$got" - -# agent — concurrent sends to one agent must ALWAYS drain to 400 (single-drainer -# handoff; a stranded action would lose increments). -got=$("$BIN" -e "(every? (fn [_] (let [a (agent 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (send a inc)))) (range 4))) (await a) (= 400 @a))) (range $N))" 2>/dev/null | last_line) -assert_true 'stress_agent_sends' "$got" - -echo -echo "Phase B concurrency stress (x$N each): all invariants held." diff --git a/test/e2e/phase16_extend_via_metadata.sh b/test/e2e/phase16_extend_via_metadata.sh deleted file mode 100644 index f94fe9939..000000000 --- a/test/e2e/phase16_extend_via_metadata.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_extend_via_metadata.sh — `(defprotocol P :extend-via-metadata -# true …)` dispatches a method via the RECEIVER's metadata when no type-impl -# matches: the fn is stored under the protocol-defining-ns-qualified method SYMBOL -# key (e.g. `user/sized`). clj-oracle-verified contract (D-314): -# - metadata dispatch works on a plain value with no extend-type; -# - metadata BEATS an extend-type impl (precedence), gated on the flag; -# - `satisfies?` IGNORES metadata (→ false for a meta-only extension); -# - a protocol WITHOUT the flag never consults metadata. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { [[ "$2" == "$3" ]] || fail "$1: got '$2' want '$3'"; echo "PASS $1 -> $3"; } - -# 1. meta-only dispatch: a plain map carrying ^{user/sized fn} dispatches via meta. -got=$("$BIN" - <<'EOF' 2>/dev/null || true -(defprotocol Sized :extend-via-metadata true (sized [x])) -(println (sized (with-meta {} {'user/sized (fn [_] 42)}))) -EOF -) -assert_eq 'meta_dispatch' "$got" '42' - -# 2. satisfies? ignores metadata → false for a meta-only extension (clj parity). -got=$("$BIN" - <<'EOF' 2>/dev/null || true -(defprotocol Sized :extend-via-metadata true (sized [x])) -(println (satisfies? Sized (with-meta {} {'user/sized (fn [_] 42)}))) -EOF -) -assert_eq 'satisfies_ignores_meta' "$got" 'false' - -# 3. metadata BEATS an extend-type impl (clj precedence), gated on the flag. -got=$("$BIN" - <<'EOF' 2>/dev/null || true -(defprotocol Sized :extend-via-metadata true (sized [x])) -(extend-protocol Sized clojure.lang.IPersistentVector (sized [_] :extend)) -(println (sized (with-meta [] {'user/sized (fn [_] :meta-wins)}))) -(println (sized [])) -EOF -) -assert_eq 'meta_beats_extend' "$got" $':meta-wins\n:extend' - -# 4. cache-bypass: metadata is per-VALUE, must not poison the per-TYPE dispatch -# cache. Same-type maps — no-meta uses the extend-type impl, meta uses its fn — -# and they stay independent in either order (ADR-0144 load-bearing pin). -got=$("$BIN" - <<'EOF' 2>/dev/null || true -(defprotocol Sized :extend-via-metadata true (sized [x])) -(extend-protocol Sized clojure.lang.IPersistentMap (sized [_] :extend)) -(println (sized {})) -(println (sized (with-meta {} {'user/sized (fn [_] :meta)}))) -(println (sized {})) -EOF -) -assert_eq 'cache_bypass' "$got" $':extend\n:meta\n:extend' - -# 5. the flag gates it: a protocol WITHOUT :extend-via-metadata never reads meta. -got=$("$BIN" - <<'EOF' 2>&1 -(defprotocol Plain (psize [x])) -(try (println (psize (with-meta {} {'user/psize (fn [_] 99)}))) - (catch Throwable _ (println :no-dispatch))) -EOF -) -assert_eq 'flag_gates' "$got" ':no-dispatch' - -echo "OK — phase16_extend_via_metadata (5 cases) green" diff --git a/test/e2e/phase16_file_io_errors.sh b/test/e2e/phase16_file_io_errors.sh deleted file mode 100755 index d3397fd75..000000000 --- a/test/e2e/phase16_file_io_errors.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_file_io_errors.sh — slurp/spit raise a CATCHABLE exception -# (io_error Kind → java.io.IOException) instead of an uncatchable raw Zig error, -# so a real app can `(try (slurp f) (catch Throwable _ default))`. Demo-driven -# (the edge bookshelf needs file I/O with error handling); ADR-0098 follow-on. - -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1"; local got="$2"; local want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -# slurp of a missing file is catchable as Throwable. -got=$("$BIN" -e '(try (slurp "/tmp/cljw_no_such_file_zzz.edn") :read (catch Throwable _ :caught))' 2>/dev/null) -assert_eq 'slurp_missing_catchable' "$got" ':caught' - -# ...and as java.io.IOException specifically (the io_error Kind class). -got=$("$BIN" -e '(try (slurp "/tmp/cljw_no_such_file_zzz.edn") (catch java.io.IOException _ :ioe))' 2>/dev/null) -assert_eq 'slurp_missing_ioexception' "$got" ':ioe' - -# ...and as java.io.FileNotFoundException — the precise leaf class clj raises for -# a missing file (D-321). FileNotFoundException extends IOException, so the -# IOException catch above (supertype) and this leaf catch both match. -got=$("$BIN" -e '(try (slurp "/tmp/cljw_no_such_file_zzz.edn") (catch java.io.FileNotFoundException _ :fnf))' 2>/dev/null) -assert_eq 'slurp_missing_filenotfound' "$got" ':fnf' - -# spit into a non-existent directory is catchable. -got=$("$BIN" -e '(try (spit "/cljw_no_such_dir_zzz/x" "y") :wrote (catch Throwable _ :caught))' 2>/dev/null) -assert_eq 'spit_baddir_catchable' "$got" ':caught' - -# ...and the missing-directory case is a FileNotFoundException too (clj parity). -got=$("$BIN" -e '(try (spit "/cljw_no_such_dir_zzz/x" "y") (catch java.io.FileNotFoundException _ :fnf))' 2>/dev/null) -assert_eq 'spit_baddir_filenotfound' "$got" ':fnf' - -# a successful round-trip still works (no regression). -TMP="/tmp/cljw_io_ok_$$.txt" -got=$("$BIN" -e "(do (spit \"$TMP\" \"hello\") (slurp \"$TMP\"))" 2>/dev/null) -rm -f "$TMP" -assert_eq 'slurp_spit_roundtrip' "$got" '"hello"' - -echo "OK — phase16_file_io_errors (6 cases) green" diff --git a/test/e2e/phase16_fs_jail.sh b/test/e2e/phase16_fs_jail.sh deleted file mode 100755 index 7c4dd1b38..000000000 --- a/test/e2e/phase16_fs_jail.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_fs_jail.sh — deploy-mode FS jail (ADR-0123 / SE-6/7). -# CLJW_FS_ROOT confines slurp/spit to one subtree: a path resolves UNDER the -# root (so jail-relative reads work), and `..`/absolute escapes raise a catchable -# error instead of touching the host FS. With no CLJW_FS_ROOT the jail is off -# (local CLI unchanged). Layer 2 (e2e CLI) per ADR-0021. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null -fail() { echo "FAIL $1" >&2; exit 1; } - -JAIL="$(mktemp -d "${TMPDIR:-/tmp}/cljw_jail.XXXXXX")" -trap 'rm -rf "$JAIL"' EXIT -printf 'secret-inside' > "$JAIL/inside.txt" - -# 1. Confined read: a jail-relative path resolves UNDER the root and reads. -got=$(CLJW_FS_ROOT="$JAIL" "$BIN" -e '(slurp "inside.txt")') -[[ "$got" == *secret-inside* ]] || fail "confined slurp: got '$got'" -echo "PASS fsjail-confined-read" - -# 2. `..` traversal is a CATCHABLE error, not the host file's contents. -got=$(CLJW_FS_ROOT="$JAIL" "$BIN" -e '(try (slurp "../../../../../etc/hosts") (catch Throwable _ :escaped))') -[[ "$got" == ":escaped" ]] || fail "traversal not rejected: got '$got'" -echo "PASS fsjail-traversal-rejected" - -# 3. An absolute path OUTSIDE the jail is rejected. -got=$(CLJW_FS_ROOT="$JAIL" "$BIN" -e '(try (slurp "/etc/hosts") (catch Throwable _ :escaped))') -[[ "$got" == ":escaped" ]] || fail "absolute escape not rejected: got '$got'" -echo "PASS fsjail-absolute-rejected" - -# 4. Confined write lands INSIDE the jail. -CLJW_FS_ROOT="$JAIL" "$BIN" -e '(spit "out.txt" "written")' >/dev/null -[[ -f "$JAIL/out.txt" ]] || fail "confined spit did not write inside the jail" -[[ "$(cat "$JAIL/out.txt")" == "written" ]] || fail "confined spit wrote wrong content" -echo "PASS fsjail-confined-write" - -# 5. A write that escapes the jail is rejected AND does not touch the host FS. -got=$(CLJW_FS_ROOT="$JAIL" "$BIN" -e '(try (spit "../escape.txt" "x") (catch Throwable _ :escaped))') -[[ "$got" == ":escaped" ]] || fail "spit traversal not rejected: got '$got'" -[[ ! -e "$JAIL/../escape.txt" ]] || { rm -f "$JAIL/../escape.txt"; fail "spit ESCAPED the jail (wrote outside)!"; } -echo "PASS fsjail-write-traversal-rejected" - -# 6. With NO jail, an absolute read works (local CLI unaffected). -got=$("$BIN" -e "(slurp \"$JAIL/inside.txt\")") -[[ "$got" == *secret-inside* ]] || fail "jail-off local read broke: got '$got'" -echo "PASS fsjail-off-local-unaffected" - -echo "OK — phase16_fs_jail (6 cases) green" diff --git a/test/e2e/phase16_host_stream.sh b/test/e2e/phase16_host_stream.sh deleted file mode 100755 index 9f6353add..000000000 --- a/test/e2e/phase16_host_stream.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_host_stream.sh -# -# ADR-0126 Cycle 3 — generic buffer-backed host_stream (4 family descriptors + -# cljw.internal/__open-*/__string-reader/__stream-slurp/__stream-copy primitives + read/ -# readLine/write/flush/close methods). The clojure.java.io reader/writer/copy -# fns that wrap these land in Cycle 4-5; this exercises the Zig surface directly. - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -run() { "$BIN" -e "$1" 2>&1 | tail -n 1; } -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_host_stream_test"; rm -rf "$TMP"; mkdir -p "$TMP" - -# --- reader: readLine / read / slurp over a string source --- -assert_eq 'readLine_seq' "$(run '(let [r (cljw.internal/__string-reader "a\nbb\nccc")] [(.readLine r) (.readLine r) (.readLine r) (.readLine r)])')" '["a" "bb" "ccc" nil]' -assert_eq 'read_byte' "$(run '(let [r (cljw.internal/__string-reader "AB")] [(.read r) (.read r) (.read r)])')" '[65 66 -1]' -assert_eq 'slurp_string' "$(run '(cljw.internal/__stream-slurp (cljw.internal/__string-reader "hello world"))')" '"hello world"' - -# --- reader: blank-line significance (line-seq must not collapse) --- -assert_eq 'blank_lines' "$(run '(let [r (cljw.internal/__string-reader "a\n\nb")] [(.readLine r) (.readLine r) (.readLine r)])')" '["a" "" "b"]' - -# --- class / instance? — (class s) is the clj-concrete buffered type (D-358); -# instance? is true for the concrete + its java.io superclass chain. -assert_eq 'class_reader' "$(run '(class (cljw.internal/__string-reader "x"))')" 'java.io.BufferedReader' -assert_eq 'inst_reader' "$(run '(instance? java.io.Reader (cljw.internal/__string-reader "x"))')" 'true' -assert_eq 'inst_reader_neg' "$(run '(instance? java.io.Reader 5)')" 'false' -assert_eq 'inst_writer' "$(run "(instance? java.io.Writer (cljw.internal/__open-writer \"$TMP/w.txt\"))")" 'true' -assert_eq 'inst_outstream' "$(run "(instance? java.io.OutputStream (cljw.internal/__open-output-stream \"$TMP/o.dat\"))")" 'true' - -# --- writer round-trip (write accumulates; close flushes to disk) --- -run "(let [w (cljw.internal/__open-writer \"$TMP/out.txt\")] (.write w \"hello \") (.write w \"world\") (.close w))" >/dev/null -assert_eq 'writer_flush' "$(cat "$TMP/out.txt")" 'hello world' - -# --- open-reader reads the file back --- -assert_eq 'reader_file' "$(run "(cljw.internal/__stream-slurp (cljw.internal/__open-reader \"$TMP/out.txt\"))")" '"hello world"' - -# --- stream-copy reader -> writer (binary transport over []u8) --- -run "(let [in (cljw.internal/__string-reader \"copied!\") out (cljw.internal/__open-writer \"$TMP/copy.txt\")] (cljw.internal/__stream-copy in out) (.close out))" >/dev/null -assert_eq 'stream_copy' "$(cat "$TMP/copy.txt")" 'copied!' - -# --- CRLF: readLine strips \r\n --- -printf 'x\r\ny\r\n' > "$TMP/crlf.txt" -assert_eq 'crlf' "$(run "(let [r (cljw.internal/__open-reader \"$TMP/crlf.txt\")] [(.readLine r) (.readLine r) (.readLine r)])")" '["x" "y" nil]' - -# --- FS-jail: open under a jail root rejects traversal (env scoped to the cmd) --- -assert_eq 'jail_escape' "$(CLJW_FS_ROOT="$TMP" "$BIN" -e '(try (cljw.internal/__open-reader "../../../etc/passwd") (catch Throwable e :blocked))' 2>&1 | tail -n 1)" ':blocked' - -rm -rf "$TMP" -echo "ALL PASS phase16_host_stream" diff --git a/test/e2e/phase16_java_io_file.sh b/test/e2e/phase16_java_io_file.sh deleted file mode 100755 index b48052239..000000000 --- a/test/e2e/phase16_java_io_file.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_java_io_file.sh -# -# ADR-0126 Cycle 1 — java.io.File host type (host_instance shape). -# Query / path / mutation methods over the file_io neutral impl, FS-jail-aware. -# class / instance? / print (opaque) per the Random precedent (AD-020). - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -run() { "$BIN" -e "$1" 2>/dev/null; } - -fail() { echo "FAIL $1" >&2; exit 1; } -assert_eq() { - local name="$1" got="$2" want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -TMP="/tmp/cljw_jio_file_test" -rm -rf "$TMP"; mkdir -p "$TMP/sub" -echo "hello" > "$TMP/a.txt" - -# --- class / instance? / print (opaque, AD-020) --- -assert_eq 'class' "$(run '(class (java.io.File. "/tmp"))')" 'java.io.File' -assert_eq 'instance_pos' "$(run '(instance? java.io.File (java.io.File. "/tmp"))')" 'true' -assert_eq 'instance_neg' "$(run '(instance? java.io.File 5)')" 'false' - -# --- pure path methods (no FS touch); cljw -e prints string results pr-quoted --- -assert_eq 'getName' "$(run '(.getName (java.io.File. "/a/b/c.txt"))')" '"c.txt"' -assert_eq 'getPath' "$(run '(.getPath (java.io.File. "/a/b/c.txt"))')" '"/a/b/c.txt"' -assert_eq 'getParent' "$(run '(.getParent (java.io.File. "/a/b/c.txt"))')" '"/a/b"' -assert_eq 'getParent_nil' "$(run '(.getParent (java.io.File. "x"))')" 'nil' -assert_eq 'isAbsolute_t' "$(run '(.isAbsolute (java.io.File. "/a"))')" 'true' -assert_eq 'isAbsolute_f' "$(run '(.isAbsolute (java.io.File. "a/b"))')" 'false' -assert_eq 'toString' "$(run '(str (java.io.File. "/a/b"))')" '"/a/b"' -assert_eq 'getParentFile' "$(run '(.getName (.getParentFile (java.io.File. "/a/b/c")))')" '"b"' - -# --- getAbsolutePath / getCanonicalPath (D-357, via std.process.currentPathAlloc) --- -assert_eq 'absPath_abs' "$(run '(.getAbsolutePath (java.io.File. "/a/b/c.txt"))')" '"/a/b/c.txt"' -assert_eq 'absPath_rel' "$(run '(.isAbsolute (java.io.File. (.getAbsolutePath (java.io.File. "rel/x"))))')" 'true' -assert_eq 'canon_dotdot' "$(run '(.getCanonicalPath (java.io.File. "/a/./b/../c"))')" '"/a/c"' - -# --- 2-arg ctor (parent + child) --- -assert_eq 'ctor2' "$(run '(.getPath (java.io.File. "/a/b" "c.txt"))')" '"/a/b/c.txt"' -assert_eq 'ctor2_file' "$(run '(.getPath (java.io.File. (java.io.File. "/a") "b"))')" '"/a/b"' - -# --- FS-touch query methods --- -assert_eq 'exists_t' "$(run "(.exists (java.io.File. \"$TMP/a.txt\"))")" 'true' -assert_eq 'exists_f' "$(run "(.exists (java.io.File. \"$TMP/nope\"))")" 'false' -assert_eq 'isFile_t' "$(run "(.isFile (java.io.File. \"$TMP/a.txt\"))")" 'true' -assert_eq 'isFile_dir' "$(run "(.isFile (java.io.File. \"$TMP/sub\"))")" 'false' -assert_eq 'isDir_t' "$(run "(.isDirectory (java.io.File. \"$TMP/sub\"))")" 'true' -assert_eq 'isDir_file' "$(run "(.isDirectory (java.io.File. \"$TMP/a.txt\"))")" 'false' -assert_eq 'length' "$(run "(.length (java.io.File. \"$TMP/a.txt\"))")" '6' -assert_eq 'canRead' "$(run "(.canRead (java.io.File. \"$TMP/a.txt\"))")" 'true' - -# --- mutation methods --- -assert_eq 'mkdir' "$(run "(.mkdir (java.io.File. \"$TMP/d1\"))")" 'true' -assert_eq 'mkdirs' "$(run "(.mkdirs (java.io.File. \"$TMP/d2/d3/d4\"))")" 'true' -assert_eq 'delete' "$(run "(.delete (java.io.File. \"$TMP/d1\"))")" 'true' -assert_eq 'delete_gone' "$(run "(.delete (java.io.File. \"$TMP/never\"))")" 'false' - -# --- listing (sorted for determinism) --- -assert_eq 'list' "$(run "(sort (seq (.list (java.io.File. \"$TMP\"))))")" '("a.txt" "d2" "sub")' - -rm -rf "$TMP" -echo "ALL PASS phase16_java_io_file" diff --git a/test/e2e/phase16_locking.sh b/test/e2e/phase16_locking.sh deleted file mode 100755 index dc57de07f..000000000 --- a/test/e2e/phase16_locking.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_locking.sh -# -# Phase B #6 — `(locking obj body...)`: hold obj's heap-value monitor (ADR-0009 -# lock_state bits + a threadlocal reentrancy held-set, runtime/concurrency/ -# object_monitor.zig — NOT a JVM monitor) for the body, release on exit. The -# surface primitive is `__locking` (lang/primitive/locking.zig); the macro is -# `expandLocking` (lang/macro_transforms.zig). -# -# Also the immediate/nil cases: `(locking )` works (immediates -# share one static monitor — a safe over-serialization vs JVM's per-box -# monitor); `(locking nil …)` errors (clj NPEs). - -set -euo pipefail -cd "$(dirname "$0")/../.." - -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null - -fail() { echo "FAIL $1" >&2; exit 1; } -last_line() { tail -n 1; } -assert_eq() { - local name="$1"; local got="$2"; local want="$3" - [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'" - echo "PASS $name -> $want" -} - -# Single-thread: acquire, run the body, release; the body value is returned. -got=$("$BIN" -e '(locking (atom 0) 42)' 2>/dev/null | last_line) -assert_eq 'locking_basic' "$got" '42' - -# Reentrant: the same thread re-acquires the same object WITHOUT deadlock -# (the threadlocal held-set bumps a depth instead of re-CASing the header). -got=$("$BIN" -e '(let [a (atom 0)] (locking a (locking a 99)))' 2>/dev/null | last_line) -assert_eq 'locking_reentrant' "$got" '99' - -# The body thunk closes over the surrounding lexical env. -got=$("$BIN" -e '(let [a (atom 5) x 10] (locking a (+ x @a)))' 2>/dev/null | last_line) -assert_eq 'locking_body_env' "$got" '15' - -# Mutual exclusion under contention. (reset! a (inc @a)) is a NON-atomic -# read-then-write: without the lock, two threads can read the same @a and both -# write the same value (a lost update), so the total would be < 400. With -# locking serialising the critical section, every increment lands -> exactly 400. -got=$("$BIN" -e '(let [a (atom 0)] (run! deref (mapv (fn [_] (future (dotimes [_ 100] (locking a (reset! a (inc @a)))))) (range 4))) @a)' 2>/dev/null | last_line) -assert_eq 'locking_mutual_exclusion' "$got" '400' - -# PARITY (was AD-014, retired 2026-07-16): locking an immediate works — -# JVM clj locks the boxed value's monitor; cljw immediates share one -# static monitor (a safe over-serialization). nil still errors (clj NPEs). -assert_eq 'locking_immediate' "$("$BIN" -e '(locking 5 42)' 2>/dev/null | last_line)" '42' -diag=$("$BIN" -e '(locking nil 42)' 2>&1 || true) -case "$diag" in - *"cannot lock nil"*) - echo "PASS locking_nil_errors -> diagnostic" ;; - *) - fail "locking_nil_errors: expected the nil diagnostic, got '$diag'" ;; -esac - -# Missing body is a macroexpand-time error. -diag=$("$BIN" -e '(locking (atom 0))' 2>&1 || true) -case "$diag" in - *"locking requires"*|*"body form"*) - echo "PASS locking_no_body_errors -> diagnostic" ;; - *) - fail "locking_no_body_errors: expected a 'requires a body' diagnostic, got '$diag'" ;; -esac - -# The monitor is released even when the body throws (the primitive's defer): a -# subsequent (locking o ...) on the same object must not deadlock. -got=$("$BIN" -e '(let [o (atom 0)] (try (locking o (throw (ex-info "x" {}))) (catch Throwable e :c)) (locking o 99))' 2>/dev/null | last_line) -assert_eq 'locking_releases_on_throw' "$got" '99' - -echo -echo "Phase B #6 locking e2e: all green." diff --git a/test/e2e/phase16_special_file_read.sh b/test/e2e/phase16_special_file_read.sh deleted file mode 100644 index 9dfcf318b..000000000 --- a/test/e2e/phase16_special_file_read.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_special_file_read.sh: a file that stats as size 0 but yields -# bytes (procfs, sysfs, a FIFO) reads to EOF through slurp, io/reader + -# line-seq, and io/file ([CLJW-PROCFS-READ]). Before the fix every one of these -# returned empty: the positional file reader trusted the stat size as a stop. -# -# procfs only exists on Linux, so this is a no-op elsewhere; the unit test in -# src/runtime/file_io.zig covers the shape on every host with a size-0 double. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -fail() { echo "FAIL $1" >&2; exit 1; } - -if [ ! -r /proc/version ]; then - echo "phase16_special_file_read: no /proc on this host, skipped" - exit 0 -fi - -if [ -z "${CLJW_SKIP_BUILD:-}" ] && ! zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null 2>&1; then - fail "zig build failed" -fi - -out="$("$BIN" - <<'EOF' 2>&1 -(println "slurp-loadavg:" (pos? (count (slurp "/proc/loadavg")))) -(println "slurp-version:" (pos? (count (slurp "/proc/version")))) -(println "reader-lines:" (pos? (count (with-open [r (clojure.java.io/reader "/proc/loadavg")] (doall (line-seq r)))))) -(println "io-file:" (pos? (count (slurp (clojure.java.io/file "/proc/version"))))) -(println "DONE") -EOF -)" || fail "probe exited non-zero: -$out" - -grep -q "slurp-loadavg: true" <<<"$out" || fail "slurp on /proc/loadavg came back empty: -$out" -grep -q "slurp-version: true" <<<"$out" || fail "slurp on /proc/version came back empty: -$out" -grep -q "reader-lines: true" <<<"$out" || fail "io/reader + line-seq on /proc/loadavg saw no lines: -$out" -grep -q "io-file: true" <<<"$out" || fail "slurp on (io/file \"/proc/version\") came back empty: -$out" -grep -q "^DONE$" <<<"$out" || fail "probe did not run to completion: -$out" - -echo "Phase 16 / special-file read (size-0 stat, bytes to EOF): all green." diff --git a/test/e2e/phase16_tokenizer_long_input.sh b/test/e2e/phase16_tokenizer_long_input.sh deleted file mode 100755 index 0bf6a6f2b..000000000 --- a/test/e2e/phase16_tokenizer_long_input.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_tokenizer_long_input.sh — untrusted-input DoS guard for the -# tokenizer (found by the 2026-06-09 reader-DoS audit). A single source LINE -# longer than 65535 columns overflowed `Tokenizer.column` (u16 `+= 1`), and a -# single TOKEN longer than 65535 chars overflowed `Token.len` (u16 `@intCast`) — -# both PANIC in the shipped ReleaseSafe build (whole-process crash on e.g. a -# minified one-line JSON/EDN payload). cljw must read large input cleanly, never -# integer-overflow-panic. Layer 2 (e2e CLI) per ADR-0021. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null -fail() { echo "FAIL $1" >&2; exit 1; } - -# (1) A long single LINE (~80k columns > 65535) must not panic. Use a long -# COMMENT so the tokenizer advances its column past 65535 without building a huge -# literal (which would hit a separate VM operand-stack limit, not the tokenizer). -# (`println` because a bare file run is script mode — it does not echo results.) -python3 -c "print(';; ' + 'x'*80000); print('(println 42)')" > /tmp/cljw_longline.$$.clj -trap 'rm -f /tmp/cljw_longline.$$.clj /tmp/cljw_longtok.$$.clj' EXIT -out=$("$BIN" /tmp/cljw_longline.$$.clj 2>&1) || true -grep -qiE "panic|integer overflow" <<<"$out" && fail "long line PANICKED (tokenizer column u16 overflow): $out" -[[ "$out" == "42" ]] || fail "long line wrong/failed result (expected 42): $out" -echo "PASS tok-long-line-no-panic -> $out" - -# (2) A long single TOKEN (string literal > 65535 chars) must not panic and must -# not silently truncate (Token.len must hold the full length). -python3 -c "n=70000; print('(println (count ' + '\"' + 'a'*n + '\"' + '))')" > /tmp/cljw_longtok.$$.clj -out=$("$BIN" /tmp/cljw_longtok.$$.clj 2>&1) || true -grep -qiE "panic|integer overflow" <<<"$out" && fail "long token PANICKED (Token.len u16 overflow): $out" -[[ "$out" == "70000" ]] || fail "long token wrong/truncated count (expected 70000): $out" -echo "PASS tok-long-token-no-panic -> $out" - -echo "OK — phase16_tokenizer_long_input (2 cases) green" diff --git a/test/run_all.sh b/test/run_all.sh index c1bb1008c..90e59dc13 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -686,27 +686,11 @@ run_step "e2e_phase15_use_libspec_filter" "bash test/e2e/phase15_use_libspec_f run_step "e2e_phase15_system_property" "bash test/e2e/phase15_system_property.sh" run_step "e2e_phase15_set_bang" "bash test/e2e/phase15_set_bang.sh" run_step "e2e_phase15_stm_dosync" "bash test/e2e/phase15_stm_dosync.sh" -run_step "e2e_phase16_locking" "bash test/e2e/phase16_locking.sh" -run_step "e2e_phase16_agent" "bash test/e2e/phase16_agent.sh" -run_step "e2e_phase16_concurrency_stress" "bash test/e2e/phase16_concurrency_stress.sh" run_step "e2e_phase16_http_server" "bash test/e2e/phase16_http_server.sh" run_step "e2e_phase16_http_client" "bash test/e2e/phase16_http_client.sh" run_step "e2e_phase16_eval_budget" "bash test/e2e/phase16_eval_budget.sh" -run_step "e2e_phase16_file_io_errors" "bash test/e2e/phase16_file_io_errors.sh" -run_step "e2e_phase16_special_file_read" "bash test/e2e/phase16_special_file_read.sh" run_step "e2e_phase16_repl_classpath" "bash test/e2e/phase16_repl_classpath.sh" -run_step "e2e_phase16_extend_via_metadata" "bash test/e2e/phase16_extend_via_metadata.sh" -run_step "e2e_phase16_fs_jail" "bash test/e2e/phase16_fs_jail.sh" -run_step "e2e_phase16_java_io_file" "bash test/e2e/phase16_java_io_file.sh" -run_step "e2e_phase16_clojure_java_io" "bash test/e2e/phase16_clojure_java_io.sh" -run_step "e2e_phase16_host_stream" "bash test/e2e/phase16_host_stream.sh" run_step "e2e_phase16_stdin_read_line" "bash test/e2e/phase16_stdin_read_line.sh" -run_step "e2e_phase16_clojure_java_io_streams" "bash test/e2e/phase16_clojure_java_io_streams.sh" -run_step "e2e_phase16_clojure_repl" "bash test/e2e/phase16_clojure_repl.sh" -run_step "e2e_phase16_clojure_core_reducers" "bash test/e2e/phase16_clojure_core_reducers.sh" -run_step "e2e_phase16_clojure_java_io_copy" "bash test/e2e/phase16_clojure_java_io_copy.sh" -run_step "e2e_phase16_cljw_json_fs" "bash test/e2e/phase16_cljw_json_fs.sh" -run_step "e2e_phase16_tokenizer_long_input" "bash test/e2e/phase16_tokenizer_long_input.sh" run_step "e2e_bulk_literal" "bash test/e2e/bulk_literal.sh" # Wasm-FFI execution e2e — now part of the DEFAULT full gate (F-001 amended # 2026-06-12: zwasm v2 is complete, so the gate builds `-Dwasm` throughout and @@ -765,7 +749,6 @@ run_step "e2e_phase15_clojure_test_tap" "bash test/e2e/phase15_clojure_test_ run_step "e2e_phase16_vm_error_loc_sidecar" "bash test/e2e/phase16_vm_error_loc_sidecar.sh" run_step "e2e_phase16_gc_torture" "bash test/e2e/phase16_gc_torture.sh" run_step "e2e_phase16_load_file_source" "bash test/e2e/phase16_load_file_source.sh" -run_step "e2e_phase16_bfs_queue_gc" "bash test/e2e/phase16_bfs_queue_gc.sh" run_step "e2e_phase14_cljw_build" "bash test/e2e/phase14_cljw_build.sh" run_step "e2e_phase14_print_family" "bash test/e2e/phase14_print_family.sh" run_step "e2e_phase14_var_special" "bash test/e2e/phase14_var_special.sh" From 597ff9c0c54669c6e841f1f0336e8dce4ea6f602 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 19:52:22 -0300 Subject: [PATCH 05/51] test(e2e): mark phase16 process-boundary scripts, move with-budget to a suite gc_torture, http_client, http_server, load_file_source, repl_classpath, stdin_read_line, vm_error_loc_sidecar, wasm_polyglot and eval_budget each assert something only a fresh cljw process shows (startup env, a bound port, stdin, rendered stderr, exit codes, external toolchains), so they carry the e2e-process-boundary marker with the reason. eval_budget's in-process cljw.eval/with-budget cases move to suites/eval_budget_test. Counted scripts 305 -> 296. Smell-audited: 0: no smells; each marker reason checked against the script body --- scripts/check_e2e_count.sh | 2 +- test/clj/suites/eval_budget_test.clj | 31 ++++++++++++++++++++++++ test/e2e/phase16_eval_budget.sh | 25 +++---------------- test/e2e/phase16_gc_torture.sh | 1 + test/e2e/phase16_http_client.sh | 1 + test/e2e/phase16_http_server.sh | 1 + test/e2e/phase16_load_file_source.sh | 1 + test/e2e/phase16_repl_classpath.sh | 1 + test/e2e/phase16_stdin_read_line.sh | 1 + test/e2e/phase16_vm_error_loc_sidecar.sh | 1 + test/e2e/phase16_wasm_polyglot.sh | 1 + 11 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 test/clj/suites/eval_budget_test.clj diff --git a/scripts/check_e2e_count.sh b/scripts/check_e2e_count.sh index be7438544..ae01b3469 100755 --- a/scripts/check_e2e_count.sh +++ b/scripts/check_e2e_count.sh @@ -23,7 +23,7 @@ # bash 3.2 compatible (the macOS host runs the system bash). set -uo pipefail -BASELINE=305 +BASELINE=296 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../test/e2e}" diff --git a/test/clj/suites/eval_budget_test.clj b/test/clj/suites/eval_budget_test.clj new file mode 100644 index 000000000..bca2c4218 --- /dev/null +++ b/test/clj/suites/eval_budget_test.clj @@ -0,0 +1,31 @@ +;; cljw.eval/with-budget (ADR-0125, D-355 Path A): an in-process scoped budget +;; whose breach is RECOVERED as a value, so a long-lived server survives a +;; runaway eval. The process-global env budgets (CLJW_EVAL_MAX_STEPS / +;; CLJW_EVAL_DEADLINE_MS / CLJW_EVAL_MAX_HEAP_MB) kill the process uncatchably +;; and stay in test/e2e/phase16_eval_budget.sh. +;; +;; Migrated from that script's with-budget cases; marker names kept. +(ns suites.eval-budget-test + (:require [clojure.test :refer [deftest is testing]])) + +(deftest with-budget-success + (is (= 3 (cljw.eval/with-budget {:max-steps 1000000} (fn [] (+ 1 2)))) + "with-budget-success: the thunk's value comes back")) + +(deftest with-budget-recovers-and-survives + (testing "a step breach answers the exhausted marker, not a throw" + (is (= :steps (:cljw.eval/exhausted + (cljw.eval/with-budget {:max-steps 100000} (fn [] (loop [] (recur)))))))) + (testing "a deadline breach answers the exhausted marker" + (is (= :deadline (:cljw.eval/exhausted + (cljw.eval/with-budget {:deadline-ms 100} (fn [] (loop [] (recur)))))))) + (testing "the caller keeps running after a breach" + (is (= 42 (+ 40 2))))) + +(deftest with-budget-heap-recovers + ;; The heap ceiling is measured against the live heap, which in the shared + ;; suite image is already populated: the probe allocates far past any + ;; plausible image size so the cap trips on the allocation itself. + (is (= :heap (:cljw.eval/exhausted + (cljw.eval/with-budget {:max-heap-mb 64} (fn [] (vec (range 100000000)))))) + "with-budget-recovers-and-survives (heap)")) diff --git a/test/e2e/phase16_eval_budget.sh b/test/e2e/phase16_eval_budget.sh index a18fd50c6..cc3dac83f 100755 --- a/test/e2e/phase16_eval_budget.sh +++ b/test/e2e/phase16_eval_budget.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: the CLJW_EVAL_* budgets are process-global env and their breach kills the process uncatchably, so each case asserts a fresh cljw's exit code # test/e2e/phase16_eval_budget.sh — in-process eval execution budget (ADR-0125, # D-351). CLJW_EVAL_MAX_STEPS / CLJW_EVAL_DEADLINE_MS bound an untrusted eval by # back-edge steps / wall-clock ms; expiry is an UNCATCHABLE resource_exhausted @@ -83,25 +84,7 @@ $got" [[ "$got" == "1000" ]] || fail "metered small alloc wrong result: got '$got'" echo "PASS eval-budget-heap-normal-unaffected" -# 9. cljw.eval/with-budget (D-355 Path A): an in-process scoped budget whose -# breach is RECOVERED as a value (not an uncatchable kill), so a long-lived -# server survives a runaway eval. Success returns the thunk value. -got="$("$BIN" -e '(cljw.eval/with-budget {:max-steps 1000000} (fn [] (+ 1 2)))')" || fail "with-budget success errored: -$got" -[[ "$got" == "3" ]] || fail "with-budget success wrong: got '$got'" -echo "PASS with-budget-success" - -# A step / deadline / heap breach inside with-budget returns the exhausted marker -# AND the process exits 0 (survives) — the whole point vs the uncatchable kill. -for probe in \ - ':max-steps 100000} (fn [] (loop [] (recur)))' \ - ':max-heap-mb 16} (fn [] (vec (range 100000000)))'; do - out="$(run_bounded 20 "$BIN" -e "(let [r (cljw.eval/with-budget {$probe)] (println :exhausted (:cljw.eval/exhausted r) :alive (+ 40 2)))" 2>&1)" \ - || fail "with-budget recovery: process did NOT survive (non-zero exit): -$out" - grep -q ":alive 42" <<<"$out" || fail "with-budget recovery: server did not continue after breach: -$out" -done -echo "PASS with-budget-recovers-and-survives" +# The in-process cljw.eval/with-budget cases (a breach recovered as a value) +# live in test/clj/suites/eval_budget_test.clj. -echo "OK — phase16_eval_budget (steps/deadline/heap kill + uncatchable + unmetered + with-budget recovery) green" +echo "OK — phase16_eval_budget (steps/deadline/heap kill + uncatchable + unmetered) green" diff --git a/test/e2e/phase16_gc_torture.sh b/test/e2e/phase16_gc_torture.sh index 134bf0f83..0c09bbc51 100755 --- a/test/e2e/phase16_gc_torture.sh +++ b/test/e2e/phase16_gc_torture.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: CLJW_GC_TORTURE is read at startup and forces a collect at every back-edge for the whole process, so each case needs a fresh cljw # test/e2e/phase16_gc_torture.sh — GC torture regression guard (D-250 / D-251). # # Runs representative programs under CLJW_GC_TORTURE=1 (force a stop-the-world diff --git a/test/e2e/phase16_http_client.sh b/test/e2e/phase16_http_client.sh index 3dba3b4d1..3e2eda1d1 100755 --- a/test/e2e/phase16_http_client.sh +++ b/test/e2e/phase16_http_client.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: starts cljw's HTTP server as a separate background process on a fixed port and drives it from a second cljw # test/e2e/phase16_http_client.sh — cljw's outbound HTTP client (D-257 discharged). # Hermetic localhost round-trip: start cljw's OWN HTTP server (http_server_demo) # as a bg process, then drive it with cljw's OWN http client (cljw.http.client) and diff --git a/test/e2e/phase16_http_server.sh b/test/e2e/phase16_http_server.sh index 3f549bd7e..c5b435cbf 100755 --- a/test/e2e/phase16_http_server.sh +++ b/test/e2e/phase16_http_server.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: starts cljw's HTTP server as a background process and drives it with curl # test/e2e/phase16_http_server.sh — cljw's own HTTP server (ADR-0098). # Starts the Ring demo fixture as a bg process, curls GET + POST, asserts the # routed body + status (POST also guards the discardBody panic regression), then diff --git a/test/e2e/phase16_load_file_source.sh b/test/e2e/phase16_load_file_source.sh index cc181c3f9..80c08d9fb 100755 --- a/test/e2e/phase16_load_file_source.sh +++ b/test/e2e/phase16_load_file_source.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: asserts the source snippet in the CLI's rendered stderr for an uncaught error # test/e2e/phase16_load_file_source.sh: an error in a load-file'd file renders # the snippet of THAT file, at its current text. # diff --git a/test/e2e/phase16_repl_classpath.sh b/test/e2e/phase16_repl_classpath.sh index f5bb2bc03..b0466c05a 100644 --- a/test/e2e/phase16_repl_classpath.sh +++ b/test/e2e/phase16_repl_classpath.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: exercises the three REPL entry points (argv, subcommand, CLJW_PATH) over piped stdin # test/e2e/phase16_repl_classpath.sh — the interactive REPL is classpath-aware: # `(require 'my.lib)` at a REPL prompt resolves `my/lib.clj` off the classpath, # for all THREE REPL entry paths (D-322): `cljw -cp DIR` (flags-but-no-source), diff --git a/test/e2e/phase16_stdin_read_line.sh b/test/e2e/phase16_stdin_read_line.sh index 859ac7cf0..9b5e9d88d 100755 --- a/test/e2e/phase16_stdin_read_line.sh +++ b/test/e2e/phase16_stdin_read_line.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: reads process stdin of the cljw binary through a pipe and a file redirect # test/e2e/phase16_stdin_read_line.sh — `(read-line)` on process stdin. # # The 2026-07-06 user-filed bug: `*in*`'s root was nil (no process-stdin diff --git a/test/e2e/phase16_vm_error_loc_sidecar.sh b/test/e2e/phase16_vm_error_loc_sidecar.sh index 23deb05fe..1beb9a15d 100755 --- a/test/e2e/phase16_vm_error_loc_sidecar.sh +++ b/test/e2e/phase16_vm_error_loc_sidecar.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: asserts the line:col and source window in the CLI's rendered stderr for an uncaught error # test/e2e/phase16_vm_error_loc_sidecar.sh # # ADR-0173 C1 guard: the per-instruction source loc moved from the diff --git a/test/e2e/phase16_wasm_polyglot.sh b/test/e2e/phase16_wasm_polyglot.sh index 7e7adb200..32ed9f863 100644 --- a/test/e2e/phase16_wasm_polyglot.sh +++ b/test/e2e/phase16_wasm_polyglot.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: runs zig cc / rustc / go to rebuild the guests and compares against other Clojure hosts on PATH # test/e2e/phase16_wasm_polyglot.sh — the polyglot demo (docs/examples/polyglot) # is a gated claim, not prose. Two legs: # From a904095c3b6194b79038f1eaad499071bc244a44 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 19:54:16 -0300 Subject: [PATCH 06/51] test(e2e): migrate the wasm component and ffi-catch scripts to suites phase16_wasm_component, _component_multiexport and _component_budget and the catchability half of phase16_wasm_ffi become deftests: wasm_component_test, wasm_component_multiexport_test (the ADR-0135 echo table, one assertion per row), wasm_component_budget_test and wasm_ffi_catch_test. wasm_ffi keeps only the exit-time leak report and is marked a process boundary. Probe fixtures deleted, debt refs repointed. Counted scripts 296 -> 292. Smell-audited: 0: no smells; every probe marker kept as an assertion message --- .dev/debt.yaml | 4 +- scripts/check_e2e_count.sh | 2 +- .../clj/suites/wasm_component_budget_test.clj | 61 ++++++++++ .../wasm_component_multiexport_test.clj | 52 ++++++++ test/clj/suites/wasm_component_test.clj | 84 +++++++++++++ test/clj/suites/wasm_ffi_catch_test.clj | 32 +++++ test/e2e/fixtures/wasm/README_spin.md | 2 +- test/e2e/fixtures/wasm_component_probe.clj | 111 ------------------ test/e2e/fixtures/wasm_ffi_catch.clj | 43 ------- test/e2e/fixtures/wasm_marshalling_table.clj | 47 -------- test/e2e/phase16_wasm_component.sh | 46 -------- test/e2e/phase16_wasm_component_budget.sh | 63 ---------- .../e2e/phase16_wasm_component_multiexport.sh | 57 --------- test/e2e/phase16_wasm_ffi.sh | 16 +-- test/e2e/phase16_wasm_require_component.sh | 2 +- test/run_all.sh | 3 - 16 files changed, 238 insertions(+), 387 deletions(-) create mode 100644 test/clj/suites/wasm_component_budget_test.clj create mode 100644 test/clj/suites/wasm_component_multiexport_test.clj create mode 100644 test/clj/suites/wasm_component_test.clj create mode 100644 test/clj/suites/wasm_ffi_catch_test.clj delete mode 100644 test/e2e/fixtures/wasm_component_probe.clj delete mode 100644 test/e2e/fixtures/wasm_ffi_catch.clj delete mode 100644 test/e2e/fixtures/wasm_marshalling_table.clj delete mode 100755 test/e2e/phase16_wasm_component.sh delete mode 100644 test/e2e/phase16_wasm_component_budget.sh delete mode 100755 test/e2e/phase16_wasm_component_multiexport.sh diff --git a/.dev/debt.yaml b/.dev/debt.yaml index 8be162f0a..22ef742b7 100644 --- a/.dev/debt.yaml +++ b/.dev/debt.yaml @@ -1405,7 +1405,7 @@ active: instant.zig (the existing neutral parser read-instant-date already reuses). last_reviewed: "2026-06-10" - id: "D-404" - status: "MARSHALLING TABLE DISCHARGED 2026-08-04: the fixture blocker (\"the full marshalling-table verification is BLOCKED on a typed component FIXTURE\") is gone — `test/e2e/fixtures/wasm/echo.component.wasm` (16 lifted functions) loads now that zwasm #157 landed, and `phase16_wasm_component_multiexport.sh` asserts 20 rows of the ADR-0135 table in BOTH directions. The RESOURCE phase is also done (ADR-0159 amendment 1: the one-shot `own` lift, the bare-integer parameter hole, and the missing owner check). Remaining: the :cljw/wasm-deps deps.edn coordinate arm. STATUS NOTE 2026-07-02 (audit): the 2026-06-14 BLOCKED-on-REQ-7 freeze below is STALE — the barrier records the blocker resolved 2026-06-21 and Impl A-E landed. True remainder = the :cljw/wasm-deps deps.edn coordinate arm only. ORIGINAL: PARTIAL (W1 first slice landed 2026-06-14): require-a-component WORKS — cljw.wasm/require-component (src/lang/clj/cljw/wasm.clj, wasm-gated bundled ns) interns one Var per component export into an :as namespace via clojure.core/intern + load-component + component-exports + component-call; export-name cleanup (#[constructor]/#[method] markers, pkg:iface/ prefix, resource type prefix stripped). (greeter/greet \"world\") -> \"Hello, world!\"; resource counter ctor/increment/get -> 6. e2e phase16_wasm_require_component.sh (-Dwasm). REMAINING W1 (:refer form DONE 2026-06-14): full WIT<->EDN marshalling table (record/variant/enum/option/result/flags/tuple — today only string/int/float/bool/char + resource handle via component-call); doc/arglists from WIT. W2: deps.edn {:wasm/component …} coords. === PARTIAL (2026-06-13): one-shot CM-API adoption WORKS, instance-caching BLOCKED on zwasm REQ-7. The zwasm-watch fired — all 6 cw CM-API requests landed (zwasm pin 5795c3d0). The experiment (push-suppressed, git stash@{0} 'wasm-component-experiment'; relative-path build.zig.zon) adopted comp.open/Opened + resolveFuncSig/WitType + REQ-2 labels: `(wasm/component-invoke …)` does greet (string→string), typed_payload (record/list/result roundtrip), resource constructor — hand-rolled TypeCtx/Opened deleted, full ADR-0135 value mapping. NEXT (instance caching → require-a-component) is BLOCKED: storing comp.open's Opened in a host heap box breaks resolveFuncSig (Opened not relocatable) → zwasm REQ-7 relayed (private/20260613_handover_from_zwasm/cw_finding_REQ7_opened_heap_stability.md; openBoxed→*Opened or relocatable Opened). Resume: watch that channel for a REQ-7 response → pop stash, re-land instance-caching (code written, in this session's history + note). Full state: private/notes/p14-wasm-component-experiment.md (§ instance caching + Extended challenge). Was: zwasm-WATCH → UNBLOCKING → open. NOTE 2026-06-14: the full marshalling-table verification is BLOCKED on a typed component FIXTURE — only greet (string) + resource_counter exist; record/variant/result need a new .wasm exercising those WIT types (or descope). doc/arglists need WIT introspection on the handle. Until a fixture lands, W1 self-selects to Track S." + status: "MARSHALLING TABLE DISCHARGED 2026-08-04: the fixture blocker (\"the full marshalling-table verification is BLOCKED on a typed component FIXTURE\") is gone — `test/e2e/fixtures/wasm/echo.component.wasm` (16 lifted functions) loads now that zwasm #157 landed, and `test/clj/suites/wasm_component_multiexport_test.clj` asserts 20 rows of the ADR-0135 table in BOTH directions. The RESOURCE phase is also done (ADR-0159 amendment 1: the one-shot `own` lift, the bare-integer parameter hole, and the missing owner check). Remaining: the :cljw/wasm-deps deps.edn coordinate arm. STATUS NOTE 2026-07-02 (audit): the 2026-06-14 BLOCKED-on-REQ-7 freeze below is STALE — the barrier records the blocker resolved 2026-06-21 and Impl A-E landed. True remainder = the :cljw/wasm-deps deps.edn coordinate arm only. ORIGINAL: PARTIAL (W1 first slice landed 2026-06-14): require-a-component WORKS — cljw.wasm/require-component (src/lang/clj/cljw/wasm.clj, wasm-gated bundled ns) interns one Var per component export into an :as namespace via clojure.core/intern + load-component + component-exports + component-call; export-name cleanup (#[constructor]/#[method] markers, pkg:iface/ prefix, resource type prefix stripped). (greeter/greet \"world\") -> \"Hello, world!\"; resource counter ctor/increment/get -> 6. e2e phase16_wasm_require_component.sh (-Dwasm). REMAINING W1 (:refer form DONE 2026-06-14): full WIT<->EDN marshalling table (record/variant/enum/option/result/flags/tuple — today only string/int/float/bool/char + resource handle via component-call); doc/arglists from WIT. W2: deps.edn {:wasm/component …} coords. === PARTIAL (2026-06-13): one-shot CM-API adoption WORKS, instance-caching BLOCKED on zwasm REQ-7. The zwasm-watch fired — all 6 cw CM-API requests landed (zwasm pin 5795c3d0). The experiment (push-suppressed, git stash@{0} 'wasm-component-experiment'; relative-path build.zig.zon) adopted comp.open/Opened + resolveFuncSig/WitType + REQ-2 labels: `(wasm/component-invoke …)` does greet (string→string), typed_payload (record/list/result roundtrip), resource constructor — hand-rolled TypeCtx/Opened deleted, full ADR-0135 value mapping. NEXT (instance caching → require-a-component) is BLOCKED: storing comp.open's Opened in a host heap box breaks resolveFuncSig (Opened not relocatable) → zwasm REQ-7 relayed (private/20260613_handover_from_zwasm/cw_finding_REQ7_opened_heap_stability.md; openBoxed→*Opened or relocatable Opened). Resume: watch that channel for a REQ-7 response → pop stash, re-land instance-caching (code written, in this session's history + note). Full state: private/notes/p14-wasm-component-experiment.md (§ instance caching + Extended challenge). Was: zwasm-WATCH → UNBLOCKING → open. NOTE 2026-06-14: the full marshalling-table verification is BLOCKED on a typed component FIXTURE — only greet (string) + resource_counter exist; record/variant/result need a new .wasm exercising those WIT types (or descope). doc/arglists need WIT introspection on the handle. Until a fixture lands, W1 self-selects to Track S." category: "wasm" barrier: |- **BLOCKER DISSOLVED (2026-06-21).** zwasm's Component-Model embedding API is now @@ -7338,7 +7338,7 @@ discharged: last_reviewed: "2026-08-04" refs: |- test/e2e/fixtures/wasm/README_echo.md (provenance + rebuild recipe + - root-cause note); test/e2e/phase16_wasm_component_multiexport.sh; + root-cause note); test/clj/suites/wasm_component_multiexport_test.clj; zwasm D-527; D-404 (the fixture blocker this discharges half of); ADR-0135 (the mapping table the fixture exists to falsify). - id: "D-569" diff --git a/scripts/check_e2e_count.sh b/scripts/check_e2e_count.sh index ae01b3469..c206a9ec5 100755 --- a/scripts/check_e2e_count.sh +++ b/scripts/check_e2e_count.sh @@ -23,7 +23,7 @@ # bash 3.2 compatible (the macOS host runs the system bash). set -uo pipefail -BASELINE=296 +BASELINE=292 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../test/e2e}" diff --git a/test/clj/suites/wasm_component_budget_test.clj b/test/clj/suites/wasm_component_budget_test.clj new file mode 100644 index 000000000..2c766a28a --- /dev/null +++ b/test/clj/suites/wasm_component_budget_test.clj @@ -0,0 +1,61 @@ +;; Caller-controlled component budgets (:fuel, :max-memory-pages), and the fuel +;; diagnostic on both call paths: a budget kill must NAME the budget, since +;; "trapped" would read as a guest bug. Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/phase16_wasm_component_budget.sh; its check labels +;; are the assertion messages. +(ns suites.wasm-component-budget-test + (:require [clojure.test :refer [deftest is testing]])) + +(def ^:private spin-component "test/e2e/fixtures/wasm/spin_component.wasm") +(def ^:private spin-core "test/e2e/fixtures/wasm/spin.wasm") +(def ^:private limits {:fuel 1000 :max-memory-pages 2}) + +(defn- fuel-error? [f] + (try (f) false + (catch Throwable t (boolean (re-find #"fuel budget" (ex-message t)))))) + +(defn- rejected? [opts] + (try (wasm/load-component spin-component opts) false (catch Throwable _ true))) + +(deftest component-fuel-budget + (is (= 42 (wasm/component-call (wasm/load-component spin-component) "ok")) + "default-compatible") + (let [h (wasm/load-component spin-component limits)] + (is (= 42 (wasm/component-call h "ok")) "limited-control") + (is (fuel-error? #(wasm/component-call h "spin")) "fuel-terminates-spin-and-says-so") + (is (fuel-error? #(wasm/component-call h "ok")) "exhausted-instance-stays-exhausted")) + (let [h (wasm/load-component spin-component limits)] + (is (= 10 (wasm/component-call h "burn" 10)) "fresh-instance-control") + (is (loop [remaining 200] + (cond (zero? remaining) false + (fuel-error? #(wasm/component-call h "burn" 10)) true + :else (recur (dec remaining)))) + "fuel-cumulative-across-calls") + (is (fuel-error? #(wasm/component-call h "ok")) "cumulative-exhaustion-persists")) + (is (= 42 (wasm/component-call (wasm/load-component spin-component {:fuel 0 :max-memory-pages 2}) "ok")) + "explicit-unmetered")) + +(deftest component-memory-budget + (let [h (wasm/load-component spin-component limits)] + (is (= 1 (wasm/component-call h "grow" 1)) "grow-within-cap") + (is (= -1 (wasm/component-call h "grow" 1)) "grow-refused-at-cap"))) + +(deftest component-budget-options-validated + (is (rejected? 1) "invalid-options") + (is (rejected? {:fuel "1000"}) "invalid-fuel") + (is (rejected? {:max-memory-pages "2"}) "invalid-memory") + (is (rejected? {:engine :jit}) "unsupported-engine") + (is (rejected? {:timeout-ms 1}) "unsupported-deadline")) + +(deftest core-module-shares-the-fuel-diagnostic + (let [m (wasm/load spin-core {:fuel 1000})] + (is (= 42 (wasm/call m "ok")) "module-control") + (is (try (wasm/call m "spin") false + (catch Throwable t + (boolean (re-find #"'spin' exhausted the module's fuel budget" (ex-message t))))) + "module-fuel-names-export")) + (testing "module-genuine-trap-is-still-a-trap" + (is (try (wasm/call (wasm/load "docs/examples/wasm/trap.wasm") "boom") false + (catch Throwable t (and (not (re-find #"fuel" (ex-message t))) + (boolean (re-find #"trapped" (ex-message t))))))))) diff --git a/test/clj/suites/wasm_component_multiexport_test.clj b/test/clj/suites/wasm_component_multiexport_test.clj new file mode 100644 index 000000000..f91508481 --- /dev/null +++ b/test/clj/suites/wasm_component_multiexport_test.clj @@ -0,0 +1,52 @@ +;; D-567 / D-404: a component with more than one export loads (zwasm rejected +;; any component with >= 2 exports with InvalidSort until zwasm #157), and the +;; ADR-0135 WIT <-> Clojure marshalling table round-trips through the +;; 16-export `echo` fixture. Every row is an ECHO, so a passing case proves +;; lower AND lift agree: a one-directional check would pass on two mistakes +;; that cancel. Provenance of the fixtures: test/e2e/fixtures/wasm/README_echo.md. +;; Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/phase16_wasm_component_multiexport.sh and its +;; wasm_marshalling_table.clj fixture. +(ns suites.wasm-component-multiexport-test + (:require [clojure.test :refer [deftest is testing]])) + +(def ^:private fx "test/e2e/fixtures/wasm/") +(def ^:private echo (str fx "echo.component.wasm")) + +(deftest multi-export-components-load + (is (= 1 (count (wasm/component-exports (str fx "greet_component.wasm")))) + "single-export-control") + (is (= 2 (count (wasm/component-exports (str fx "two_export_component.wasm")))) + "two-export-loads: the minimal D-567 reproduction") + (is (= 16 (count (wasm/component-exports echo))) + "typed-fixture-lists-all-exports")) + +(defmacro ^:private echoes [f & vs] + `(do ~@(for [v vs] `(is (= ~v (wasm/component-invoke echo ~f ~v)) (str ~f " " (pr-str ~v)))))) + +(deftest adr0135-marshalling-table-round-trips + (testing "scalars" + (echoes "echo-bool" true) + (echoes "echo-s32" -7) + (echoes "echo-u64" 12345) + (echoes "echo-f32" 0.5) + (echoes "echo-f64" 1.5)) + (testing "char is a 21-bit code point, so an astral-plane scalar binds (ADR-0135 am2)" + (echoes "echo-char" \a (char 0x10437))) + (testing "string: the first row needing linear memory + cabi_realloc" + (echoes "echo-string" "hi")) + (testing "enum -> keyword (a case NAME, not an ordinal)" + (echoes "echo-colour" :green)) + (testing "option -> nil | v" + (echoes "echo-option-u32" 9 nil)) + (testing "list -> vector; flags -> set of keywords; tuple -> vector; record -> map" + (echoes "echo-list-u32" [1 2 3]) + (echoes "echo-perms" #{:read :exec}) + (echoes "echo-tuple" [7 "x"]) + (echoes "echo-pair" {:n 3 :label "p"})) + (testing "result and variant as tagged vectors (their LOWER side raised until 2026-08-04)" + (echoes "echo-result" [:ok 7] [:err "boom"]) + (echoes "echo-shape" [:circle 1.5] [:square 3])) + (testing "a payload-less variant case is [:tag], not [:tag nil]" + (echoes "echo-shape" [:point]))) diff --git a/test/clj/suites/wasm_component_test.clj b/test/clj/suites/wasm_component_test.clj new file mode 100644 index 000000000..fad142bc8 --- /dev/null +++ b/test/clj/suites/wasm_component_test.clj @@ -0,0 +1,84 @@ +;; cljw <-> wasm COMPONENT boundary (D-404 / ADR-0135): value marshalling, the +;; cached-handle lifetime (REQ-7 instance caching), resource chains, and every +;; error staying a catchable cljw exception (no exit-70 crash). The engine is +;; zwasm's concern; this is the Clojure usage path. Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/phase16_wasm_component.sh and its probe fixture; +;; the probe's PASS markers are kept as assertion messages. +(ns suites.wasm-component-test + (:require [clojure.string :as str] + [clojure.test :refer [deftest is testing]])) + +(def ^:private greet "test/e2e/fixtures/wasm/greet_component.wasm") +(def ^:private counter "test/e2e/fixtures/wasm/resource_counter.wasm") + +(defn- caught? [f] (try (f) false (catch Throwable _ true))) + +(defn- counter-exports + "The counter's ctor / increment / get export strings, DISCOVERED via + component-exports rather than hard-coded." + [] + (let [ex (wasm/component-exports counter) + find (fn [needle] (some #(when (str/includes? (:name %) needle) (:name %)) ex))] + {:ctor (find "[constructor]counter") + :incr (find "[method]counter.increment") + :getf (find "[method]counter.get")})) + +(deftest component-invoke-and-exports + (is (= "Hello, zwasm!" (wasm/component-invoke greet "greet" "zwasm")) + "component-invoke-greet: string -> string marshalling") + (let [ex (wasm/component-exports greet)] + (is (some #(= "greet" (:name %)) ex) "component-exports") + (is (= "string" (:result (first (filter #(= "greet" (:name %)) ex)))) "component-exports"))) + +(deftest load-component-handle-reuse + (let [c (wasm/load-component greet)] + (is (= "Hello, zwasm!" (wasm/component-call c "greet" "zwasm"))) + (is (= "Hello, again!" (wasm/component-call c "greet" "again")) + "the opened component outlives its load buffer and survives across calls")) + (is (= "Hello, handle!" (wasm/component-invoke (wasm/load-component greet) "greet" "handle")) + "component-invoke-accepts-handle: it used to refuse a handle as 'the module path must be a string'")) + +(deftest resource-chain + (let [{:keys [ctor incr getf]} (counter-exports) + c (wasm/load-component counter) + h (wasm/component-call c ctor 5)] + (wasm/component-call c incr h) + (is (= 6 (wasm/component-call c getf h)) "counter ctor(5) -> increment -> get"))) + +(deftest one-shot-resource-outlives-call + ;; A one-shot invoke returning a resource keeps its component alive, because + ;; the handle names something inside it (ADR-0159 am1). This USED to return a + ;; bare integer into a table torn down one line later. + (let [{:keys [ctor incr getf]} (counter-exports) + h (wasm/component-invoke counter ctor 5)] + (is (not (number? h)) "one-shot ctor returned a raw handle number") + (is (= 5 (wasm/component-call h getf h)) + "a resource names its own component, so its methods are reachable through it") + (wasm/component-call h incr h) + (System/gc) + (is (= 6 (wasm/component-call h getf h)) "the resource survives a collection (GC-ROOT §H)") + (wasm/resource-drop h) + (is (caught? #(wasm/component-call h getf h)) "use after drop is catchable"))) + +(deftest resource-handle-is-bound-to-its-component + ;; Handle numbers are dense per-instance table indices starting at 1, so a + ;; foreign handle does not fail on its own: it names a DIFFERENT live resource. + (let [{:keys [ctor getf]} (counter-exports) + a (wasm/component-invoke counter ctor 5) + c (wasm/load-component counter) + b (wasm/component-call c ctor 9)] + (is (= 5 (wasm/component-call a getf a))) + (is (= 9 (wasm/component-call c getf b))) + (is (caught? #(wasm/component-call c getf a)) + "without the owner check this returned 9, c's resource 1, for a's handle") + (is (caught? #(wasm/component-call a getf b))) + (testing "a raw handle number is not a resource (a negative one was a ReleaseSafe panic)" + (is (caught? #(wasm/component-call c getf 1))) + (is (caught? #(wasm/component-call c getf -1)))))) + +(deftest component-errors-are-catchable + (is (caught? #(wasm/component-invoke "/no/such/file.wasm" "greet" "x")) "bad-path") + (is (caught? #(wasm/component-invoke greet "no-such-export" "x")) "missing-export") + (is (caught? #(wasm/component-call "not-a-handle" "greet" "x")) "component-call-on-non-handle") + (is (caught? #(wasm/load-component 42)) "bad-arg-type-to-load")) diff --git a/test/clj/suites/wasm_ffi_catch_test.clj b/test/clj/suites/wasm_ffi_catch_test.clj new file mode 100644 index 000000000..984896b01 --- /dev/null +++ b/test/clj/suites/wasm_ffi_catch_test.clj @@ -0,0 +1,32 @@ +;; FIX-4 wasm-error taxonomy: every wasm/load and wasm/call failure is a +;; CATCHABLE cljw exception, never an internal_error / exit 70. Several cases +;; pin the SPECIFIC host class so the Kind mapping (value_error -> +;; IllegalArgumentException, type_error -> ClassCastException, arity_error -> +;; ArityException) is locked, not just "some Throwable". Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/fixtures/wasm_ffi_catch.clj (driven by +;; phase16_wasm_ffi.sh); the probe's line labels are the assertion messages. +(ns suites.wasm-ffi-catch-test + (:require [clojure.test :refer [deftest is]])) + +(def ^:private add-wasm "docs/examples/wasm/add.wasm") + +(deftest wasm-call-errors-are-catchable + (let [m (wasm/load add-wasm)] + (is (= 42 (wasm/call m "add" 2 40)) "control: add(2,40)") + (is (thrown? Throwable (wasm/call m "add" 5000000000 0)) "out-of-range") + (is (thrown? IllegalArgumentException (wasm/call m "add" 5000000000 0)) "out-of-range-iae") + (is (thrown? ClassCastException (wasm/call m "add" "x" 0)) "not-a-number") + (is (thrown? Throwable (wasm/call m "nope" 1 2)) "unknown-export") + (is (thrown? ArityException (wasm/call m "add" 1)) "wrong-arity"))) + +(deftest wasm-load-errors-are-catchable + (is (thrown? Throwable (wasm/load add-wasm {:fuel "x"})) "bad-opts") + (is (thrown? IllegalArgumentException (wasm/load add-wasm {:wasi {}})) + "wasi-load-split: :wasi belongs to wasm/run, not to a module handle") + (is (thrown? IllegalArgumentException (wasm/load add-wasm {:wasi nil})) + "wasi-load-nil-split: a present nil :wasi is not treated as absent") + (is (thrown? ClassCastException (wasm/load 42)) "bad-path")) + +(deftest wasm-trap-is-catchable + (is (thrown? Throwable (wasm/call (wasm/load "docs/examples/wasm/trap.wasm") "boom")) "trap")) diff --git a/test/e2e/fixtures/wasm/README_spin.md b/test/e2e/fixtures/wasm/README_spin.md index 805ce34ee..3d5ba1eac 100644 --- a/test/e2e/fixtures/wasm/README_spin.md +++ b/test/e2e/fixtures/wasm/README_spin.md @@ -56,7 +56,7 @@ WebAssembly component exhausted its fuel budget; the handle stays exhausted, ... A genuine guest fault (`docs/examples/wasm/trap.wasm`, divide by zero) still reads `trapped`, which is what lets an operator tell a guest bug from a budget. -The no-build regression is `test/e2e/phase16_wasm_component_budget.sh`. +The no-build regression is `test/clj/suites/wasm_component_budget_test.clj`. Set `CLJW_BIN` to the binary to verify. It places its own outer process timeout around the fixture, so a broken fuel implementation cannot hang the test runner. That test timeout is not a component runtime feature. diff --git a/test/e2e/fixtures/wasm_component_probe.clj b/test/e2e/fixtures/wasm_component_probe.clj deleted file mode 100644 index dbe8f446f..000000000 --- a/test/e2e/fixtures/wasm_component_probe.clj +++ /dev/null @@ -1,111 +0,0 @@ -;; cljw↔wasm COMPONENT boundary probe (D-404 / ADR-0135). The wasm engine itself -;; is zwasm's concern; this verifies the CLOJURE→wasm-component usage path: value -;; marshalling, the cached-handle lifetime (REQ-7 instance caching), resource -;; chains, every happy path, and that every error stays a CATCHABLE cljw exception -;; (no exit-70 crash). Fixtures are copied from zwasm's component test corpus. -(def greet "test/e2e/fixtures/wasm/greet_component.wasm") -(def counter "test/e2e/fixtures/wasm/resource_counter.wasm") - -;; --- happy: one-shot typed invoke (string → string marshalling) --- -(let [r (wasm/component-invoke greet "greet" "zwasm")] - (assert (= "Hello, zwasm!" r) (pr-str r)) - (println "PASS component-invoke-greet")) - -;; --- happy: exports listing returns {:name :params :result} maps --- -(let [ex (wasm/component-exports greet)] - (assert (some #(= "greet" (:name %)) ex) (pr-str ex)) - (assert (= "string" (:result (first (filter #(= "greet" (:name %)) ex)))) (pr-str ex)) - (println "PASS component-exports")) - -;; --- happy: cached handle, reuse across multiple calls (REQ-7 instance caching — -;; the opened component outlives its load buffer + survives across calls) --- -(let [c (wasm/load-component greet) - a (wasm/component-call c "greet" "zwasm") - b (wasm/component-call c "greet" "again")] - (assert (= "Hello, zwasm!" a) (pr-str a)) - (assert (= "Hello, again!" b) (pr-str b)) - (println "PASS load-component-handle-reuse")) - -;; --- happy: resource chain (ctor own-handle → method borrow), counter 5 → 6. -;; Export strings are DISCOVERED via component-exports (not hard-coded). --- -(let [ex (wasm/component-exports counter) - find (fn [needle] (some #(when (clojure.string/includes? (:name %) needle) (:name %)) ex)) - ctor (find "[constructor]counter") - incr (find "[method]counter.increment") - getf (find "[method]counter.get") - c (wasm/load-component counter) - h (wasm/component-call c ctor 5) - _ (wasm/component-call c incr h) - g (wasm/component-call c getf h)] - (assert (= 6 g) (str "counter ctor(5)→increment→get expected 6, got " (pr-str g))) - (println "PASS resource-chain")) - -;; --- one-shot `component-invoke` returning a resource: the component outlives -;; the call, because the handle names something inside it (ADR-0159 am1). -;; This USED to return a bare integer into a table torn down one line later. --- -(let [ex (wasm/component-exports counter) - find (fn [needle] (some #(when (clojure.string/includes? (:name %) needle) (:name %)) ex)) - ctor (find "[constructor]counter") - incr (find "[method]counter.increment") - getf (find "[method]counter.get") - h (wasm/component-invoke counter ctor 5)] - (assert (not (number? h)) (str "one-shot ctor returned a raw handle number: " (pr-str h))) - ;; A resource names its own component, so methods are reachable without any - ;; other handle to that instance — the instance has no other name. - (assert (= 5 (wasm/component-call h getf h))) - (wasm/component-call h incr h) - ;; Survives a collection: the wrapper roots its component (GC-ROOT §H). - (System/gc) - (assert (= 6 (wasm/component-call h getf h)) "one-shot resource did not survive a GC") - (wasm/resource-drop h) - (assert (= "CAUGHT" (try (wasm/component-call h getf h) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT")))) - (println "PASS one-shot-resource-outlives-call")) - -;; --- a resource handle is bound to the component that minted it. Handle numbers -;; are dense per-instance table indices starting at 1, so a foreign handle -;; does not fail on its own — it names a DIFFERENT live resource. --- -(let [ex (wasm/component-exports counter) - find (fn [needle] (some #(when (clojure.string/includes? (:name %) needle) (:name %)) ex)) - ctor (find "[constructor]counter") - getf (find "[method]counter.get") - a (wasm/component-invoke counter ctor 5) - c (wasm/load-component counter) - b (wasm/component-call c ctor 9)] - (assert (= 5 (wasm/component-call a getf a))) - (assert (= 9 (wasm/component-call c getf b))) - ;; Without the owner check this returned 9 (c's resource 1) for a's handle. - (assert (= "CAUGHT" (try (wasm/component-call c getf a) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT")))) - (assert (= "CAUGHT" (try (wasm/component-call a getf b) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT")))) - ;; A raw handle number is not a resource. It used to be @intCast'd straight - ;; into the table — unchecked, and a negative one was a ReleaseSafe panic. - (assert (= "CAUGHT" (try (wasm/component-call c getf 1) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT")))) - (assert (= "CAUGHT" (try (wasm/component-call c getf -1) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT")))) - (println "PASS resource-handle-is-bound-to-its-component")) - -;; --- problematic boundary cases: every error stays a CATCHABLE cljw exception --- -(println "bad-path:" - (try (wasm/component-invoke "/no/such/file.wasm" "greet" "x") "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "missing-export:" - (try (wasm/component-invoke greet "no-such-export" "x") "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "component-call-on-non-handle:" - (try (wasm/component-call "not-a-handle" "greet" "x") "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "bad-arg-type-to-load:" - (try (wasm/load-component 42) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) - -(println "DONE") - -;; --- happy: component-invoke also takes a load-component handle (it used to -;; refuse it as "the module path must be a string") and reuses it --- -(let [c (wasm/load-component greet) - r (wasm/component-invoke c "greet" "handle")] - (assert (= "Hello, handle!" r) (pr-str r)) - (println "PASS component-invoke-accepts-handle")) diff --git a/test/e2e/fixtures/wasm_ffi_catch.clj b/test/e2e/fixtures/wasm_ffi_catch.clj deleted file mode 100644 index 064c2623d..000000000 --- a/test/e2e/fixtures/wasm_ffi_catch.clj +++ /dev/null @@ -1,43 +0,0 @@ -;; FIX-4 wasm-error taxonomy: every wasm-surface error is a CATCHABLE cljw -;; exception (not an internal_error / exit 70). Each case evaluates a failing -;; wasm/load or wasm/call inside a (catch …); the body returns "NOT-CAUGHT" when -;; no throw happened, so the e2e asserts every line says CAUGHT and the process -;; stays exit-0. A couple of cases pin the SPECIFIC host class so the Kind -;; mapping (value_error→IllegalArgumentException, type_error→ClassCastException, -;; arity_error→ArityException) is locked, not just "some Throwable". -(def m (wasm/load "docs/examples/wasm/add.wasm")) - -(println "out-of-range:" - (try (wasm/call m "add" 5000000000 0) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "out-of-range-iae:" - (try (wasm/call m "add" 5000000000 0) "NOT-CAUGHT" - (catch IllegalArgumentException _ "CAUGHT"))) -(println "not-a-number:" - (try (wasm/call m "add" "x" 0) "NOT-CAUGHT" - (catch ClassCastException _ "CAUGHT"))) -(println "unknown-export:" - (try (wasm/call m "nope" 1 2) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "wrong-arity:" - (try (wasm/call m "add" 1) "NOT-CAUGHT" - (catch ArityException _ "CAUGHT"))) -(println "bad-opts:" - (try (wasm/load "docs/examples/wasm/add.wasm" {:fuel "x"}) "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) -(println "wasi-load-split:" - (try (wasm/load "docs/examples/wasm/add.wasm" {:wasi {}}) "NOT-CAUGHT" - (catch IllegalArgumentException _ "CAUGHT"))) -(println "wasi-load-nil-split:" - (try (wasm/load "docs/examples/wasm/add.wasm" {:wasi nil}) "NOT-CAUGHT" - (catch IllegalArgumentException _ "CAUGHT"))) -(println "bad-path:" - (try (wasm/load 42) "NOT-CAUGHT" - (catch ClassCastException _ "CAUGHT"))) - -(def t (wasm/load "docs/examples/wasm/trap.wasm")) -(println "trap:" - (try (wasm/call t "boom") "NOT-CAUGHT" - (catch Throwable _ "CAUGHT"))) - -(println "DONE") diff --git a/test/e2e/fixtures/wasm_marshalling_table.clj b/test/e2e/fixtures/wasm_marshalling_table.clj deleted file mode 100644 index 8a11e09bc..000000000 --- a/test/e2e/fixtures/wasm_marshalling_table.clj +++ /dev/null @@ -1,47 +0,0 @@ -;; test/e2e/fixtures/wasm_marshalling_table.clj — the ADR-0135 WIT<->Clojure -;; mapping table, driven through the 16-export `echo` component fixture. -;; -;; Every row is an ECHO, so a passing case proves lower AND lift agree: a value -;; that survives the round-trip was encoded into the canonical ABI and decoded -;; back. A one-directional check would pass on two mistakes that cancel. -;; -;; Run by test/e2e/phase16_wasm_component_multiexport.sh. Prints MISMATCH lines -;; (the runner greps for them) and TABLE-DONE on completion. -(def p "test/e2e/fixtures/wasm/echo.component.wasm") -(defn ck [f in expect] - (let [got (wasm/component-invoke p f in)] - (when-not (= got expect) - (println "MISMATCH" f "in=" (pr-str in) "got=" (pr-str got) "want=" (pr-str expect))))) - -;; scalars -(ck "echo-bool" true true) -(ck "echo-s32" -7 -7) -(ck "echo-u64" 12345 12345) -(ck "echo-f32" 0.5 0.5) -(ck "echo-f64" 1.5 1.5) -;; char is a 21-bit code point in cljw, so an astral-plane scalar binds here -;; where a JVM 16-bit Character cannot (ADR-0135 amendment 2). -(ck "echo-char" \a \a) -(ck "echo-char" (char 0x10437) (char 0x10437)) -;; string — the first row needing linear memory + cabi_realloc -(ck "echo-string" "hi" "hi") -;; enum -> keyword (a case NAME, not an ordinal) -(ck "echo-colour" :green :green) -;; option -> nil | v -(ck "echo-option-u32" 9 9) -(ck "echo-option-u32" nil nil) -;; list -> vector; flags -> set of keywords; tuple -> vector; record -> map -(ck "echo-list-u32" [1 2 3] [1 2 3]) -(ck "echo-perms" #{:read :exec} #{:read :exec}) -(ck "echo-tuple" [7 "x"] [7 "x"]) -(ck "echo-pair" {:n 3 :label "p"} {:n 3 :label "p"}) -;; the two sum types, as the tagged vectors ADR-0135 am2 settled. These are the -;; rows whose LOWER side raised feature_not_supported until 2026-08-04, so the -;; shapes the lift produced could not be handed back in. -(ck "echo-result" [:ok 7] [:ok 7]) -(ck "echo-result" [:err "boom"] [:err "boom"]) -(ck "echo-shape" [:circle 1.5] [:circle 1.5]) -(ck "echo-shape" [:square 3] [:square 3]) -;; a payload-less variant case is [:tag], not [:tag nil] -(ck "echo-shape" [:point] [:point]) -(println "TABLE-DONE") diff --git a/test/e2e/phase16_wasm_component.sh b/test/e2e/phase16_wasm_component.sh deleted file mode 100755 index 40d0380c0..000000000 --- a/test/e2e/phase16_wasm_component.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_wasm_component.sh — cljw↔wasm COMPONENT boundary (D-404 / ADR-0135). -# The wasm engine is zwasm's concern; this exercises the CLOJURE→wasm-component -# usage path: value marshalling, the cached-handle lifetime (REQ-7 instance -# caching), resource chains, every happy path, and that every error stays a -# catchable cljw exception (no exit-70 crash). -# -# OPT-IN, like phase16_wasm_ffi.sh / phase16_wasm_run.sh: it builds `-Dwasm` and -# is NOT in the default per-commit gate. During the local-accumulation phase the -# The zwasm dep resolves from the tag pin in build.zig.zon (fetched on any -# host), so this is not Mac-local. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -fail() { echo "FAIL $1" >&2; exit 1; } - -# Honor the gate's shared-binary contract (CLJW_SKIP_BUILD): skip our own build to -# avoid clobbering the shared binary mid-run. Standalone, build -Dwasm ReleaseSafe. -if [ -z "${CLJW_SKIP_BUILD:-}" ] && ! zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null 2>&1; then - fail "zig build -Dwasm failed (zwasm dep unresolved? check the build.zig.zon pin)" -fi -"$BIN" --version | grep -q wasm || fail "cljw is not wasm-enabled ($("$BIN" --version)) — zwasm did not resolve" - -out="$("$BIN" test/e2e/fixtures/wasm_component_probe.clj 2>&1)" || fail "cljw exited non-zero: -$out" - -for marker in \ - "PASS component-invoke-greet" \ - "PASS component-exports" \ - "PASS load-component-handle-reuse" \ - "PASS component-invoke-accepts-handle" \ - "PASS resource-chain" \ - "PASS one-shot-resource-outlives-call" \ - "PASS resource-handle-is-bound-to-its-component"; do - grep -q "$marker" <<<"$out" || fail "missing: $marker -$out" -done - -# Every error path must be CAUGHT (no NOT-CAUGHT, no exit-70 crash above). -grep -q "NOT-CAUGHT" <<<"$out" && fail "a component error escaped (catch …): -$out" -grep -q "^DONE$" <<<"$out" || fail "fixture did not run to completion: -$out" - -echo -echo "Phase 16 / wasm component boundary: all green." diff --git a/test/e2e/phase16_wasm_component_budget.sh b/test/e2e/phase16_wasm_component_budget.sh deleted file mode 100644 index 8aeec6992..000000000 --- a/test/e2e/phase16_wasm_component_budget.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# Caller-controlled component budgets, and the fuel diagnostic on both call -# paths. Uses an existing binary; never builds. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN=${CLJW_BIN:-zig-out/bin/cljw} -[[ -x "$BIN" ]] || { echo "Missing cljw binary: $BIN" >&2; exit 2; } -# An outer bound makes a broken fuel implementation fail without hanging CI. -# timeout -> gtimeout -> unbounded: hosted macOS runners ship neither GNU -# timeout nor coreutils gtimeout (same idiom as phase16_gc_torture.sh). -run_bounded() { local s="$1"; shift; if command -v timeout >/dev/null 2>&1; then timeout "$s" "$@"; elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$s" "$@"; else "$@"; fi; } -run_bounded 20 "$BIN" - <<'CLJ' -(def path "test/e2e/fixtures/wasm/spin_component.wasm") -(def core-path "test/e2e/fixtures/wasm/spin.wasm") -(defn check [label value] - (when-not value (throw (ex-info (str "FAIL " label) {}))) - (println "PASS" label)) -(defn rejected? [opts] - (try (wasm/load-component path opts) false (catch Throwable _ true))) -;; A budget kill must name the budget: "trapped" would read as a guest bug. -(defn fuel-error? [f] - (try (f) false - (catch Throwable t (boolean (re-find #"fuel budget" (ex-message t)))))) -(defn throws? [f] (try (f) false (catch Throwable _ true))) - -(check "default-compatible" (= 42 (wasm/component-call (wasm/load-component path) "ok"))) -(def h (wasm/load-component path {:fuel 1000 :max-memory-pages 2})) -(check "limited-control" (= 42 (wasm/component-call h "ok"))) -(check "fuel-terminates-spin-and-says-so" (fuel-error? #(wasm/component-call h "spin"))) -(check "exhausted-instance-stays-exhausted" (fuel-error? #(wasm/component-call h "ok"))) -(def hb (wasm/load-component path {:fuel 1000 :max-memory-pages 2})) -(check "fresh-instance-control" (= 10 (wasm/component-call hb "burn" 10))) -(check "fuel-cumulative-across-calls" - (loop [remaining 200] - (if (zero? remaining) - false - (if (fuel-error? #(wasm/component-call hb "burn" 10)) - true - (recur (dec remaining)))))) -(check "cumulative-exhaustion-persists" (fuel-error? #(wasm/component-call hb "ok"))) -(def hm (wasm/load-component path {:fuel 1000 :max-memory-pages 2})) -(check "grow-within-cap" (= 1 (wasm/component-call hm "grow" 1))) -(check "grow-refused-at-cap" (= -1 (wasm/component-call hm "grow" 1))) -(check "invalid-options" (rejected? 1)) -(check "invalid-fuel" (rejected? {:fuel "1000"})) -(check "invalid-memory" (rejected? {:max-memory-pages "2"})) -(check "unsupported-engine" (rejected? {:engine :jit})) -(check "unsupported-deadline" (rejected? {:timeout-ms 1})) -(check "explicit-unmetered" (= 42 (wasm/component-call (wasm/load-component path {:fuel 0 :max-memory-pages 2}) "ok"))) - -;; The core-module path (wasm/load + wasm/call) shares the diagnostic, and its -;; message names the export. -(def m (wasm/load core-path {:fuel 1000})) -(check "module-control" (= 42 (wasm/call m "ok"))) -(check "module-fuel-names-export" - (try (wasm/call m "spin") false - (catch Throwable t (boolean (re-find #"'spin' exhausted the module's fuel budget" (ex-message t)))))) -(check "module-genuine-trap-is-still-a-trap" - (try (wasm/call (wasm/load "docs/examples/wasm/trap.wasm") "boom") false - (catch Throwable t (and (not (re-find #"fuel" (ex-message t))) - (boolean (re-find #"trapped" (ex-message t))))))) -(println "component budgets: passed") -CLJ diff --git a/test/e2e/phase16_wasm_component_multiexport.sh b/test/e2e/phase16_wasm_component_multiexport.sh deleted file mode 100755 index 84f2b3f5b..000000000 --- a/test/e2e/phase16_wasm_component_multiexport.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_wasm_component_multiexport.sh — D-567 / D-404. -# -# cljw's headline feature is "require a Wasm component as a namespace". A -# namespace with one function is not a namespace, and until 2026-08-04 nobody -# had checked: both component fixtures then in the tree (greet, -# resource_counter) export exactly one function, so a component with TWO -# exports had never been loaded. It did not load — zwasm rejected any component -# with >= 2 exports with `InvalidSort`, engine-side (zwasm D-527, fixed in -# zwasm #157). -# -# This file used to PIN that gap. It now asserts what the gap was hiding: the -# ADR-0135 marshalling table, driven through the 16-export typed fixture in -# BOTH directions. That is what discharges D-404's "BLOCKED on a typed -# component FIXTURE" — the fixture exists and every row it covers runs here. -# -# Provenance + rebuild recipe for the fixtures: fixtures/wasm/README_echo.md. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null -fail() { echo "FAIL $1" >&2; exit 1; } - -F=test/e2e/fixtures/wasm -[ -f "$F/echo.component.wasm" ] || fail "the typed fixture is missing" -[ -f "$F/two_export_component.wasm" ] || fail "the minimal reproduction is missing" - -# The single-export control, kept: it is what made the original failure legible -# as being about export COUNT rather than about components in general. -got="$("$BIN" -e '(println (count (wasm/component-exports "test/e2e/fixtures/wasm/greet_component.wasm")))' 2>&1 | sed -n 1p)" -[[ "$got" == "1" ]] || fail "single-export control did not load: got '$got'" -echo "PASS single-export-control -> $got" - -# Two exports — the minimal reproduction of the engine bug. -got="$("$BIN" -e '(println (count (wasm/component-exports "test/e2e/fixtures/wasm/two_export_component.wasm")))' 2>&1 | sed -n 1p)" -[[ "$got" == "2" ]] || fail "two-export component did not list both exports: got '$got'" -echo "PASS two-export-loads -> $got" - -# Sixteen exports — the typed fixture. An exact count, so a regression that -# silently DROPS exports (rather than failing outright) is caught too. -got="$("$BIN" -e '(println (count (wasm/component-exports "test/e2e/fixtures/wasm/echo.component.wasm")))' 2>&1 | sed -n 1p)" -[[ "$got" == "16" ]] || fail "typed fixture: expected 16 exports, got '$got'" -echo "PASS typed-fixture-lists-all-exports -> $got" - -# --- the ADR-0135 marshalling table, both directions ------------------------- -# -# Every row is an ECHO, so a passing case proves lower AND lift agree: a value -# that survives the round-trip was encoded into the canonical ABI and decoded -# back. A one-directional check would pass on two mistakes that cancel. -out="$("$BIN" test/e2e/fixtures/wasm_marshalling_table.clj 2>&1 || true)" -grep -q "MISMATCH" <<<"$out" && fail "ADR-0135 marshalling table round-trip mismatch: -$out" -grep -q "^TABLE-DONE$" <<<"$out" || fail "the marshalling table did not run to completion: -$out" -echo "PASS adr0135-marshalling-table-round-trips (20 rows, both directions)" - -echo "OK — phase16_wasm_component_multiexport (4 cases) green — D-567 + D-404's fixture blocker discharged" diff --git a/test/e2e/phase16_wasm_ffi.sh b/test/e2e/phase16_wasm_ffi.sh index 63d207953..b88f3a3ad 100755 --- a/test/e2e/phase16_wasm_ffi.sh +++ b/test/e2e/phase16_wasm_ffi.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# e2e-process-boundary: the leak guard reads the allocator's leak report, which a cljw process prints only at its own exit # test/e2e/phase16_wasm_ffi.sh — polyglot Wasm FFI smoke + leak guard (ADR-0099, # D-259 (b)). Builds the `-Dwasm` binary (which resolves zwasm via the relative- # path build.zig.zon), loads a Zig→Wasm module, calls an export, and asserts BOTH @@ -51,17 +52,8 @@ echo "PASS wasm-ffi-no-leak -> 0 leaks" # (NOT an internal_error / exit 70). The fixture wraps each failing wasm/load + # wasm/call in a (catch …); if any error were uncatchable the process would exit # 70 here (non-zero), and a mismatched Kind would print NOT-CAUGHT. -catch_out="$("$BIN" test/e2e/fixtures/wasm_ffi_catch.clj 2>&1)" || fail "catchability fixture exited non-zero (an error escaped (catch …) → exit 70): -$catch_out" -grep -q "NOT-CAUGHT" <<<"$catch_out" && fail "a wasm error was not caught (or matched the wrong host class): -$catch_out" -grep -q "^wasi-load-split: CAUGHT$" <<<"$catch_out" || fail "wasm/load accepted or misclassified :wasi instead of enforcing the command/handle split: -$catch_out" -grep -q "^wasi-load-nil-split: CAUGHT$" <<<"$catch_out" || fail "wasm/load treated a present nil :wasi as absent: -$catch_out" -grep -q "^DONE$" <<<"$catch_out" || fail "catchability fixture did not run to completion: -$catch_out" -echo "PASS wasm-ffi-catchable -> all errors caught, exit 0" +# The catchability cases (every wasm/load and wasm/call error is a catchable +# exception of the right host class) live in test/clj/suites/wasm_ffi_catch_test.clj. echo -echo "Phase 16 / wasm FFI smoke + leak guard + catchability: all green." +echo "Phase 16 / wasm FFI smoke + leak guard: all green." diff --git a/test/e2e/phase16_wasm_require_component.sh b/test/e2e/phase16_wasm_require_component.sh index 8abf49759..90655ca8a 100644 --- a/test/e2e/phase16_wasm_require_component.sh +++ b/test/e2e/phase16_wasm_require_component.sh @@ -5,7 +5,7 @@ # clojure.core/intern). Exercises: :as ns creation, export-name cleanup # (#[constructor]/#[method] markers stripped), cached-handle reuse, resource chain. # -# OPT-IN, like phase16_wasm_component.sh: builds `-Dwasm` and is NOT in the default +# OPT-IN, like phase16_wasm_ffi.sh: builds `-Dwasm` and is NOT in the default # per-commit gate. The zwasm dep resolves from # the tag pin in build.zig.zon (fetched on any host). set -euo pipefail diff --git a/test/run_all.sh b/test/run_all.sh index 90e59dc13..b5b2ce96a 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -705,11 +705,9 @@ run_step "e2e_phase16_wasm_run" "bash test/e2e/phase16_wasm_run.sh" # instruction writes is the guest's choice, so only a capture cap bounds host # memory. Measured pre-fix: 64 MB per 1e6 fuel, ~6.4 GB RSS at 1e8. run_step "e2e_phase16_wasm_run_output_cap" "bash test/e2e/phase16_wasm_run_output_cap.sh" -run_step "e2e_phase16_wasm_component" "bash test/e2e/phase16_wasm_component.sh" run_step "e2e_phase16_wasm_require_component" "bash test/e2e/phase16_wasm_require_component.sh" # Caller-set :fuel / :max-memory-pages on wasm/load-component, and the fuel # diagnostic on both call paths (a budget kill must not read as a guest trap). -run_step "e2e_phase16_wasm_component_budget" "bash test/e2e/phase16_wasm_component_budget.sh" # docs/examples/polyglot: the C / Zig / Rust / Go guests the README shows, run # as committed AND rebuilt from their source headers (each toolchain on PATH), # plus hosts.cljc on every Clojure host present. A README claim with a gate. @@ -797,7 +795,6 @@ run_step "e2e_phase14_extend_abstract_base" "bash test/e2e/phase14_extend_abstr run_step "e2e_phase14_deftype_imeta" "bash test/e2e/phase14_deftype_imeta.sh" run_step "e2e_phase14_core_surface_protocols" "bash test/e2e/phase14_core_surface_protocols.sh" run_step "e2e_phase14_file_var" "bash test/e2e/phase14_file_var.sh" -run_step "e2e_phase16_wasm_component_multiexport" "bash test/e2e/phase16_wasm_component_multiexport.sh" run_step "e2e_phase14_deftype_iseq" "bash test/e2e/phase14_deftype_iseq.sh" run_step "e2e_phase14_deftype_overload_arity" "bash test/e2e/phase14_deftype_overload_arity.sh" run_step "e2e_phase14_deftype_indexed" "bash test/e2e/phase14_deftype_indexed.sh" From 9ebdf3f6e97262d9841f66fb1840937e6ccbbfa1 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 19:55:40 -0300 Subject: [PATCH 07/51] test(e2e): migrate wasm_memory and wasm_engine_select to suites The linear-memory probe and the engine-selection probe become wasm_memory_test and wasm_engine_select_test, one assertion per probe label. scripts/run_wasm_gate.sh now also runs every wasm_*_test suite so the wasm-only quick path keeps its coverage. Counted scripts 292 -> 290. Smell-audited: 0: no smells; run_wasm_gate exercised against the suites --- .dev/zwasm_capabilities.md | 2 +- data/feature_deps.yaml | 2 +- scripts/check_e2e_count.sh | 2 +- scripts/run_wasm_gate.sh | 16 ++- test/clj/suites/wasm_engine_select_test.clj | 59 +++++++++++ test/clj/suites/wasm_memory_test.clj | 92 ++++++++++++++++ test/e2e/fixtures/wasm/jit_engine_select.clj | 77 -------------- test/e2e/fixtures/wasm_memory_probe.clj | 81 -------------- test/e2e/phase16_wasm_engine_select.sh | 106 ------------------- test/e2e/phase16_wasm_memory.sh | 94 ---------------- test/run_all.sh | 2 - 11 files changed, 168 insertions(+), 365 deletions(-) create mode 100644 test/clj/suites/wasm_engine_select_test.clj create mode 100644 test/clj/suites/wasm_memory_test.clj delete mode 100644 test/e2e/fixtures/wasm/jit_engine_select.clj delete mode 100644 test/e2e/fixtures/wasm_memory_probe.clj delete mode 100755 test/e2e/phase16_wasm_engine_select.sh delete mode 100755 test/e2e/phase16_wasm_memory.sh diff --git a/.dev/zwasm_capabilities.md b/.dev/zwasm_capabilities.md index 2de7b0328..b28531809 100644 --- a/.dev/zwasm_capabilities.md +++ b/.dev/zwasm_capabilities.md @@ -229,7 +229,7 @@ have surfaced it. and pinned the tag (`build.zig.zon` `.zwasm` = tag URL + hash, `.lazy = true`), then flipped `engine.LoadOpts.engine` default `.interp`→`.auto` (removed the PROVISIONAL marker + emptied feature_deps#runtime/cljw/wasm/engine_default, same commit): a no-opts `(wasm/load path)` now - rides zwasm's JIT-first engine. e2e `phase16_wasm_engine_select.sh` extended — the no-opts + rides zwasm's JIT-first engine. e2e `phase16_wasm_engine_select.sh` (now suites/wasm_engine_select_test.clj) extended — the no-opts default executes a SIMD body only the JIT can run (`default-simd: 42`), proving the flip. Components stay interp-pinned on the zwasm side (D-500), so the component path is unaffected and the F-012 diff oracle (explicit `.interp`/`.jit`) is untouched. `to_cljw_09` CONSUMED. diff --git a/data/feature_deps.yaml b/data/feature_deps.yaml index 7ff32411a..c82896039 100644 --- a/data/feature_deps.yaml +++ b/data/feature_deps.yaml @@ -116,7 +116,7 @@ entries: components stay interp-pinned on the zwasm side (D-500) so the component path is unaffected, and the F-012 diff oracle uses the explicit `.interp`/`.jit` forms so it is untouched. Verified + locked - by the surface e2e (phase16_wasm_engine_select.sh — the no-opts + by the surface suite (test/clj/suites/wasm_engine_select_test.clj — the no-opts default now runs a SIMD body that only JIT can execute) and the engine.zig dual-engine unit test. Discharges cljw D-488. kind: runtime_layer diff --git a/scripts/check_e2e_count.sh b/scripts/check_e2e_count.sh index c206a9ec5..99c79b71e 100755 --- a/scripts/check_e2e_count.sh +++ b/scripts/check_e2e_count.sh @@ -23,7 +23,7 @@ # bash 3.2 compatible (the macOS host runs the system bash). set -uo pipefail -BASELINE=292 +BASELINE=290 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../test/e2e}" diff --git a/scripts/run_wasm_gate.sh b/scripts/run_wasm_gate.sh index fc721b2d5..4a52c5a47 100755 --- a/scripts/run_wasm_gate.sh +++ b/scripts/run_wasm_gate.sh @@ -4,7 +4,7 @@ # Since F-001 was amended 2026-06-12 (zwasm v2 complete), the DEFAULT # `test/run_all.sh` gate ALSO covers the wasm e2e (builds `-Dwasm` throughout, # ADR-0133) — so this is no longer the *only* wasm coverage. Keep it as a fast -# wasm-only path: build `-Dwasm` + run just the two wasm e2e, without the full +# wasm-only path: build `-Dwasm` + run just the wasm e2e and wasm suites, without the full # ~250s gate, when iterating on the wasm surface. # # It builds the `-Dwasm` ReleaseSafe binary ONCE (ADR-0132: never a bare @@ -40,7 +40,7 @@ zig-out/bin/cljw --version | grep -q 'wasm' || { export CLJW_SKIP_BUILD=1 CLJW_OPT=ReleaseSafe rc=0 -for s in phase16_wasm_ffi phase16_wasm_memory phase16_wasm_run; do +for s in phase16_wasm_ffi phase16_wasm_run; do echo "==> $s" if bash "test/e2e/$s.sh"; then echo " [pass] $s" @@ -50,6 +50,18 @@ for s in phase16_wasm_ffi phase16_wasm_memory phase16_wasm_run; do fi done +# The in-process wasm surface is deftests (test/clj/suites/wasm_*_test.clj); +# run just those namespaces against the same binary. +wasm_suites=$(cd test/clj/suites && ls wasm_*_test.clj | sed 's/_test\.clj$/-test/; s/_/-/g; s/^/suites./') +echo "==> wasm suites" +# shellcheck disable=SC2086 +if zig-out/bin/cljw -cp test/clj -M test/clj/run_suites.clj $wasm_suites; then + echo " [pass] wasm suites" +else + echo " [FAIL] wasm suites" >&2 + rc=1 +fi + if [ "$rc" -eq 0 ]; then echo "WASM GATE: all green" else diff --git a/test/clj/suites/wasm_engine_select_test.clj b/test/clj/suites/wasm_engine_select_test.clj new file mode 100644 index 000000000..f53b6d76b --- /dev/null +++ b/test/clj/suites/wasm_engine_select_test.clj @@ -0,0 +1,59 @@ +;; Per-instance zwasm engine selection (zwasm ADR-0200 JIT adoption): +;; `(wasm/load path {:engine :jit|:interp|:auto})` + `wasm/call`. A GPR export +;; is identical jit == interp (the F-012 differential discipline applied to +;; engine choice); a SIMD body runs on the JIT and traps catchably on interp +;; (SIMD is JIT-only, to_cljw_03); the no-opts default rides :auto = JIT +;; (D-488), proven by a SIMD body only the JIT can run. Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/phase16_wasm_engine_select.sh and its +;; jit_engine_select.clj fixture; the probe's labels are the messages. +(ns suites.wasm-engine-select-test + (:require [clojure.string :as str] + [clojure.test :refer [deftest is testing]])) + +(def ^:private fx "test/e2e/fixtures/wasm/") +(def ^:private simd (str fx "jit_simd.wasm")) + +(defn- on [path engine] (wasm/load (str fx path) {:engine engine})) + +(deftest gpr-and-simd-by-engine + (let [jit (wasm/load simd {:engine :jit}) + interp (wasm/load simd {:engine :interp})] + (is (= 5 (wasm/call jit "add" 2 3)) "add-jit") + (is (= 5 (wasm/call interp "add" 2 3)) "add-interp") + (is (= 42 (wasm/call jit "lane0")) "lane0-jit: SIMD through the exportFuncSig JIT arm") + (is (thrown? Throwable (wasm/call interp "lane0")) "lane0-interp: TRAPPED catchably"))) + +(deftest no-opts-default-rides-the-jit + (is (= 5 (wasm/call (wasm/load simd) "add" 2 3)) "default") + (is (= 42 (wasm/call (wasm/load simd) "lane0")) + "default-simd: an interp default would TRAP on the SIMD body")) + +(deftest invoke-shape-marshalling + (testing "multi-value result -> vector, 2-arg f64 -> double, jit == interp" + (is (= [3 2] (wasm/call (on "jit_shapes.wasm" :jit) "divmod" 17 5)) "divmod-jit") + (is (= [3 2] (wasm/call (on "jit_shapes.wasm" :interp) "divmod" 17 5)) "divmod-interp") + (is (= 3.75 (wasm/call (on "jit_shapes.wasm" :interp) "addf" 1.5 2.25)) "addf-interp") + (is (= 3.75 (wasm/call (on "jit_shapes.wasm" :jit) "addf" 1.5 2.25)) "addf-jit")) + (testing "mixed (i32,f64)->f64 (zwasm @3cf40a573)" + (is (= 5.5 (wasm/call (on "mix.wasm" :jit) "mix" 3 2.5)) "mix-jit") + (is (= 5.5 (wasm/call (on "mix.wasm" :interp) "mix" 3 2.5)) "mix-interp")) + (testing "3-arg (f64,f64,f64)->f64 via the generic buffer path" + (is (== 7 (wasm/call (on "three_f64.wasm" :jit) "sum3" 1.5 2.5 3.0)) "sum3-jit") + (is (== 7 (wasm/call (on "three_f64.wasm" :interp) "sum3" 1.5 2.5 3.0)) "sum3-interp")) + (is (= 70 (wasm/call (on "simd_dot.wasm" :jit) "simd_dot")) + "simd-dot-jit: i32x4.mul (1,2,3,4)*(5,6,7,8) summed")) + +(deftest engine-truth-at-the-surface + ;; ADR-0196: wasm/engine reports the selection a handle was loaded with, and + ;; a known-gap trap names the export, signature, engine and remedy. + (is (= :jit (wasm/engine (wasm/load simd {:engine :jit}))) "engine-jit") + (is (= :interp (wasm/engine (wasm/load simd {:engine :interp}))) "engine-interp") + (is (= :auto (wasm/engine (wasm/load simd))) "engine-default") + (let [va (str fx "void_arity.wasm") + msg (try (wasm/call (wasm/load va) "gpr4_void" 1 2 3 4) "RAN" + (catch Throwable e (ex-message e)))] + (is (str/includes? msg "'gpr4_void' (i32 i32 i32 i32) -> () trapped on the auto (JIT-first) engine") + "void4-default names the export, signature and engine (D-585)") + (is (str/includes? msg "load the module with {:engine :interp}") "void4-default carries the remedy") + (is (nil? (wasm/call (wasm/load va {:engine :interp}) "gpr4_void" 1 2 3 4)) "void4-interp: RAN"))) diff --git a/test/clj/suites/wasm_memory_test.clj b/test/clj/suites/wasm_memory_test.clj new file mode 100644 index 000000000..9de09350a --- /dev/null +++ b/test/clj/suites/wasm_memory_test.clj @@ -0,0 +1,92 @@ +;; The (ptr,len) linear-memory surface (ADR-0192, arrays ADR-0204): the host +;; fills the buffer a guest's pointer names and reads back what the guest +;; wrote; every element type round-trips through the wasm memory model +;; (little-endian, unaligned-tolerant, i64 exact past the i48 window); every +;; failure is a catchable exception naming the fn the user wrote, never a host +;; panic on caller data. Needs the -Dwasm binary. +;; +;; Migrated from test/e2e/phase16_wasm_memory.sh and its probe fixture; the +;; probe's line labels are the assertion messages. +(ns suites.wasm-memory-test + (:require [clojure.string :as str] + [clojure.test :refer [deftest is testing]])) + +(def ^:private kernel "test/e2e/fixtures/wasm/mem_kernel.wasm") + +(defn- message [f] + (try (f) "NOT-CAUGHT" (catch Exception e (.getMessage e)))) + +(defmacro ^:private raises [label prefix form] + `(is (str/includes? (message (fn [] ~form)) ~prefix) ~label)) + +(deftest wasm-memory-roundtrip + (let [m (wasm/load kernel)] + (is (= 65536 (wasm/mem-size m)) "size: the memory answers in BYTES, not pages") + (is (= 4 (wasm/mem-write! m :f64 0 [1.0 2.0 3.0 4.0])) "wrote") + (is (= 10.0 (wasm/call m "sum_f64" 0 4)) "sum: the guest read the host's buffer") + (is (= 25.0 (wasm/call m "scale_f64" 0 4 2.5)) "newsum") + (is (= [2.5 5.0 7.5 10.0] (wasm/mem-read m :f64 0 4)) "scaled: the host read the guest's write"))) + +(deftest wasm-memory-dtypes + (let [m (wasm/load kernel)] + (wasm/mem-write! m :f64 64 [1.5]) + (is (= [0 0 0 0 0 0 248 63] (wasm/mem-read m :u8 64 8)) "as-u8: little-endian bytes") + (testing "wasm-memory-i64-exact: 2^53+1 is not an f64, so equality proves no float" + (wasm/mem-write! m :i64 128 [9007199254740993 -1]) + (is (= [9007199254740993 -1] (wasm/mem-read m :i64 128 2)) "i64") + (is (= 9007199254740993 (first (wasm/mem-read m :i64 128 1))) "i64-exact")) + (wasm/mem-write! m :u32 200 [4294967295]) + (is (= [4294967295] (wasm/mem-read m :u32 200 1)) "u32") + (is (= [-1] (wasm/mem-read m :i32 200 1)) "i32 over the same bytes") + (wasm/mem-write! m :f64 301 [7.25]) + (is (= [7.25] (wasm/mem-read m :f64 301 1)) "unaligned"))) + +(deftest wasm-memory-arrays + (let [m (wasm/load kernel)] + (is (= 4 (wasm/mem-write! m :f64 0 (double-array [1 2 3 4]))) "arr-wrote") + (is (= 20.0 (wasm/call m "scale_f64" 0 4 2.0)) "arr-newsum") + (let [d (wasm/mem-read-array m :f64 0 4)] + (is (= [2.0 4.0 6.0 8.0] (vec d)) "arr-f64") + (is (= :double (cljw.internal/__array-kind d)) "arr-f64 answers a typed array")) + (doseq [[dt arr] [[:i8 (byte-array [-1 2 127])] + [:i16 (short-array [-300 2 32767])] + [:i32 (int-array [-70000 2 2147483647])] + [:i64 (long-array [9007199254740993 -1 2])] + [:f32 (float-array [1.5 -2.25 3])] + [:f64 (double-array [1.5 -2.25 3])]]] + (wasm/mem-write! m dt 1000 arr) + (is (= (vec arr) (vec (wasm/mem-read-array m dt 1000 3))) (str "arr-rt " dt))) + (wasm/mem-write! m :u16 2000 [65535]) + (wasm/mem-write! m :u32 2008 [4294967295]) + (is (= [65535] (vec (wasm/mem-read-array m :u16 2000 1))) "arr-unsigned u16") + (is (= [4294967295] (vec (wasm/mem-read-array m :u32 2008 1))) "arr-unsigned u32") + (wasm/mem-write! m :f64 3000 (int-array [5 6])) + (is (= [5.0 6.0] (wasm/mem-read m :f64 3000 2)) + "arr-convert: a kind that is not the dtype's layout converts element by element") + (raises "arr-object" "wasm/mem-write!: the data argument must be a vector of numbers" + (wasm/mem-write! m :f64 0 (object-array 1))) + (raises "arr-range" "wasm/mem-write!: element 1 is outside this element type's range" + (wasm/mem-write! m :i8 0 (int-array [1 300]))) + (raises "arr-oob" "wasm/mem-read-array: 1 element(s) at byte offset 65530" + (wasm/mem-read-array m :f64 65530 1)))) + +(deftest wasm-memory-catchable + (let [m (wasm/load kernel)] + (raises "no-memory" "wasm/mem-size: this module exports no linear memory" + (wasm/mem-size (wasm/load "docs/examples/wasm/add.wasm"))) + (raises "bad-handle" "wasm/mem-size: the first argument must be a loaded wasm module" + (wasm/mem-size 42)) + (raises "bad-dtype" "wasm/mem-read: the element type must be one of" (wasm/mem-read m :f16 0 1)) + (raises "bad-index" "wasm/mem-read: the byte offset and the element count must both be integers" + (wasm/mem-read m :f64 1.5 1)) + (raises "oob" "wasm/mem-read: 1 element(s) at byte offset 65530" (wasm/mem-read m :f64 65530 1)) + (raises "negative" "wasm/mem-read: 1 element(s) at byte offset -8" (wasm/mem-read m :f64 -8 1)) + (raises "overflow" "wasm/mem-read: 9223372036854775807 element(s)" + (wasm/mem-read m :f64 0 9223372036854775807)) + (raises "bad-data" "wasm/mem-write!: the data argument must be a vector of numbers" + (wasm/mem-write! m :f64 0 "nope")) + (raises "bad-element" "wasm/mem-write!: element 1 is not a number" (wasm/mem-write! m :i32 0 [1 :x 3])) + (raises "element-range" "wasm/mem-write!: element 1 is outside this element type's range" + (wasm/mem-write! m :u8 0 [1 300])) + (raises "element-fraction" "wasm/mem-write!: element 0 is not an integer" + (wasm/mem-write! m :i32 0 [1.5])))) diff --git a/test/e2e/fixtures/wasm/jit_engine_select.clj b/test/e2e/fixtures/wasm/jit_engine_select.clj deleted file mode 100644 index 83ee4f974..000000000 --- a/test/e2e/fixtures/wasm/jit_engine_select.clj +++ /dev/null @@ -1,77 +0,0 @@ -;; ADR-0200 JIT-engine adoption — cljw selects the zwasm engine per-instance via -;; `(wasm/load path {:engine :jit/:interp/:auto})`. The module exports a GPR `add` -;; (multi-arg) and a SIMD-body `lane0` (i32x4.extract_lane on a v128.const → 42; -;; SIMD executes JIT-compiled and crosses the scalar boundary). -;; -;; Asserts: (1) the GPR export is byte-identical under :jit and :interp — the F-012 -;; differential discipline applied to engine choice; (2) the SIMD export runs on -;; :jit → 42 (end-to-end through wasm/call, which reads exportFuncSig — the JIT arm -;; zwasm shipped @5b6449779 / from_cljw_02); (3) the SIMD export traps a CATCHABLE -;; error on :interp (SIMD is JIT-only in zwasm — confirmed intentional, to_cljw_03); -;; (4) the no-opts default rides :auto = JIT (D-488 flipped 2026-06-22; zwasm -;; v2.0.0-alpha.3 re-landed .auto→JIT): the default GPR add works AND a SIMD body — -;; which ONLY the JIT can execute — returns 42 with NO :engine opt, proving the -;; default is JIT-first (an interp default would TRAP on the SIMD body). -(def w "test/e2e/fixtures/wasm/jit_simd.wasm") - -(def jit (wasm/load w {:engine :jit})) -(def interp (wasm/load w {:engine :interp})) - -(println "add-jit:" (wasm/call jit "add" 2 3)) -(println "add-interp:" (wasm/call interp "add" 2 3)) -(println "lane0-jit:" (wasm/call jit "lane0")) -(println "lane0-interp:" - (try (wasm/call interp "lane0") "RAN" - (catch Throwable _ "TRAPPED"))) -(println "default:" (wasm/call (wasm/load w) "add" 2 3)) -;; The no-opts default now rides :auto = JIT. lane0 is a SIMD (v128) body that ONLY -;; the JIT can execute (interp traps), so a no-opts load returning 42 proves the -;; default flipped to JIT-first (D-488, zwasm v2.0.0-alpha.3). -(println "default-simd:" (wasm/call (wasm/load w) "lane0")) - -;; JIT invoke-shape marshalling at the surface (to_cljw_02 matrix): a multi-value -;; (>1 scalar) result returns a cljw vector, and a 2-arg f64 FP-bank param/result -;; returns a cljw double — both byte-identical jit==interp. zwasm @d7da97e04 fixed the -;; 2-arg×FP-bank JIT dispatch (from_cljw_03 repro → to_cljw_04). Wider :jit gaps remain -;; (multi-result-with-FP, 3+-arg FP, v128 boundary) — not exercised here. -(def shapes "test/e2e/fixtures/wasm/jit_shapes.wasm") -(def sj (wasm/load shapes {:engine :jit})) -(def si (wasm/load shapes {:engine :interp})) -(println "divmod-jit:" (wasm/call sj "divmod" 17 5)) -(println "divmod-interp:" (wasm/call si "divmod" 17 5)) -(println "addf-interp:" (wasm/call si "addf" 1.5 2.25)) -(println "addf-jit:" (wasm/call sj "addf" 1.5 2.25)) - -;; Mixed-bank 2-arg: (i32,f64)→f64. zwasm @3cf40a573 made the 1/2-arg JIT invoke matrix -;; complete (the per-combo veneer falls through to the generic buffer thunk), so a mixed -;; i32+f64 param shape now runs JIT-compiled — byte-identical jit==interp. -(def mj (wasm/load "test/e2e/fixtures/wasm/mix.wasm" {:engine :jit})) -(def mi (wasm/load "test/e2e/fixtures/wasm/mix.wasm" {:engine :interp})) -(println "mix-jit:" (wasm/call mj "mix" 3 2.5)) -(println "mix-interp:" (wasm/call mi "mix" 3 2.5)) - -;; 3-arg FP via the generic buffer path (beyond the 2-arg veneer fast-path) — locks -;; the (f64,f64,f64)→f64 shape jit==interp (to_cljw_05: 3-arg confirmed via buffer). -(println "sum3-jit:" (wasm/call (wasm/load "test/e2e/fixtures/wasm/three_f64.wasm" {:engine :jit}) "sum3" 1.5 2.5 3.0)) -(println "sum3-interp:" (wasm/call (wasm/load "test/e2e/fixtures/wasm/three_f64.wasm" {:engine :interp}) "sum3" 1.5 2.5 3.0)) - -;; Real SIMD arithmetic on the JIT: i32x4.mul (1,2,3,4)*(5,6,7,8) = (5,12,21,32), -;; horizontal sum = 70 (not just a const lane extract). JIT-only (interp traps). -(println "simd-dot-jit:" (wasm/call (wasm/load "test/e2e/fixtures/wasm/simd_dot.wasm" {:engine :jit}) "simd_dot")) - -;; ADR-0196: engine truth at the surface. `wasm/engine` reports the selection a -;; handle was loaded with, and a trap names the export, its signature, the -;; engine and the remedy when the shape is a known gap. `gpr4_void` is the D-585 -;; discriminator: a zero-result arity-4 export the JIT cannot dispatch, so it -;; traps on the default and runs on :interp. -(println "engine-jit:" (wasm/engine jit)) -(println "engine-interp:" (wasm/engine interp)) -(println "engine-default:" (wasm/engine (wasm/load w))) -(def va "test/e2e/fixtures/wasm/void_arity.wasm") -(println "void4-default:" - (try (wasm/call (wasm/load va) "gpr4_void" 1 2 3 4) "RAN" - (catch Throwable e (ex-message e)))) -(println "void4-interp:" - (try (wasm/call (wasm/load va {:engine :interp}) "gpr4_void" 1 2 3 4) "RAN" - (catch Throwable e (ex-message e)))) -(println "DONE") diff --git a/test/e2e/fixtures/wasm_memory_probe.clj b/test/e2e/fixtures/wasm_memory_probe.clj deleted file mode 100644 index 59a5b1d57..000000000 --- a/test/e2e/fixtures/wasm_memory_probe.clj +++ /dev/null @@ -1,81 +0,0 @@ -;; Linear-memory surface fixture (ADR-0192). Exercises the (ptr,len) calling -;; convention end to end: the host fills a buffer, the guest reads it, the guest -;; writes it back, the host reads the result. Asserted by -;; test/e2e/phase16_wasm_memory.sh. -(def m (wasm/load "test/e2e/fixtures/wasm/mem_kernel.wasm")) - -;; The memory answers in BYTES (one 64 KiB page here), not in pages. -(println "size" (wasm/mem-size m)) - -;; Round trip through the guest: write 4 doubles, have the guest sum them, -;; have the guest scale them in place, read the scaled buffer back. -(println "wrote" (wasm/mem-write! m :f64 0 [1.0 2.0 3.0 4.0])) -(println "sum" (wasm/call m "sum_f64" 0 4)) -(println "newsum" (wasm/call m "scale_f64" 0 4 2.5)) -(println "scaled" (wasm/mem-read m :f64 0 4)) - -;; Every element type, over the same bytes. The f64 1.5 written at 64 reads -;; back as its little-endian bytes, which is the wasm memory model. -(wasm/mem-write! m :f64 64 [1.5]) -(println "as-u8" (wasm/mem-read m :u8 64 8)) - -;; An i64 past the i48 immediate window must survive exactly. 9007199254740993 -;; is 2^53+1, which is NOT representable as an f64 — so an equal round trip -;; proves the value did not pass through a float. -(wasm/mem-write! m :i64 128 [9007199254740993 -1]) -(println "i64" (wasm/mem-read m :i64 128 2)) -(println "i64-exact" (= 9007199254740993 (first (wasm/mem-read m :i64 128 1)))) - -;; Unsigned vs signed over identical bytes. -(wasm/mem-write! m :u32 200 [4294967295]) -(println "u32" (wasm/mem-read m :u32 200 1) "i32" (wasm/mem-read m :i32 200 1)) - -;; Unaligned access is supported: a (ptr,len) ABI hands the host whatever -;; offset the guest's allocator produced. -(wasm/mem-write! m :f64 301 [7.25]) -(println "unaligned" (wasm/mem-read m :f64 301 1)) - -;; Every failure is a CATCHABLE cljw exception naming the fn the user wrote, -;; never an internal_error and never a host panic on caller data. -;; Primitive arrays cross in bulk (ADR-0204): a double-array written as :f64 -;; is the guest's buffer byte for byte, and mem-read-array answers a typed -;; array. Every array kind round-trips through its dtype. -(println "arr-wrote" (wasm/mem-write! m :f64 0 (double-array [1 2 3 4]))) -(println "arr-newsum" (wasm/call m "scale_f64" 0 4 2.0)) -(let [d (wasm/mem-read-array m :f64 0 4)] - (println "arr-f64" (vec d) (= :double (cljw.internal/__array-kind d)))) -(doseq [[dt arr] [[:i8 (byte-array [-1 2 127])] - [:i16 (short-array [-300 2 32767])] - [:i32 (int-array [-70000 2 2147483647])] - [:i64 (long-array [9007199254740993 -1 2])] - [:f32 (float-array [1.5 -2.25 3])] - [:f64 (double-array [1.5 -2.25 3])]]] - (wasm/mem-write! m dt 1000 arr) - (println "arr-rt" dt (= (vec arr) (vec (wasm/mem-read-array m dt 1000 3))))) -(wasm/mem-write! m :u16 2000 [65535]) -(wasm/mem-write! m :u32 2008 [4294967295]) -(println "arr-unsigned" (vec (wasm/mem-read-array m :u16 2000 1)) (vec (wasm/mem-read-array m :u32 2008 1))) -;; A kind that is not the dtype's layout converts element by element. -(wasm/mem-write! m :f64 3000 (int-array [5 6])) -(println "arr-convert" (wasm/mem-read m :f64 3000 2)) - -(defn probe [label f] - (println label (try (do (f) "NOT-CAUGHT") (catch Exception e (.getMessage e))))) - -(probe "arr-object" #(wasm/mem-write! m :f64 0 (object-array 1))) -(probe "arr-range" #(wasm/mem-write! m :i8 0 (int-array [1 300]))) -(probe "arr-oob" #(wasm/mem-read-array m :f64 65530 1)) - -(probe "no-memory" #(wasm/mem-size (wasm/load "docs/examples/wasm/add.wasm"))) -(probe "bad-handle" #(wasm/mem-size 42)) -(probe "bad-dtype" #(wasm/mem-read m :f16 0 1)) -(probe "bad-index" #(wasm/mem-read m :f64 1.5 1)) -(probe "oob" #(wasm/mem-read m :f64 65530 1)) -(probe "negative" #(wasm/mem-read m :f64 -8 1)) -(probe "overflow" #(wasm/mem-read m :f64 0 9223372036854775807)) -(probe "bad-data" #(wasm/mem-write! m :f64 0 "nope")) -(probe "bad-element" #(wasm/mem-write! m :i32 0 [1 :x 3])) -(probe "element-range" #(wasm/mem-write! m :u8 0 [1 300])) -(probe "element-fraction" #(wasm/mem-write! m :i32 0 [1.5])) - -(println "DONE") diff --git a/test/e2e/phase16_wasm_engine_select.sh b/test/e2e/phase16_wasm_engine_select.sh deleted file mode 100755 index deb12de08..000000000 --- a/test/e2e/phase16_wasm_engine_select.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_wasm_engine_select.sh — zwasm ADR-0200 JIT-engine adoption smoke. -# Exercises per-instance engine selection at the cljw surface: -# `(wasm/load path {:engine :jit/:interp})` + `wasm/call`. Asserts the GPR export -# is byte-identical jit==interp (the F-012 differential discipline applied to engine -# choice), the SIMD body runs on the JIT (end-to-end through exportFuncSig, the JIT -# arm zwasm shipped @5b6449779 / from_cljw_02), the SIMD body traps catchably on -# interp (SIMD is JIT-only — to_cljw_03), and the no-opts default rides :auto = JIT -# (D-488 flipped 2026-06-22; zwasm v2.0.0-alpha.3 re-landed .auto→JIT — a SIMD body -# only the JIT can run returns 42 under the no-opts default). -# -# Like phase16_wasm_ffi.sh this is OPT-IN: it builds/uses the `-Dwasm` binary (which -# resolves zwasm via the relative-path build.zig.zon experiment), so it is NOT in the -# default per-commit gate's step list while F-001 keeps the default gate zwasm-free. -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -fail() { echo "FAIL $1" >&2; exit 1; } - -# Honor the gate's shared-binary contract (CLJW_SKIP_BUILD); standalone build -# -Dwasm ReleaseSafe (NOT bare -Dwasm = Debug, ADR-0132). -if [ -z "${CLJW_SKIP_BUILD:-}" ] && ! zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null 2>&1; then - fail "zig build -Dwasm failed (zwasm dep unresolved?)" -fi -"$BIN" --version | grep -q wasm || fail "cljw is not wasm-enabled ($("$BIN" --version)) — zwasm did not resolve" - -out="$("$BIN" test/e2e/fixtures/wasm/jit_engine_select.clj 2>&1)" || fail "fixture exited non-zero: -$out" - -# (1) GPR export byte-identical across engines (dual-engine diff oracle). -grep -q "add-jit: 5" <<<"$out" || fail "add on :jit != 5: -$out" -grep -q "add-interp: 5" <<<"$out" || fail "add on :interp != 5: -$out" - -# (2) SIMD body executes JIT-compiled → 42 (end-to-end via wasm/call/exportFuncSig). -grep -q "lane0-jit: 42" <<<"$out" || fail "SIMD lane0 on :jit != 42 (exportFuncSig JIT arm missing?): -$out" - -# (3) SIMD body traps a CATCHABLE error on :interp (SIMD is JIT-only in zwasm). -grep -q "lane0-interp: TRAPPED" <<<"$out" || fail "SIMD lane0 on :interp did not trap catchably: -$out" - -# (4) no-opts default rides :auto = JIT (D-488). GPR add works AND a SIMD body — -# which only the JIT can execute — returns 42 with no :engine opt (an interp default -# would TRAP, so default-simd: 42 is the proof the default flipped to JIT-first). -grep -q "default: 5" <<<"$out" || fail "no-opts default wasm/call broke: -$out" -grep -q "default-simd: 42" <<<"$out" || fail "no-opts default did NOT ride JIT (SIMD body should run under .auto=JIT default; interp would trap): -$out" - -# (5) Multi-value (>1 scalar) result marshals to a cljw vector, identical jit==interp. -grep -q "divmod-jit: \[3 2\]" <<<"$out" || fail "multi-value divmod on :jit != [3 2]: -$out" -grep -q "divmod-interp: \[3 2\]" <<<"$out" || fail "multi-value divmod on :interp != [3 2]: -$out" - -# (6) 2-arg f64 FP-bank is byte-identical jit==interp (zwasm @d7da97e04 fixed the -# 2-arg×FP-bank JIT dispatch — from_cljw_03 repro → to_cljw_04). -grep -q "addf-interp: 3.75" <<<"$out" || fail "f64 addf on :interp != 3.75: -$out" -grep -q "addf-jit: 3.75" <<<"$out" || fail "f64 addf on :jit != 3.75 (regressed? zwasm 2-arg×FP dispatch was fixed @d7da97e04): -$out" - -# (6b) Mixed-bank 2-arg (i32,f64)→f64 byte-identical jit==interp (1/2-arg matrix -# completed @3cf40a573 — veneer falls through to the generic buffer thunk). -grep -q "mix-jit: 5.5" <<<"$out" || fail "mixed (i32,f64)->f64 on :jit != 5.5 (zwasm 1/2-arg matrix @3cf40a573): -$out" -grep -q "mix-interp: 5.5" <<<"$out" || fail "mixed (i32,f64)->f64 on :interp != 5.5: -$out" - -# (6c) 3-arg FP via the generic buffer path (f64,f64,f64)->f64 byte-identical jit==interp. -grep -q "sum3-jit: 7" <<<"$out" || fail "3-arg (f64,f64,f64)->f64 on :jit != 7.0: -$out" -grep -q "sum3-interp: 7" <<<"$out" || fail "3-arg (f64,f64,f64)->f64 on :interp != 7.0: -$out" - -# (7) Real SIMD arithmetic on the JIT: i32x4.mul → horizontal sum = 70. -grep -q "simd-dot-jit: 70" <<<"$out" || fail "SIMD i32x4.mul kernel on :jit != 70: -$out" - -# (8) ADR-0196: engine truth at the surface. `wasm/engine` reports the selection -# a handle was loaded with, and the D-585 discriminator (a zero-result arity-4 -# export) traps on the default with a message naming the export, its signature, -# the engine and the remedy, while it runs on :interp. If zwasm fixes the -# miscompile this step flips together with engine.zig's gap-lock test: delete -# the `jit_void_window` row in gaps.zig and turn the trap assertion into RAN. -grep -q "engine-jit: :jit" <<<"$out" || fail "wasm/engine on a :jit handle != :jit: -$out" -grep -q "engine-interp: :interp" <<<"$out" || fail "wasm/engine on an :interp handle != :interp: -$out" -grep -q "engine-default: :auto" <<<"$out" || fail "wasm/engine on a no-opts handle != :auto (did the default move?): -$out" -void4="$(grep '^void4-default:' <<<"$out")" -grep -qF "'gpr4_void' (i32 i32 i32 i32) -> () trapped on the auto (JIT-first) engine" <<<"$void4" || fail "void arity-4 trap on the default did not name the export, signature and engine (D-585 gap-lock flipped?): -$out" -grep -qF "load the module with {:engine :interp}" <<<"$void4" || fail "void arity-4 trap on the default did not carry the remedy: -$out" -grep -q "void4-interp: RAN" <<<"$out" || fail "void arity-4 export did not run on :interp: -$out" - -grep -q "^DONE$" <<<"$out" || fail "fixture did not run to completion: -$out" - -echo -echo "Phase 16 / wasm engine selection (zwasm ADR-0200 JIT adoption): all green." diff --git a/test/e2e/phase16_wasm_memory.sh b/test/e2e/phase16_wasm_memory.sh deleted file mode 100755 index 223da9206..000000000 --- a/test/e2e/phase16_wasm_memory.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# test/e2e/phase16_wasm_memory.sh — the (ptr,len) linear-memory surface -# (ADR-0192). `wasm/call` marshals scalars, which reaches every guest whose -# whole interface fits in numbers; a numeric guest's does not. This asserts the -# host can fill the buffer a guest's pointer argument names and read what the -# guest wrote back, that every element type round-trips through the wasm memory -# model (little-endian, unaligned-tolerant, i64 exact past the i48 window), and -# that every failure is a catchable exception rather than a host panic. -# -# OPT-IN for the same reason as phase16_wasm_ffi.sh: it builds `-Dwasm`, so it -# resolves zwasm, which the default gate never does (F-001). -set -euo pipefail -cd "$(dirname "$0")/../.." -BIN="zig-out/bin/cljw" -fail() { echo "FAIL $1" >&2; exit 1; } - -if [ -z "${CLJW_SKIP_BUILD:-}" ] && ! zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null 2>&1; then - fail "zig build -Dwasm failed (zwasm dep unresolved?)" -fi -"$BIN" --version | grep -q wasm || fail "cljw is not wasm-enabled ($("$BIN" --version)) — zwasm did not resolve" - -FIX="test/e2e/fixtures/wasm_memory_probe.clj" -out="$("$BIN" "$FIX" 2>&1)" || fail "cljw exited non-zero running $FIX: -$out" - -want() { - grep -qF "$1" <<<"$out" || fail "expected '$1' in the probe output, got: -$out" -} - -# (1) The size is in BYTES (one 64 KiB page), not in pages. -want "size 65536" - -# (2) Round trip through the guest. The host writes 4 doubles; the guest sums -# them (10.0); the guest scales them in place and reports the new sum -# (25.0); the host reads back what the guest wrote. Every one of those four -# numbers is impossible without the memory surface. -want "wrote 4" -want "sum 10.0" -want "newsum 25.0" -want "scaled [2.5 5.0 7.5 10.0]" -echo "PASS wasm-memory-roundtrip -> guest read the host's buffer and wrote it back" - -# (3) The bytes are the wasm memory model's: f64 1.5 little-endian. -want "as-u8 [0 0 0 0 0 0 248 63]" - -# (4) i64 past the i48 immediate window survives EXACTLY. 2^53+1 is not -# representable as an f64, so an equal round trip proves the value did not -# pass through a float (which `Value.initInteger` alone would have done). -want "i64 [9007199254740993 -1]" -want "i64-exact true" -echo "PASS wasm-memory-i64-exact -> 2^53+1 round-trips without a float" - -# (5) Unsigned and signed read the same bytes differently; unaligned works. -want "u32 [4294967295] i32 [-1]" -want "unaligned [7.25]" -echo "PASS wasm-memory-dtypes -> signedness + unaligned access" - -# (5b) Primitive arrays cross in bulk (ADR-0204), every array kind through -# its dtype, unsigned widening, and a cross-kind element-wise convert. -want "arr-wrote 4" -want "arr-newsum 20.0" -want "arr-f64 [2.0 4.0 6.0 8.0] true" -for dt in i8 i16 i32 i64 f32 f64; do want "arr-rt :$dt true"; done -want "arr-unsigned [65535] [4294967295]" -want "arr-convert [5.0 6.0]" -want "arr-object wasm/mem-write!: the data argument must be a vector of numbers" -want "arr-range wasm/mem-write!: element 1 is outside this element type's range" -want "arr-oob wasm/mem-read-array: 1 element(s) at byte offset 65530" -echo "PASS wasm-memory-arrays -> primitive arrays cross as one memcpy per kind" - -# (6) Every failure is CATCHABLE and names the fn the user wrote. A NOT-CAUGHT -# means the error escaped (catch …); an uncatchable one would have exited -# 70 above and never reached here. -grep -q "NOT-CAUGHT" <<<"$out" && fail "a wasm/mem-* error was not raised or not caught: -$out" -want "no-memory wasm/mem-size: this module exports no linear memory" -want "bad-handle wasm/mem-size: the first argument must be a loaded wasm module" -want "bad-dtype wasm/mem-read: the element type must be one of" -want "bad-index wasm/mem-read: the byte offset and the element count must both be integers" -want "oob wasm/mem-read: 1 element(s) at byte offset 65530" -want "negative wasm/mem-read: 1 element(s) at byte offset -8" -want "overflow wasm/mem-read: 9223372036854775807 element(s)" -want "bad-data wasm/mem-write!: the data argument must be a vector of numbers" -want "bad-element wasm/mem-write!: element 1 is not a number" -want "element-range wasm/mem-write!: element 1 is outside this element type's range" -want "element-fraction wasm/mem-write!: element 0 is not an integer" -echo "PASS wasm-memory-catchable -> negative offset / overflow / bad element all raise, none panic" - -grep -q "^DONE$" <<<"$out" || fail "the probe did not run to completion: -$out" - -echo -echo "Phase 16 / wasm linear memory (ADR-0192): all green." diff --git a/test/run_all.sh b/test/run_all.sh index b5b2ce96a..b206f7edd 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -698,8 +698,6 @@ run_step "e2e_bulk_literal" "bash test/e2e/bulk_literal.sh" # (CLJW_SKIP_BUILD), so no per-step rebuild. On a host where `zig build -Dwasm` # cannot resolve zwasm, build_cljw fails first — wasm is a gate prerequisite now. run_step "e2e_phase16_wasm_ffi" "bash test/e2e/phase16_wasm_ffi.sh" -run_step "e2e_phase16_wasm_memory" "bash test/e2e/phase16_wasm_memory.sh" -run_step "e2e_phase16_wasm_engine_select" "bash test/e2e/phase16_wasm_engine_select.sh" run_step "e2e_phase16_wasm_run" "bash test/e2e/phase16_wasm_run.sh" # D-349: the third budget axis. Fuel bounds instructions; how many bytes an # instruction writes is the guest's choice, so only a capture cap bounds host From 8cae4fc8c2c7897dd43fa1e50477380823f2d7ea Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 22 Oct 2026 20:33:20 -0300 Subject: [PATCH 08/51] perf(lazy): realize map/filter/take/iterate nodes natively (O-110) Each node of a one-coll map/filter/take/iterate chain allocated an interpreted arity-0 closure and re-entered eval to run its body. Over an unchunked source such as (range) = (iterate inc 0) that was one closure and one eval re-entry per element per layer. A LazySeq can now hold a builtin step plus two operands (lazy_seq.allocNative). The steps in lang/primitive/lazy_step.zig build the same nodes as the clj bodies: chunk-cons over a chunked source via the O-032 drains, cons plus the next node otherwise. filter skips rejected elements and empty chunks within one realization. iterate does not call f for its head, as clj's Iterate. take keeps the interpreted body for a non-integer n. lazy_chain (3 reps): 1208 ms -> ~270 ms, bb 141 ms. Realization counts are pinned against bb in lazy_step_test.clj. VM and tree_walk give the same results under --compare. Smell-audited: 1: one native-step slot on LazySeq instead of a node type per producer --- .dev/optimizations.md | 1 + data/placement.yaml | 8 +- src/lang/clj/clojure/core.clj | 56 ++++---- src/lang/primitive.zig | 2 + src/lang/primitive/lazy_step.zig | 211 +++++++++++++++++++++++++++++ src/runtime/lazy_seq.zig | 43 +++++- test/clj/suites/lazy_step_test.clj | 88 ++++++++++++ 7 files changed, 370 insertions(+), 39 deletions(-) create mode 100644 src/lang/primitive/lazy_step.zig create mode 100644 test/clj/suites/lazy_step_test.clj diff --git a/.dev/optimizations.md b/.dev/optimizations.md index fcbafc728..aaa9eaf77 100644 --- a/.dev/optimizations.md +++ b/.dev/optimizations.md @@ -105,6 +105,7 @@ | O-056 | `lang/clj/clojure/core.clj` `pop` (vector arm) + `lang/primitive/collection.zig` `__vector-pop` (new) | `(pop v)` on a vector was `(into [] (take (dec (count coll)) coll))` — an O(n) REBUILD of a whole fresh trie. Meanwhile `runtime/collection/vector.zig::pop` already implements the JVM `PersistentVector.pop()` algorithm correctly (tail fast path; else pull the last leaf out of the root and collapse a level), and was reachable ONLY from `java.util.ArrayDeque`. A stratified-design ABSTRACTION-BARRIER violation (Grokking Simplicity ch.8): the layer above re-implemented a barrier operation with high-level seq ops instead of calling through it | Expose the existing barrier fn as the `__vector-pop` primitive and have `pop` call it. No new lower-layer code — the "minimal interface" move: the barrier already had the op | O(n) -> O(log32 n), and it now SHARES structure with the parent instead of allocating a fresh trie. Measured 2000 pops, ReleaseSafe: n=1000 539.5 -> 1.14 ms; n=4000 2151.5 -> 1.30 ms; **n=16000 8278.3 -> 2.47 ms (~3352x)**. Reference JVM clj 1.12.4 on the same box/probe: 1.93 ms — cljw was ~4288x SLOWER than clj here and is now within ~1.3x | `scripts/check_corpus_regression.sh` **4347/4347** golden clj outputs reproduced + e2e `phase14_peek_pop`, `phase14_deftype_collection_base`, `phase14_deftype_object`, `phase14_catch_internal` + spot checks (meta preserved, list arm -> rest, queue arm -> `__queue-pop`, empty still raises IllegalStateException) | D-044, ADR-0148, `.dev/bench/` | | O-061 | `runtime/collection/chunked_seq.zig` (NEW, the IChunkedSeq boundary) + `lang/primitive/sequence.zig` (`chunked-seq?` / `__chunk-count` / `__chunk-nth` / `chunk-rest`) + `lang/primitive/chunk_transform.zig` (O-032 drains) | The seq of a vector (an `.array_seq` view, O-058) answered `chunked-seq?` false, so `map`/`filter`/`keep`/`map-indexed` over a vector built one lazy node + one interpreted thunk per element; clj's `PersistentVector$ChunkedSeq` realizes a 32-element leaf per step (a laziness divergence too: `(first (map f v))` ran f once, clj 32 times) | One boundary answers isChunked / chunkCount / chunkNth / chunkRest for `.chunked_cons` and the vector-backed view, with clj's leaf geometry (cursor i exposes i..(i\|31)+1, bounded by count). subvec and Java-array seqs stay unchunked (clj APersistentVector$Seq / ArraySeq are not IChunkedSeq). Naive fallback: per-element lazy walk | 8000-element vector, ReleaseSafe -Dwasm, per call (bb): `(reduce + (map inc v))` 19271 -> 1204 us (420); `(count (filter even? v))` 19646 -> 927 (221); `(into [] (map inc v))` 18082 -> 1306 (302) | `chunked_seq.zig` units (leaf alignment, mid-leaf cursor, tail) + `test/clj/suites/chunked_seq_test.clj` `a-vector-seq-is-chunked-by-leaf` (realization counts 32 / 27, chunk-rest counts 38 / 38, measured on bb) + full suites | ADR-0205, O-032, O-058 | | O-062 | `lang/primitive/map_step.zig` (NEW, `cljw.internal/__map-step`) + `core.clj` `-map-n-lazy` / `map` 2- and 3-coll arities | Multi-coll `map` interpreted a let / and / seq / first / rest / cons body per element | The step body (seq every source, f on the firsts, then the rests, cons onto `(self f rests..)`) runs in Zig; the lazy node and the recursion stay in core.clj, so the unchunked one-f-per-node shape, its order and its realization counts are clj's. Naive fallback: the `.clj` body | see ADR-0205 Consequences | suites + `chunked_seq_test.clj` `multi-coll-map-is-unchunked` | ADR-0205 | +| O-110 | `lang/primitive/lazy_step.zig` (NEW, `cljw.internal/__map-lazy` / `__filter-lazy` / `__take-lazy` / `__iterate-lazy`) + `runtime/lazy_seq.zig` (`allocNative`, `native` + `step_a`/`step_b` slots) + `core.clj` `-map-lazy` / `-filter-lazy` / `take` / `iterate` | Every node of a one-coll map/filter/take/iterate chain allocated an interpreted arity-0 closure and re-entered eval to run a let/if/seq/first/rest/cons body; over an unchunked source ((range) = (iterate inc 0)) that was 3-4 closures + 3-4 eval re-entries per element | The lazy node stores a builtin step and its two operands; realizing it calls the step in Zig, which builds exactly clj's node (chunk-cons over a chunked source via the O-032 drains, cons + next node otherwise; filter skips rejects and empty chunks inside one realization). Naive fallback: the `.clj` bodies (take keeps it for a non-int n) | One fewer closure alloc and no eval re-entry per node; lazy_chain 3 reps 1208 -> ~270 ms (bb 141) | `test/clj/suites/lazy_step_test.clj` (bb-measured realization counts, chunk-of-32) + `--compare` VM/tree_walk parity | card 20261022191718-06bc209a | | O-064 | `eval/backend/tree_walk.zig` (`uninitScratch`; `callMethodImpl` locals, `evalCall` args + arg locs, `evalRecur`, `evalConstructorCall`) + `eval/backend/vm.zig` (`eval` handler stack) | ReleaseSafe fills every `= undefined` local with 0xAA, not only Debug. The per-call scratch windows (`[MAX_LOCALS]Value`, about 2 KB each) were memset on every Clojure fn call: 19% of instructions in `(reduce + (map * xs xs))`, 18-59% of samples in transduce/map after ADR-0205 | `uninitScratch(T)` returns `undefined` with runtime safety off outside Debug. Every user reads only a prefix it has just written, so the poison caught nothing; Debug keeps it, and the callers' own checks stay on | User instructions, ReleaseSafe -Dwasm, `taskset -c 2`: seq_dot_1k 16.02G -> 13.15G (-18%), lazy_chain -38%, multimethod_dispatch -35%, ratio_sum -27%, protocol_dispatch -26%, transduce -24%, gc_large_heap -50%, startup 74.2M -> 66.9M (-10%); no cell of 01-45 regressed | `test/run_all.sh --smoke` (unit VM + TreeWalk, corpus, clj suites, e2e) | card CLJW-PERF-UNDEFINED-SCRATCH, knowledge 20261022011049-02b6329c | | O-060 | `runtime/collection/string_seq.zig` (NEW, `.string_seq` A13 — a day-1 reservation with no producer) + `lang/primitive/sequence.zig` (`seqFn`/`countFn`/`firstFn`/`restFn`/`nextFn` + rev-walk `.string` arm → view) + `.string_seq` arms across `equal.zig` (isSequential via the interface_membership SSOT / isCountable / seqLen / valueHash / isSeqKeyValue) / `print.zig` (`printStringSeq`, ports-threaded) / `higher_order.zig` (fuse-base + chunkClassify) / `clojure_lang_method.zig` (native-collection arms) / `lazy_seq.zig` / `metadata.zig` / `meta.zig` / `runtime.zig` (GC trace registration) | `(seq s)` built an EAGER n-cell codepoint cons chain by asking `charset.codepointAt(s, i)` per element, and that restarts a `Utf8Iterator` from byte 0 every call — n calls at O(i) each = **O(n²)** overall. Measured cljw eager path: 2,000 chars 27 ms → 146,670 chars **89,910 ms**; downstream, bb-mcp paid ~79 s/boot asking `(seq value)` as a truthiness test on a 146,673-char nREPL response | A byte-offset VIEW satisfying the seq protocol: `StringSeq{backing, offset}`, two words. The cursor is a BYTE offset, not a codepoint index (an index would reintroduce the byte-0 walk): `first` decodes in place at `offset`, `rest` advances by the width it just read, `count` is a single forward byte scan (UTF-8 gives no length arithmetic — unlike the vector view's subtraction — but the scan allocates nothing and is no worse than `(count s)`). Consumers (`printStringSeq`, both `equal.zig` cursors) take the byte walk too rather than `nth(i)` (`nth` on UTF-8 is O(i) however the view is built, so the index-walk shape copied from array_seq would rebuild the quadratic inside the fix). `decodeAt` is total (malformed byte → width-1 raw), so a walk always advances — a view can never wedge or loop. Slot from the F-004 A13 day-1 reservation (already declared across interface_membership + class_name displayClassName), NO `heap_tag.unallocated` slot spent — the same position `.array_seq` (O-058) was in | **O(n²) → O(n).** NEW cljw ReleaseSafe end-to-end (incl. process startup + O(n) string build): `(count (seq (str-of-n "x")))` n=100k **0.13 s**, n=500k **0.60 s** (linear); the eager path's 89,910 ms at 146k chars is now sub-second (**>10,000× at that size**). clj JVM isolates the same seq+count to ~0 ms (StringSeq view + JIT); cljw's view is likewise O(1)/step, its residual wall time being the O(n) string construction, not the seq walk. Broad win: every hand-written `(seq s)` / `doseq`/`for` over a string and every `(seq value)` truthiness probe on a large string | `zig build -Dwasm -Doptimize=ReleaseSafe` (string-seq binary compiles) + corpus `string_seq.txt` (33 exprs vs clj, `33/33 reproduced`) + the 47-assertion observable-surface pin set, landed as e2e `string_seq_view.sh` and since migrated verbatim to `test/clj/suites/string_seq_view_test.clj` (vs clj: prints-as-seq, seq?/sequential?/coll?/not-string/not-vector, class=StringSeq, UTF-8 count/first/rest/nth/last, second/fnext/nthrest, `=`/hash with list AND vector + map-key + set-dedup + length-mismatch loses, vec/into/apply-str/reverse/take/drop/cons/concat, map/filter/reduce, empty→nil, scale count 20k + utf8 10k) | D-179, O-058, F-004, `feat/string-seq-view` | | O-059 | `runtime/collection/sub_vector.zig` (NEW, `.sub_vector` D11) + `lang/clj/clojure/core.clj` `subvec` (fast-path) + `lang/primitive/collection.zig` (`__subvec` prim + `conjOne`/`getFn`/`nthFn`/`assocFn`/`containsQFn`/`__vector-pop`/`isEntrySeqTag`) + `.sub_vector` arms across `interface_membership.zig` (16 arrays) / `sequence.zig` / `core.zig` / `equal.zig` / `print.zig` / `compare.zig` / `lookup.zig` + `tree_walk.zig` / `meta.zig` / `metadata.zig` / `class_name.zig` / `lazy_seq.zig` / `walk.zig` / `json.zig` / `higher_order.zig` / `intrinsic.zig` / `serialize.zig` / `clojure_lang_method.zig` / `multimethod.zig` / `transient_array_map.zig` / `array_seq.zig` (backing arm). `vector.subvec`→`copyRange` (materializing; LBQ compaction) | `clojure.core/subvec` on a vector was `(into [] (take (- end start) (drop start v)))` — an eager O(n) rebuild of a whole fresh trie per call, the LAST catastrophic collection gap (measured 7480x slower than clj). JVM never copies: `subvec` returns an `APersistentVector$SubVector` sharing the parent's root+tail (D-044/D-583). The Zig `vector.subvec` (eager) was reachable only from LBQ compaction, which genuinely wants a copy — so the view had to be a NEW representation, not a rewrite of it | A new REPRESENTATION satisfying the existing IPersistentVector protocol: `SubVector{start,end,parent,meta}`, 32 bytes fixed whatever the span. `count = end-start` (O(1)), `nth(i) = parent.nth(start+i)`. JVM `SubVector` semantics: FLATTEN on nest (parent is always a plain `.vector`), never-empty (empty range → EMPTY plain vector), write ops stay SubVectors via `parent.assocN` (`conj`/`assoc`/`pop`, structural sharing — never materialize). Slot came from the F-004 `box`→`sub_vector` amendment (user-selected, ADR-0179), 64-slot layout unchanged. Interface membership matches JVM ground truth exactly: `.sub_vector` joins every array `.vector` is in EXCEPT `EDITABLE_TAGS` (no transient) and `IREDUCEINIT_TAGS` (SubVector is not IReduceInit) — verified live against clj 1.11.1. seq/rest/next route through the O-058 `.array_seq` view (its backing switch gained a `.sub_vector` arm), so a subvec's seq is O(1) too. `=`/hash reuse the equal.zig `.vec` cursor by init'ing it with the parent + absolute [start,end) indices — zero new cursor code | O(n) alloc + trie rebuild -> O(1). Measured 2000 subvec ops on a 16000-element vector, ReleaseSafe: **9215 -> 0.77 ms (~11967x)**. Reference JVM clj 1.11.1, same box/probe: 1.06 ms — cljw was ~8693x SLOWER than clj and is now at PARITY (slightly faster). Broad win: `subvec` is the idiomatic vector slice, and it now shares structure instead of rebuilding | `zig build test -Dwasm` ×2 (VM + tree_walk diff oracle, all Values equal) + new `sub_vector.zig` units (layout, flatten-on-nest, never-empty, conj/assoc/pop parent-untouched, with-meta) + the 52-assertion observable-surface pin set (vector?/hash-eq/map-key/set-dedup/compare/into-{}/walk/nested/structural-sharing/meta/class=SubVector/empty+full-range/OOB-throws), landed as e2e `subvec_view.sh` and since migrated verbatim to `test/clj/suites/subvec_view_test.clj` + corpus `subvec.txt` (31 exprs) + `phase14_subvec.sh` (bounds) still green | D-583, D-044, O-058, ADR-0179, F-004, `private/notes/subvec-seqview-design.md` | diff --git a/data/placement.yaml b/data/placement.yaml index 64ce45bfc..d3129e5f1 100644 --- a/data/placement.yaml +++ b/data/placement.yaml @@ -24,8 +24,8 @@ schema_version: "2.0" summary: namespaces_required: 36 - total: 1464 - zig: 431 + total: 1468 + zig: 435 clj: 807 value: 220 unbound: 6 @@ -92,11 +92,13 @@ vars: "cljw.internal/__entry-at": { impl: zig, flags: "-" } "cljw.internal/__extend-type!": { impl: zig, flags: "-" } "cljw.internal/__extends?": { impl: zig, flags: "-" } + "cljw.internal/__filter-lazy": { impl: zig, flags: "-" } "cljw.internal/__future-call": { impl: zig, flags: "-" } "cljw.internal/__get-method": { impl: zig, flags: "-" } "cljw.internal/__in-reader": { impl: zig, flags: "-" } "cljw.internal/__in-transaction?": { impl: zig, flags: "-" } "cljw.internal/__instance-of?": { impl: zig, flags: "-" } + "cljw.internal/__iterate-lazy": { impl: zig, flags: "-" } "cljw.internal/__kv-reduce-or": { impl: zig, flags: "-" } "cljw.internal/__lazy-seq-create": { impl: zig, flags: "-" } "cljw.internal/__load-source": { impl: zig, flags: "-" } @@ -105,6 +107,7 @@ vars: "cljw.internal/__make-protocol!": { impl: zig, flags: "-" } "cljw.internal/__make-protocol-fn!": { impl: zig, flags: "-" } "cljw.internal/__map->record": { impl: zig, flags: "-" } + "cljw.internal/__map-lazy": { impl: zig, flags: "-" } "cljw.internal/__map-step": { impl: zig, flags: "-" } "cljw.internal/__methods": { impl: zig, flags: "-" } "cljw.internal/__native-type": { impl: zig, flags: "-" } @@ -134,6 +137,7 @@ vars: "cljw.internal/__string-writer": { impl: zig, flags: "-" } "cljw.internal/__subvec": { impl: zig, flags: "-" } "cljw.internal/__subvec-fast": { impl: zig, flags: "-" } + "cljw.internal/__take-lazy": { impl: zig, flags: "-" } "cljw.internal/__unsupported-host-ref": { impl: zig, flags: "-" } "cljw.internal/__update-in": { impl: zig, flags: "-" } "cljw.internal/__vector-pop": { impl: zig, flags: "-" } diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj index ca1813ba8..717f3f093 100644 --- a/src/lang/clj/clojure/core.clj +++ b/src/lang/clj/clojure/core.clj @@ -158,16 +158,11 @@ ;; `map` stamps the descriptor on the outer node reduce sees. [refs: O-023] (def -map-lazy (fn* [f coll] - ;; PERF: chunked source → transform a whole 32-chunk per thunk (O-004). - (lazy-seq - (let [s (seq coll)] - (if s - (if (chunked-seq? s) - ;; PERF: O-032 in-Zig chunk drain — the per-element `.clj` loop moved - ;; into `cljw.internal/__chunk-map-step` (producer analogue of reduceFn's O-004 drain). - (chunk-cons (cljw.internal/__chunk-map-step f s) (-map-lazy f (chunk-rest s))) - (cons (f (first s)) (-map-lazy f (rest s)))) - nil))))) + ;; PERF: O-110 the node body runs in Zig (`__map-lazy`, lazy_step.zig): + ;; (lazy-seq (when-let [s (seq coll)] (if (chunked-seq? s) (chunk-cons + ;; (-map-lazy f (chunk-rest s))) (cons (f (first + ;; s)) (-map-lazy f (rest s)))))) with no closure or eval re-entry per node. + (cljw.internal/__map-lazy f coll))) ;; PERF: O-062 internal lazy body for 2/3-coll `map`: one lazy node per element ;; (clj's multi-coll map is unchunked), whose body is the Zig `__map-step` ;; (seq all, f on the firsts, recur on the rests). Passes itself as `self`. @@ -212,18 +207,11 @@ ;; PERF: O-023 internal lazy body for `filter` — recurses on itself, no stamp. (def -filter-lazy (fn* [pred coll] - (lazy-seq - (let [s (seq coll)] - (if s - (if (chunked-seq? s) - ;; PERF: O-032 in-Zig chunk drain — the per-element `.clj` loop moved - ;; into `cljw.internal/__chunk-filter-step` (survivors-only partial chunk). - (chunk-cons (cljw.internal/__chunk-filter-step pred s) (-filter-lazy pred (chunk-rest s))) - (let [v (first s)] - (if (pred v) - (cons v (-filter-lazy pred (rest s))) - (-filter-lazy pred (rest s))))) - nil))))) + ;; PERF: O-110 the node body runs in Zig (`__filter-lazy`, lazy_step.zig): + ;; a chunked source keeps chunk-of-32 survivors (O-032), an unchunked one + ;; conses each survivor onto (-filter-lazy pred (rest s)); a non-match + ;; continues in the same realization, as clj's recursive body does. + (cljw.internal/__filter-lazy pred coll))) (def filter (fn* ([pred] ;; transducer arity @@ -247,13 +235,18 @@ result (if (> m 0) (rf result input) result)] (if (not (> nm 0)) (ensure-reduced result) result))))))) ([n coll] - (lazy-seq - (if (pos? n) - (let [s (seq coll)] - (if s - (cons (first s) (take (dec n) (rest s))) - nil)) - nil))))) + ;; PERF: O-110 a fixed-precision n takes native lazy nodes + ;; (`__take-lazy`, lazy_step.zig) with this exact body; any other n + ;; (a double, a ratio) keeps the interpreted one. + (if (int? n) + (cljw.internal/__take-lazy n coll) + (lazy-seq + (if (pos? n) + (let [s (seq coll)] + (if s + (cons (first s) (take (dec n) (rest s))) + nil)) + nil)))))) (def drop (fn* ([n] ;; transducer arity: stateful (skips the first n inputs) @@ -1574,7 +1567,10 @@ ;; a fn body's free symbols at analysis time, so a forward ref to a Var ;; def'd later in the file fails to resolve. (def iterate - (fn* [f x] (lazy-seq (cons x (iterate f (f x)))))) + ;; PERF: O-110 native lazy nodes (`__iterate-lazy`, lazy_step.zig). As clj's + ;; Iterate, `f` runs only when the element after x is realized, never for + ;; (first (iterate f x)). + (fn* [f x] (cljw.internal/__iterate-lazy f x))) ;; `(range)` → infinite lazy 0,1,2,…; `(range n)` → 0..n-1; ;; `(range start end)` → start..end-1; `(range start end step)` → stepped. diff --git a/src/lang/primitive.zig b/src/lang/primitive.zig index af563db99..6382f7960 100644 --- a/src/lang/primitive.zig +++ b/src/lang/primitive.zig @@ -56,6 +56,7 @@ const namespace_prim = @import("primitive/namespace.zig"); const macroexpand_prim = @import("primitive/macroexpand.zig"); const array_prim = @import("primitive/array.zig"); const map_step = @import("primitive/map_step.zig"); +const lazy_step = @import("primitive/lazy_step.zig"); pub const RegisterError = error{ InternalNamespaceMissing, @@ -116,6 +117,7 @@ pub fn registerAll(env: *Env) !void { try macroexpand_prim.register(env, clojure_core_ns); // D-229 macroexpand-1/macroexpand try array_prim.register(env, clojure_core_ns); // ADR-0105 / D-287 Java arrays try map_step.register(env, clojure_core_ns); // ADR-0205 multi-coll map step + try lazy_step.register(env, clojure_core_ns); // O-110 native lazy producers // Phase 14 row 14.1 (D-079 discharge): walk every // `runtime/java//.zig`'s `___HOST_EXTENSION` diff --git a/src/lang/primitive/lazy_step.zig b/src/lang/primitive/lazy_step.zig new file mode 100644 index 000000000..77d7f02d8 --- /dev/null +++ b/src/lang/primitive/lazy_step.zig @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: EPL-2.0 +//! Native lazy-seq producers for one-coll `map`, `filter`, `take` and +//! `iterate` (O-110, card CLJW-PERF-LAZY-REALIZE). +//! +//! Each `__X-lazy` builtin returns an UNREALIZED lazy node whose body is a +//! native step (`lazy_seq.allocNative`) instead of an interpreted arity-0 +//! closure. Realizing the node runs the step in Zig: one node per element +//! (or per 32-chunk on a chunked source), exactly the shape of the clj +//! bodies they replace, so realization counts, chunk geometry and call order +//! are unchanged: +//! +//! map (lazy-seq (when-let [s (seq coll)] +//! (if (chunked-seq? s) +//! (chunk-cons (map f (chunk-rest s))) +//! (cons (f (first s)) (map f (rest s)))))) +//! filter same, survivors only; a non-match continues with the rest +//! take (lazy-seq (when (pos? n) (when-let [s (seq coll)] +//! (cons (first s) (take (dec n) (rest s)))))) +//! iterate (lazy-seq (cons x (iterate f (f x)))) +//! +//! What goes away per element: the closure allocation for the lazy body, +//! the eval re-entry that runs it, and the interpreted let/if/seq/first/rest +//! tree. `f`/`pred` still run through `invokeCallable`. +//! +//! ## Backend: impl-only (no surface delegation) +//! Impl deps: lazy_seq.allocNative, seqable (Layer 0), chunked_seq, +//! chunked_cons, list.consSeqHeap, chunk_transform, higher_order.invokeCallable +//! Clojure peer: -map-lazy / -filter-lazy / take / iterate (core.clj) + +const Value = @import("../../runtime/value/value.zig").Value; +const Runtime = @import("../../runtime/runtime.zig").Runtime; +const env_mod = @import("../../runtime/env.zig"); +const Env = env_mod.Env; +const error_catalog = @import("../../runtime/error/catalog.zig"); +const SourceLocation = @import("../../runtime/error/info.zig").SourceLocation; +const seqable = @import("../../runtime/seqable.zig"); +const lazy_seq = @import("../../runtime/lazy_seq.zig"); +const list = @import("../../runtime/collection/list.zig"); +const chunked_cons = @import("../../runtime/collection/chunked_cons.zig"); +const chunked_seq = @import("../../runtime/collection/chunked_seq.zig"); +const root_set = @import("../../runtime/gc/root_set.zig"); +const chunk_transform = @import("chunk_transform.zig"); +const invokeCallable = @import("higher_order.zig").invokeCallable; +const big_int = @import("../../runtime/numeric/big_int.zig"); + +const nb_i48_max: i64 = (1 << 47) - 1; + +/// A 3-slot reentrant-primitive root frame (ADR-0094): [op, cursor, value]. +const Roots = struct { + slots: [3]Value, + sp: u16 = 3, + frame: root_set.EvalFrame = undefined, + + fn push(self: *Roots) void { + self.frame = .{ .stack = &self.slots, .sp = &self.sp, .locals = &.{}, .parent = root_set.eval_frame_head }; + root_set.eval_frame_head = &self.frame; + } + fn pop(self: *Roots) void { + root_set.eval_frame_head = self.frame.parent; + } +}; + +// The step pointers are encoded at run time (a fn address is not comptime). +fn mapStepV() Value { + return Value.initBuiltinFn(&mapStep); +} +fn filterStepV() Value { + return Value.initBuiltinFn(&filterStep); +} +fn takeStepV() Value { + return Value.initBuiltinFn(&takeStep); +} +fn iterateStepV() Value { + return Value.initBuiltinFn(&iterateStep); +} + +/// PERF: realize one `(map f coll)` node in Zig [refs: O-110, O-032] +fn mapStep(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + const f = args[0]; + // GC-ROOT: [f, cursor, mapped/buf]: the step operands live in the node + // being realized, but every fresh cursor, buffer and tail is rooted here + // across the user fn and the allocations [ref: .dev/gc_rooting.md]. + var r: Roots = .{ .slots = .{ f, args[1], .nil_val } }; + r.push(); + defer r.pop(); + const s = try seqable.seq(rt, env, args[1], loc); + r.slots[1] = s; + if (s.isNil()) return .nil_val; + if (chunked_seq.isChunked(s)) { + r.slots[2] = try chunk_transform.chunkMapStepFn(rt, env, &.{ f, s }, loc); + r.slots[1] = try chunked_seq.chunkRest(rt, s); + r.slots[1] = try lazy_seq.allocNative(rt, mapStepV(), f, r.slots[1]); + return try chunked_cons.chunkCons(rt, r.slots[2], r.slots[1]); + } + r.slots[2] = try invokeCallable(rt, env, f, &.{try seqable.first(rt, env, s, loc)}, loc); + r.slots[1] = try seqable.rest(rt, env, s, loc); + r.slots[1] = try lazy_seq.allocNative(rt, mapStepV(), f, r.slots[1]); + return try list.consSeqHeap(rt, r.slots[2], r.slots[1]); +} + +/// PERF: realize one `(filter pred coll)` node in Zig; a non-match (or an +/// all-dropped chunk) continues in this loop instead of through a nested +/// lazy node [refs: O-110, O-032] +fn filterStep(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + const pred = args[0]; + // GC-ROOT: [pred, cursor, survivor/buf], as mapStep. + var r: Roots = .{ .slots = .{ pred, args[1], .nil_val } }; + r.push(); + defer r.pop(); + while (true) { + const s = try seqable.seq(rt, env, r.slots[1], loc); + r.slots[1] = s; + if (s.isNil()) return .nil_val; + if (chunked_seq.isChunked(s)) { + r.slots[2] = try chunk_transform.chunkFilterStepFn(rt, env, &.{ pred, s }, loc); + r.slots[1] = try chunked_seq.chunkRest(rt, s); + if (r.slots[2].decodePtr(*const chunked_cons.ChunkBuffer).count == 0) continue; + r.slots[1] = try lazy_seq.allocNative(rt, filterStepV(), pred, r.slots[1]); + return try chunked_cons.chunkCons(rt, r.slots[2], r.slots[1]); + } + r.slots[2] = try seqable.first(rt, env, s, loc); + const keep = (try invokeCallable(rt, env, pred, &.{r.slots[2]}, loc)).isTruthy(); + r.slots[1] = try seqable.rest(rt, env, s, loc); + if (keep) { + r.slots[1] = try lazy_seq.allocNative(rt, filterStepV(), pred, r.slots[1]); + return try list.consSeqHeap(rt, r.slots[2], r.slots[1]); + } + } +} + +/// PERF: realize one `(take n coll)` node in Zig (n a fixed-precision +/// integer; core.clj keeps the generic body for any other n) [refs: O-110] +fn takeStep(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + const n = args[0].asInteger(); + if (n <= 0) return .nil_val; + // GC-ROOT: [n, cursor, head], as mapStep. + var r: Roots = .{ .slots = .{ args[0], args[1], .nil_val } }; + r.push(); + defer r.pop(); + const s = try seqable.seq(rt, env, args[1], loc); + r.slots[1] = s; + if (s.isNil()) return .nil_val; + r.slots[2] = try seqable.first(rt, env, s, loc); + r.slots[1] = try seqable.rest(rt, env, s, loc); + r.slots[1] = try lazy_seq.allocNative(rt, takeStepV(), Value.initInteger(n - 1), r.slots[1]); + return try list.consSeqHeap(rt, r.slots[2], r.slots[1]); +} + +/// PERF: realize the head of `(iterate f x)`: `(x . )`, no `f` +/// call, as clj's Iterate answers `first` without running `f` [refs: O-110] +fn iterateStep(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + _ = env; + _ = loc; + // GC-ROOT: [f, x, tail], as mapStep. + var r: Roots = .{ .slots = .{ args[0], args[1], .nil_val } }; + r.push(); + defer r.pop(); + r.slots[2] = try lazy_seq.allocNative(rt, iterateNextV(), args[0], args[1]); + return try list.consSeqHeap(rt, args[1], r.slots[2]); +} + +/// PERF: realize the element after `prev`: `y = (f prev)`, then +/// `(y . )`. `f` runs once per element, when that element is +/// first reached, as in clj's Iterate.next [refs: O-110] +fn iterateNext(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + const f = args[0]; + // GC-ROOT: [f, y, tail], as mapStep. + var r: Roots = .{ .slots = .{ f, .nil_val, .nil_val } }; + r.push(); + defer r.pop(); + r.slots[1] = try invokeCallable(rt, env, f, &.{args[1]}, loc); + r.slots[2] = try lazy_seq.allocNative(rt, iterateNextV(), f, r.slots[1]); + return try list.consSeqHeap(rt, r.slots[1], r.slots[2]); +} + +fn iterateNextV() Value { + return Value.initBuiltinFn(&iterateNext); +} + +fn NodeFn(comptime name: []const u8, comptime stepV: fn () Value) type { + return struct { + fn call(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + _ = env; + try error_catalog.checkArity(name, args, 2, loc); + return try lazy_seq.allocNative(rt, stepV(), args[0], args[1]); + } + }; +} + +/// `(take n coll)` with a non-integer n stays in core.clj; reject it here so +/// a wrong caller fails loudly instead of truncating. +fn takeNode(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + try error_catalog.checkArity("__take-lazy", args, 2, loc); + _ = env; + const n: Value = switch (args[0].tag()) { + .integer => args[0], + // A Long past the inline i48 range (`int?`, heap-boxed, D-165): no + // seq is that long, so a positive one takes everything and a negative + // one takes nothing, exactly as the count-down would. + .big_int => if ((big_int.toI64(args[0]) catch 0) > 0) Value.initInteger(nb_i48_max) else Value.initInteger(0), + else => return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "__take-lazy", .expected = "integer", .actual = @tagName(args[0].tag()) }), + }; + return try lazy_seq.allocNative(rt, takeStepV(), n, args[1]); +} + +pub fn register(env: *Env, ns: *env_mod.Namespace) !void { + _ = try env.intern(ns, "__map-lazy", Value.initBuiltinFn(&NodeFn("__map-lazy", mapStepV).call), null); + _ = try env.intern(ns, "__filter-lazy", Value.initBuiltinFn(&NodeFn("__filter-lazy", filterStepV).call), null); + _ = try env.intern(ns, "__iterate-lazy", Value.initBuiltinFn(&NodeFn("__iterate-lazy", iterateStepV).call), null); + _ = try env.intern(ns, "__take-lazy", Value.initBuiltinFn(&takeNode), null); +} diff --git a/src/runtime/lazy_seq.zig b/src/runtime/lazy_seq.zig index 3c1c2ed67..7cc602ee6 100644 --- a/src/runtime/lazy_seq.zig +++ b/src/runtime/lazy_seq.zig @@ -70,7 +70,10 @@ pub const LazySeq = extern struct { /// a thread holds the CAS-claim and is invoking the thunk). Accessed /// only via `@atomicLoad`/`@cmpxchgStrong`/`@atomicStore` in `force`. realized_flag: u8 = 0, - _pad: [5]u8 = .{ 0, 0, 0, 0, 0 }, + /// 1 when `thunk` is a native step (a builtin_fn called with + /// `[step_a step_b]`) instead of an arity-0 closure (O-110). + native: u8 = 0, + _pad: [4]u8 = .{ 0, 0, 0, 0 }, /// Arity-0 Fn Value that produces the body. Released (nil) once the /// node is realised, so the closure and what it captured can be /// collected (JVM `fn = null`); kept across a thrown realisation so the @@ -88,6 +91,11 @@ pub const LazySeq = extern struct { /// (NOT the user meta map) so `(meta (map …))` stays nil = clj parity. Inert /// to every seq op (first/rest/seq/take force the thunk normally). [refs: O-023] fuse: Value = Value.nil_val, + /// PERF: O-110 the two operands of a native step (`native == 1`), e.g. + /// `f` and the source seq of a one-coll `map` node. Released with the + /// thunk once the node is realised. [refs: O-110] + step_a: Value = Value.nil_val, + step_b: Value = Value.nil_val, comptime { std.debug.assert(@alignOf(LazySeq) >= 8); @@ -118,6 +126,27 @@ pub fn alloc(rt: *Runtime, thunk_fn: Value) !Value { return Value.encodeHeapPtr(.lazy_seq, ls); } +/// PERF: a lazy node whose body is a native step: realizing it calls the +/// builtin `step` with `[a b]` instead of an interpreted arity-0 closure, so +/// a core producer (map/filter/take/iterate) costs no closure allocation and +/// no eval re-entry per node. Same at-most-once realization. Callers root +/// `a`/`b` across this alloc. [refs: O-110] +pub fn allocNative(rt: *Runtime, step: Value, a: Value, b: Value) !Value { + const ls = try rt.gc.alloc(LazySeq); + ls.* = .{ .header = HeapHeader.init(.lazy_seq), .native = 1, .thunk = step, .step_a = a, .step_b = b }; + return Value.encodeHeapPtr(.lazy_seq, ls); +} + +/// Invoke a node's body: the native step on its operands, or the closure. +fn callBody(rt: *Runtime, env: *env_mod.Env, ls: *LazySeq, loc: SourceLocation) anyerror!Value { + if (ls.native == 1) { + const step = ls.thunk.asBuiltinFn(@import("dispatch.zig").BuiltinFn); + return step(rt, env, &.{ ls.step_a, ls.step_b }, loc); + } + const vt = rt.vtable orelse return error.LazySeqVTableNotInstalled; + return vt.callFn(rt, env, ls.thunk, &[_]Value{}, loc); +} + /// True iff the LazySeq's thunk has already been forced. Powers /// `(realized? ls)` introspection (the `realized_flag` discriminator). pub fn isRealised(v: Value) bool { @@ -166,16 +195,12 @@ fn realize(rt: *Runtime, env: *env_mod.Env, v: Value, loc: SourceLocation, compt // Try to win the realise window. Loser falls through to // re-observe (the winner is now CLAIMING). if (@cmpxchgStrong(u8, &ls.realized_flag, flag_pending, flag_claiming, .acq_rel, .acquire) != null) continue; - const vt = rt.vtable orelse { - @atomicStore(u8, &ls.realized_flag, flag_pending, .release); - return error.LazySeqVTableNotInstalled; - }; var gc_roots: [1]Value = .{.nil_val}; var gc_sp: u16 = 1; var gc_frame: root_set.EvalFrame = .{ .stack = &gc_roots, .sp = &gc_sp, .locals = &.{}, .parent = root_set.eval_frame_head }; root_set.eval_frame_head = &gc_frame; defer root_set.eval_frame_head = gc_frame.parent; - const raw = vt.callFn(rt, env, ls.thunk, &[_]Value{}, loc) catch |e| { + const raw = callBody(rt, env, ls, loc) catch |e| { @atomicStore(u8, &ls.realized_flag, flag_pending, .release); return e; }; @@ -186,6 +211,8 @@ fn realize(rt: *Runtime, env: *env_mod.Env, v: Value, loc: SourceLocation, compt }; ls.realized = stored; ls.thunk = .nil_val; + ls.step_a = .nil_val; + ls.step_b = .nil_val; // Publish: the release-store fences the plain `realized` / // `thunk` writes so a fast-path acquire-load observes them // (clj's volatile-lock visibility trick). @@ -236,6 +263,8 @@ pub fn traceLazySeq(gc_ptr: *anyopaque, header: *HeapHeader) void { if (ls.realized.heapHeader()) |h| mark_sweep.mark(gc, h); if (ls.meta.heapHeader()) |h| mark_sweep.mark(gc, h); if (ls.fuse.heapHeader()) |h| mark_sweep.mark(gc, h); + if (ls.step_a.heapHeader()) |h| mark_sweep.mark(gc, h); + if (ls.step_b.heapHeader()) |h| mark_sweep.mark(gc, h); } /// Register the LazySeq trace fn into `tag_ops.tag_trace_table`. @@ -281,7 +310,7 @@ fn copy(rt: *Runtime, v: Value) !*LazySeq { @atomicStore(u8, &ls.realized_flag, flag_pending, .release); }; const nls = try rt.gc.alloc(LazySeq); - nls.* = .{ .header = HeapHeader.init(.lazy_seq), .realized_flag = flag, .thunk = ls.thunk, .realized = ls.realized, .meta = ls.meta, .fuse = ls.fuse }; + nls.* = .{ .header = HeapHeader.init(.lazy_seq), .realized_flag = flag, .native = ls.native, .thunk = ls.thunk, .realized = ls.realized, .meta = ls.meta, .fuse = ls.fuse, .step_a = ls.step_a, .step_b = ls.step_b }; return nls; } } diff --git a/test/clj/suites/lazy_step_test.clj b/test/clj/suites/lazy_step_test.clj new file mode 100644 index 000000000..06fea7980 --- /dev/null +++ b/test/clj/suites/lazy_step_test.clj @@ -0,0 +1,88 @@ +;; Native lazy producers (O-110): one-coll map, filter, take and iterate +;; realize their nodes in Zig (lazy_step.zig) instead of through an +;; interpreted arity-0 closure. The node shape is clj's own, so WHAT is +;; realized and WHEN must not change. Every count below was measured on bb +;; (clj semantics): an unchunked source (iterate, a list) realizes one +;; element per node, a chunked one (a range, a vector) a chunk of 32. +(ns suites.lazy-step-test + (:require [clojure.test :refer [deftest is]])) + +(defn- counting [log f] (fn [x] (swap! log inc) (f x))) + +(deftest iterate-runs-f-only-for-reached-elements + (let [log (atom 0) + s (iterate (counting log inc) 0)] + (is (= 0 (first s))) + (is (= 0 @log)) ; first never calls f + (is (= 3 (nth s 3))) + (is (= 3 @log)) + (is (= [0 1 2 3] (vec (take 4 s)))) + (is (= 3 @log)))) ; realized elements are cached + +(deftest map-over-an-unchunked-source-is-one-call-per-node + (let [log (atom 0) + m (map (counting log inc) (iterate inc 0))] + (is (false? (realized? m))) + (is (= 0 @log)) ; building the seq calls nothing + (is (= 1 (first m))) + (is (= 1 @log)) + (is (= [1 2 3] (vec (take 3 m)))) + (is (= 3 @log))) + (let [log (atom 0)] + (is (= '(2 3 4) (map (counting log inc) '(1 2 3)))) + (is (= 3 @log)))) + +(deftest filter-over-an-unchunked-source-stops-at-the-first-survivor + (let [log (atom 0) + s (filter (counting log odd?) (iterate inc 0))] + (is (= 1 (first s))) + (is (= 2 @log)) ; 0 rejected, 1 kept + (is (= [1 3 5] (vec (take 3 s)))) + (is (= 6 @log))) + ;; a long run of rejects realizes in one step and does not grow the stack + (is (= 100000 (first (filter #(= % 100000) (iterate inc 0))))) + (is (= '() (filter odd? '(2 4 6)))) + (is (nil? (seq (filter odd? (take 100000 (iterate (partial + 2) 0))))))) + +(deftest chunked-sources-still-realize-a-chunk-per-node + (let [log (atom 0)] + (is (= 0 (first (map (counting log identity) (range 100))))) + (is (= 32 @log))) + (let [log (atom 0)] + (is (= 1 (first (filter (counting log odd?) (range 100))))) + (is (= 32 @log))) + (let [log (atom 0)] + ;; an all-rejected chunk is skipped inside the same realization + (is (= 40 (first (filter (counting log #(>= % 40)) (range 100))))) + (is (= 64 @log))) + (is (true? (chunked-seq? (seq (map inc (range 40)))))) + (is (true? (chunked-seq? (seq (filter odd? (range 40))))))) + +(deftest take-counts-down-like-clj + (let [log (atom 0) + s (take 3 (map (counting log inc) (iterate inc 0)))] + (is (= 0 @log)) + (is (= [1 2 3] (vec s))) + (is (= 3 @log))) ; the element after n is never reached + (is (= '() (take 0 (iterate inc 0)))) + (is (= '() (take -1 [1 2]))) + (is (= [1 2] (vec (take 5 [1 2])))) + (is (= [0 1 2] (vec (take 2.5 (range 10))))) ; a non-integer n counts down too + (is (= [0 1] (vec (take 9007199254740993 [0 1])))) + (is (= '() (take -9007199254740993 [0 1]))) + (is (= 2500000000 (reduce + (take 50000 (filter odd? (map #(* 1 %) (iterate inc 0)))))))) + +(deftest a-throwing-step-propagates + (let [m (map (fn [x] (throw (ex-info "boom" {:x x}))) (iterate inc 7))] + (is (= {:x 7} (try (first m) (catch Exception e (ex-data e))))))) + +(deftest native-nodes-behave-as-lazy-seqs + (let [m (map inc (iterate inc 0))] + (is (instance? clojure.lang.LazySeq m)) + (is (nil? (meta m))) + (is (= {:a 1} (meta (with-meta m {:a 1})))) + (is (= 1 (first (with-meta m {:a 1})))) + (is (= '(2 3 4) (take 3 (rest (map inc (iterate inc 0))))))) + (is (= "(1 2 3)" (pr-str (take 3 (map inc (iterate inc 0)))))) + (is (= [2 4] (into [] (comp (map inc) (filter even?)) (take 4 (iterate inc 0))))) + (is (= 2500 (reduce + (map inc (filter even? (take 100 (iterate inc 0)))))))) From dfa62a359b8bca44333b724e1f311dd0c7784db2 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 19:51:38 -0300 Subject: [PATCH 09/51] docs(changelog): native lazy nodes entry; bench row for 8cae4fc8 lazy_chain 13.75x -> 2.28x bb on the cherry-pick of #91 onto v1.15.4 staging (bench/vs_bb -n 3, load 12.6). Smell-audited: 1: changelog and bench row only, no code --- CHANGELOG.md | 7 +++++++ bench/vs_bb/history.yaml | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0301a48e1..c67a837d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Changed + +- **Lazy `map`, `filter`, `take` and `iterate` realize their nodes natively + (O-110).** A lazy chain no longer re-enters the evaluator per element to + step a `lazy-seq` thunk. `bench/vs_bb` lazy_chain went from 13.75x bb to + 2.28x bb (1315 ms vs bb 576 ms in-process), with peak RSS 117 MB vs bb 198. + ## [1.15.4] - 2026-10-23 ### Added diff --git a/bench/vs_bb/history.yaml b/bench/vs_bb/history.yaml index ffbcc05f9..b9df26a0d 100644 --- a/bench/vs_bb/history.yaml +++ b/bench/vs_bb/history.yaml @@ -72,3 +72,30 @@ cljw: {inproc_ms: 1452, wall_ms: 1563, rss_kb: 172408} bb: {inproc_ms: 448, wall_ms: 500, rss_kb: 167600} jvm: {inproc_ms: 450, wall_ms: 1667, rss_kb: 558992} +- date: "2026-10-23T22:51:12Z" + commit: "8cae4fc8" + host: "Linux x86_64" + ci: false + load: "12.64" + runs: 3 + wall_source: min-of-n + versions: + cljw: "ClojureWasm v1.15.4 (ReleaseSafe, wasm)" + bb: "babashka v1.13.224" + startup_ms: + cljw: 22 + bb: 36 + geomean_inproc: {cljw_over_bb: 4.48, cljw_over_jvm: -} + workloads: + gc_churn: + cljw: {inproc_ms: 1338, wall_ms: 1454, rss_kb: 361188} + bb: {inproc_ms: 595, wall_ms: 650, rss_kb: 187248} + lazy_chain: + cljw: {inproc_ms: 1315, wall_ms: 1428, rss_kb: 119996} + bb: {inproc_ms: 576, wall_ms: 654, rss_kb: 203296} + reduce_map: + cljw: {inproc_ms: 4948, wall_ms: 5034, rss_kb: 119504} + bb: {inproc_ms: 641, wall_ms: 695, rss_kb: 172836} + transduce: + cljw: {inproc_ms: 3446, wall_ms: 3469, rss_kb: 18784} + bb: {inproc_ms: 340, wall_ms: 374, rss_kb: 113948} From da2b19bd1b84cd540f4891edd779324cbfd44959 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 19:56:52 -0300 Subject: [PATCH 10/51] docs(changelog): VM inline f64 arithmetic entry (O-065) Smell-audited: 1: changelog only, no code --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0301a48e1..366af58f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Changed + +- **Double arithmetic and comparison stay inline in the VM (O-065).** A double + paired with a double or a long computes `+ - * < <= > >=` in f64 without the + builtin var call. Instruction counts: dot product over 1k doubles -12%, + over 100k -11%, a float `loop`/`recur` -26%. + ## [1.15.4] - 2026-10-23 ### Added From 127f4b0e1441039952f45ad6054b216c61202763 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 19:58:17 -0300 Subject: [PATCH 11/51] build(zwasm): pin fork PR #7, preview1-adapted components link zwasm d77568a0 (BuddhiLW/zwasm fix/wasi-p2-adapter-imports, upstream zwasm/zwasm#634): a component built with the wasi_snapshot_preview1 adapter no longer fails wasm/load-component with UnknownImport. The adapter's env::memory re-export resolves, and filesystem-error-code plus the 0.2 clock resolution rows are served. This pins a fix branch only until fork #7 merges (fork Actions are disabled); the merge has the same tree, so the .hash carries over. --- build.zig.zon | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7da3d294a..c9f2cb488 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -14,6 +14,17 @@ // zwasm v2 embedding API (F-001 / D-037). `lazy` so the DEFAULT build + // gate never resolve it — only `-Dwasm` (b.lazyDependency) does. // + // 2026-10-23: pinned to d77568a0, the head of fork PR #7 + // (fix/wasi-p2-adapter-imports, one commit on fork `main` e9d74934): + // a component built with the wasi_snapshot_preview1 adapter + // (`wasm-tools component new --adapt`, any preview1 guest) links. + // The adapter's `env::memory` re-export now resolves, and three 0.2 + // rows are served (filesystem-error-code, wall-clock/monotonic-clock + // `resolution`). Upstream: zwasm/zwasm#634. This pins a fix branch + // only because the fork's Actions are disabled, so #7 cannot merge + // with CI green. Its merge commit has the same tree, so the `.hash` + // stays valid: re-point `?ref=main#` once #7 lands. + // // 2026-10-22: the fork's `main` is cljw's zwasm integration line: // upstream zwasm/zwasm `main` plus patches found and proven here, each // listed with its evidence in the fork's FORK.md and upstreamed once a @@ -80,8 +91,8 @@ // moment anything else claims that name. The `.hash` is // content-addressed, so the host of the URL never changes it. .zwasm = .{ - .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=main#e9d74934ec3212e502b101feab7673bf11c910ba", - .hash = "zwasm-2.7.0-FT1Fv3v5ngAavfQgV-JntjzckyYqMLUUCfYssfSrFN9S", + .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=fix/wasi-p2-adapter-imports#d77568a0fb25663896beaebbd6e738aed0f04d92", + .hash = "zwasm-2.7.0-FT1FvxgznwA-KsQhDxkCOpB9xz0RCYigMGg4YiAOLMBh", .lazy = true, }, }, From 683480a8553b34103fb9263ab04528a8b1e0b76e Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 21:29:48 -0300 Subject: [PATCH 12/51] perf(collection): size vector tails and array maps to their contents A vector's TailNode carried an inline [32]Value (a 272-byte cell for a 2-vector) and an ArrayMap an inline [16]Value (152 bytes for a 2-key map). Both now keep their slots as trailing storage in the same GC cell (allocWithTail, the ADR-0184 shape), sized to len / 2*count and read through items() / entries(). Every persistent change copies the cell anyway, so the reserve bought nothing. 200k retained {:id i :v [i (inc i)]}: last_live 97.2 -> 29.6 MB (486 -> 148 B per entry). gc_churn: bytes_alloc 497 -> 175 MB, peak RSS 353 -> 160 MB. Instructions per literal are unchanged (perf stat, pinned): the remaining time gap to bb is VM dispatch, recorded in O-091. Smell-audited: 1: removes a fixed-capacity reserve that no persistent op used; one cell per node kept, no new seam --- .dev/optimizations.md | 1 + CHANGELOG.md | 8 + src/lang/primitive/walk.zig | 2 +- src/runtime/collection/map.zig | 158 +++++++++++------- .../transient/transient_array_map.zig | 2 +- src/runtime/collection/vector.zig | 121 ++++++++------ src/runtime/print.zig | 8 +- 7 files changed, 179 insertions(+), 121 deletions(-) diff --git a/.dev/optimizations.md b/.dev/optimizations.md index fcbafc728..5fa2c569e 100644 --- a/.dev/optimizations.md +++ b/.dev/optimizations.md @@ -127,6 +127,7 @@ | O-076 | `runtime/keyword.zig` (`stackKey` probe in `internUnlocked`/`findUnlocked`) | every `keyword` intern allocated and freed a `ns/name` lookup key even on a hit | probe the table with a stack-built key (or the name itself when there is no ns); allocate only on a miss or a key over 128 bytes | `(keyword "abc")` x500k 235→104 ms; vector_assoc ~950→~800 ms in-proc (A/B, loaded box) | `=` identity of re-interned long/ns keywords, `find-keyword` hit/miss | O-051 | | O-077 | `lang/primitive/chunk_transform.zig` (`__dorun`) + `core.clj` `dorun` 1-arity | `dorun` (and so `doall`) stepped `(next s)` through the interpreter per element | native walk: chunked_cons skips its whole realized chunk via `chunk-rest`, other seqs step with `seqable.next` (realization runs once per element, in order) | `(doall (map identity (range 2000)))` x250 225→135 ms | side-effect counts, lazy/concat mixes, nil/vector/string/map, non-seqable raises IAE | O-032 | | O-090 | `eval/bytecode/serialize.zig` (`readValue` `.list` arm), cold-start axis | Each quoted-list constant decoded from the AOT bootstrap blob took its forward-read scratch buffer from `std.heap.page_allocator` | The buffer comes from the reader's allocator (`ctx.allocator`), as `readCollection` already does; the count header is bounds-checked against the remaining bytes first | `page_allocator` is one `mmap` + `munmap` + first-touch page fault per list, 927 per cold start. `perf stat -r 20` on `cljw -e nil`, ReleaseSafe: task-clock 38.8 → 27.3 ms, kernel instructions 22.2M → 7.9M, page faults 2741 → 1792 (bb: 29.1 ms, 4868 faults); pinned `taskset -c 2`: kernel instructions 29.3M → 12.3M (bb 26.8M), user unchanged 61.6M (bb 16.2M, the residual is D-610) | `test/run_all.sh --smoke` + `perf stat`/`strace -c` A/B | card CLJW-PERF-STARTUP | +| O-091 | `runtime/collection/vector.zig` (`TailNode`) + `runtime/collection/map.zig` (`ArrayMap`), memory axis | A vector tail was a fixed 32-slot cell (272 B) and an array map a fixed 16-slot entry array (152 B), so `[i i]` and `{:a 1 :b 2}` paid for 32 and 8 slots | Slots / entries trail the head in the same GC cell (`allocWithTail`, the ADR-0184 shape), sized to `len` / `2*count`; every persistent change copies the cell anyway, so a reserve bought nothing | 200k retained `{:id i :v [i (inc i)]}`: last_live 97.2 → 29.6 MB (486 → 148 B/entry); gc_churn bytes_alloc 497 → 175 MB. Instructions per `{:a i :b [i i i]}` unchanged (perf stat, pinned): the remaining 2.5x vs bb is per-op dispatch, not cell size | zig unit tests (cell-size asserts) + `test/run_all.sh --smoke` | card CLJW-PERF-SMALL-LITERAL | ## Identified high-ROI candidates (measured, not yet implemented) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0301a48e1..33f340642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Changed + +- A small vector's tail and an array map's entries are now sized to their + element count inside one GC cell, not a fixed 32-slot tail (272 bytes) and + a fixed 16-slot entry array (152 bytes). 200k retained `{:id i :v [i (inc i)]}` + hold 29.6 MB live instead of 97.2 MB (148 bytes per entry, was 486), and + `bench/vs_bb/workloads/gc_churn.clj` allocates 175 MB instead of 497 MB. + ## [1.15.4] - 2026-10-23 ### Added diff --git a/src/lang/primitive/walk.zig b/src/lang/primitive/walk.zig index f718d379f..a99776b69 100644 --- a/src/lang/primitive/walk.zig +++ b/src/lang/primitive/walk.zig @@ -215,7 +215,7 @@ fn rebuildArrayMap( // Hand the walk fn a real map ENTRY, as clj does — it used to get a plain // 2-vector, so `(postwalk #(do (key %) %) {:a 1})` threw once key/val // began requiring an entry. - const entry = try map_entry_collection.make(rt, am.entries[2 * i], am.entries[2 * i + 1]); + const entry = try map_entry_collection.make(rt, am.entries()[2 * i], am.entries()[2 * i + 1]); gc_roots[1] = result; gc_roots[2] = entry; const transformed = try transform_child(rt, env, ctx, entry, loc); diff --git a/src/runtime/collection/map.zig b/src/runtime/collection/map.zig index 92949778a..59d13dcb4 100644 --- a/src/runtime/collection/map.zig +++ b/src/runtime/collection/map.zig @@ -50,12 +50,14 @@ const hash = @import("../hash.zig"); pub const ARRAY_MAP_THRESHOLD: u8 = 8; /// ArrayMap — linear-scan map for ≤ 8 entries. Entries are stored as -/// `[k0, v0, k1, v1, ...]` in `entries[0..2*count]`. +/// `[k0, v0, k1, v1, ...]`, exactly `2*count` Values trailing the struct in +/// the same GC cell (the ADR-0184 shape). Every assoc / dissoc copies the map, +/// so a 16-slot reserve bought nothing and made a 2-key map a 152-byte cell. +// PERF: exact-size entries, 56 bytes for a 2-key map instead of 152 [refs: O-091] pub const ArrayMap = extern struct { header: HeapHeader, _pad: [2]u8 = .{ 0, 0 }, count: u32 = 0, - entries: [2 * ARRAY_MAP_THRESHOLD]Value = @splat(Value.nil_val), /// Metadata map (or nil). Same-type ops (assoc/dissoc) preserve it; /// `with-meta` sets it on a copy (D-075). meta: Value = Value.nil_val, @@ -63,9 +65,32 @@ pub const ArrayMap = extern struct { comptime { std.debug.assert(@alignOf(ArrayMap) >= 8); std.debug.assert(@offsetOf(ArrayMap, "header") == 0); + std.debug.assert(@sizeOf(ArrayMap) % @alignOf(Value) == 0); + } + + /// The `2*count` flat key/value Values. + pub fn entries(self: *const ArrayMap) []const Value { + const base: [*]const Value = @ptrCast(@alignCast(@as([*]const u8, @ptrCast(self)) + @sizeOf(ArrayMap))); + return base[0 .. 2 * @as(usize, self.count)]; + } + + /// The entries, writable: only for a map still being built. + pub fn entriesMut(self: *ArrayMap) []Value { + const base: [*]Value = @ptrCast(@alignCast(@as([*]u8, @ptrCast(self)) + @sizeOf(ArrayMap))); + return base[0 .. 2 * @as(usize, self.count)]; } }; +/// A fresh ArrayMap cell for `n` entries (1..ARRAY_MAP_THRESHOLD), entries +/// nil until the caller fills them, so the trace never reads garbage. +fn newArrayMap(rt: *Runtime, n: u32, meta: Value) !*ArrayMap { + std.debug.assert(n >= 1 and n <= ARRAY_MAP_THRESHOLD); + const am = try rt.gc.allocWithTail(ArrayMap, 2 * @as(usize, n) * @sizeOf(Value)); + am.* = .{ .header = HeapHeader.init(.array_map), .count = n, .meta = meta }; + @memset(am.entriesMut(), Value.nil_val); + return am; +} + /// HamtMapNode — CHAMP-style HAMT bitmap-indexed node. `data_map` /// marks slots holding K/V pairs; `node_map` marks slots holding /// child node pointers. The slots region holds K/V pairs in slot @@ -158,7 +183,7 @@ pub fn forEachEntry( const am = v.decodePtr(*const ArrayMap); var i: u32 = 0; while (i < am.count) : (i += 1) { - try cb(ctx, am.entries[2 * i], am.entries[2 * i + 1]); + try cb(ctx, am.entries()[2 * i], am.entries()[2 * i + 1]); } }, .hash_map => { @@ -207,7 +232,7 @@ fn arrayMapKeywordSlot(am: *const ArrayMap, kw: Value) ?u32 { const kb = @intFromEnum(kw); var i: u32 = 0; while (i < am.count) : (i += 1) { - if (@intFromEnum(am.entries[2 * i]) == kb) return i; + if (@intFromEnum(am.entries()[2 * i]) == kb) return i; } return null; } @@ -250,11 +275,12 @@ pub fn allSimpleKeys(pairs: []const Value) bool { /// therefore cannot trigger GC, so the freshly-alloc'd `am` is safe WITHOUT a /// rooting frame (last-key-wins, matching the assoc-fold). [refs: O-026, D-386] pub fn fromLiteralPairs(rt: *Runtime, pairs: []const Value) !Value { - // D-244 #4: single ArrayMap alloc, filled from the rooted operand stack — - // no multi-alloc internal window, so no fabrication region needed (the - // alloc-torture is the completeness guard if this ever grows a 2nd alloc). - const am = try rt.gc.alloc(ArrayMap); - am.* = .{ .header = HeapHeader.init(.array_map) }; + // D-244 #4: single ArrayMap alloc, filled from the rooted operand stack, + // so no fabrication region is needed (the alloc-torture is the + // completeness guard if this ever grows a 2nd alloc). The dedup runs into + // a stack buffer first (simple keys keep keyEq pure; the buffer holds only + // values `pairs` already roots), so the cell is sized to the distinct keys. + var buf: [2 * ARRAY_MAP_THRESHOLD]Value = undefined; var w: u32 = 0; var i: usize = 0; while (i < pairs.len) : (i += 2) { @@ -263,19 +289,20 @@ pub fn fromLiteralPairs(rt: *Runtime, pairs: []const Value) !Value { var found = false; var j: u32 = 0; while (j < w) : (j += 1) { - if (try keyEq(am.entries[2 * j], k)) { - am.entries[2 * j + 1] = val; + if (try keyEq(buf[2 * j], k)) { + buf[2 * j + 1] = val; found = true; break; } } if (!found) { - am.entries[2 * w] = k; - am.entries[2 * w + 1] = val; + buf[2 * w] = k; + buf[2 * w + 1] = val; w += 1; } } - am.count = w; + const am = try newArrayMap(rt, w, Value.nil_val); + @memcpy(am.entriesMut(), buf[0 .. 2 * w]); return Value.encodeHeapPtr(.array_map, am); } @@ -304,7 +331,7 @@ fn assocArrayMap(rt: *Runtime, am: *const ArrayMap, k: Value, val: Value) !Value var found_idx: ?u32 = null; var i: u32 = 0; while (i < am.count) : (i += 1) { - if (try keyEq(am.entries[2 * i], k)) { + if (try keyEq(am.entries()[2 * i], k)) { found_idx = i; break; } @@ -312,9 +339,10 @@ fn assocArrayMap(rt: *Runtime, am: *const ArrayMap, k: Value, val: Value) !Value if (found_idx) |idx| { // Replace value at idx — copy the map with the one slot updated. - const new_am = try rt.gc.alloc(ArrayMap); - new_am.* = .{ .header = HeapHeader.init(.array_map), .count = am.count, .entries = am.entries, .meta = am.meta }; - new_am.entries[2 * idx + 1] = val; + const new_am = try newArrayMap(rt, am.count, am.meta); + const out = new_am.entriesMut(); + @memcpy(out, am.entries()); + out[2 * idx + 1] = val; return Value.encodeHeapPtr(.array_map, new_am); } @@ -323,10 +351,11 @@ fn assocArrayMap(rt: *Runtime, am: *const ArrayMap, k: Value, val: Value) !Value return try promoteArrayMap(rt, am, k, val); } - const new_am = try rt.gc.alloc(ArrayMap); - new_am.* = .{ .header = HeapHeader.init(.array_map), .count = am.count + 1, .entries = am.entries, .meta = am.meta }; - new_am.entries[2 * am.count] = k; - new_am.entries[2 * am.count + 1] = val; + const new_am = try newArrayMap(rt, am.count + 1, am.meta); + const out = new_am.entriesMut(); + @memcpy(out[0 .. 2 * am.count], am.entries()); + out[2 * am.count] = k; + out[2 * am.count + 1] = val; return Value.encodeHeapPtr(.array_map, new_am); } @@ -351,7 +380,7 @@ fn dissocArrayMap(rt: *Runtime, am: *const ArrayMap, original: Value, k: Value) var found_idx: ?u32 = null; var i: u32 = 0; while (i < am.count) : (i += 1) { - if (try keyEq(am.entries[2 * i], k)) { + if (try keyEq(am.entries()[2 * i], k)) { found_idx = i; break; } @@ -365,19 +394,15 @@ fn dissocArrayMap(rt: *Runtime, am: *const ArrayMap, original: Value, k: Value) } // Copy + shift entries after the removed slot down by one K/V pair. - const new_am = try rt.gc.alloc(ArrayMap); - new_am.* = .{ - .header = HeapHeader.init(.array_map), - .count = am.count - 1, - .entries = @splat(Value.nil_val), - .meta = am.meta, - }; + const new_am = try newArrayMap(rt, am.count - 1, am.meta); + const out = new_am.entriesMut(); + const src = am.entries(); var write: u32 = 0; var read: u32 = 0; while (read < am.count) : (read += 1) { if (read == found_idx.?) continue; - new_am.entries[2 * write] = am.entries[2 * read]; - new_am.entries[2 * write + 1] = am.entries[2 * read + 1]; + out[2 * write] = src[2 * read]; + out[2 * write + 1] = src[2 * read + 1]; write += 1; } return Value.encodeHeapPtr(.array_map, new_am); @@ -388,10 +413,8 @@ fn dissocArrayMap(rt: *Runtime, am: *const ArrayMap, original: Value, k: Value) pub fn traceArrayMap(gc_ptr: *anyopaque, header: *HeapHeader) void { const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr)); const am: *ArrayMap = @ptrCast(@alignCast(header)); - var i: u32 = 0; - while (i < am.count) : (i += 1) { - if (am.entries[2 * i].heapHeader()) |h| mark_sweep.mark(gc, h); - if (am.entries[2 * i + 1].heapHeader()) |h| mark_sweep.mark(gc, h); + for (am.entries()) |e| { + if (e.heapHeader()) |h| mark_sweep.mark(gc, h); } if (am.meta.heapHeader()) |h| mark_sweep.mark(gc, h); } @@ -438,10 +461,11 @@ pub fn entryAt(v: Value, k: Value) !?Entry { const am = v.decodePtr(*const ArrayMap); // Keywords are interned, so the probe IS the stored key. if (k.tag() == .keyword) - break :blk if (arrayMapKeywordSlot(am, k)) |i| Entry{ .key = k, .val = am.entries[2 * i + 1] } else null; + break :blk if (arrayMapKeywordSlot(am, k)) |i| Entry{ .key = k, .val = am.entries()[2 * i + 1] } else null; + const es = am.entries(); var i: u32 = 0; while (i < am.count) : (i += 1) { - if (try keyEq(am.entries[2 * i], k)) break :blk Entry{ .key = am.entries[2 * i], .val = am.entries[2 * i + 1] }; + if (try keyEq(es[2 * i], k)) break :blk Entry{ .key = es[2 * i], .val = es[2 * i + 1] }; } break :blk null; }, @@ -485,7 +509,7 @@ fn keysArrayMap(rt: *Runtime, am: *const ArrayMap) !Value { var i: i32 = @intCast(am.count); while (i > 0) { i -= 1; - result = try list_mod.consHeap(rt, am.entries[@intCast(2 * i)], result); + result = try list_mod.consHeap(rt, am.entries()[@intCast(2 * i)], result); } return result; } @@ -510,7 +534,7 @@ fn valsArrayMap(rt: *Runtime, am: *const ArrayMap) !Value { var i: i32 = @intCast(am.count); while (i > 0) { i -= 1; - result = try list_mod.consHeap(rt, am.entries[@intCast(2 * i + 1)], result); + result = try list_mod.consHeap(rt, am.entries()[@intCast(2 * i + 1)], result); } return result; } @@ -543,7 +567,7 @@ fn seqArrayMap(rt: *Runtime, am: *const ArrayMap) !Value { i -= 1; // A distinct MapEntry (D-209 / ADR-0078), not a 2-vector, so // `(map-entry? (first {…}))`→true while it still behaves as `[k v]`. - const pair = try map_entry_mod.make(rt, am.entries[@intCast(2 * i)], am.entries[@intCast(2 * i + 1)]); + const pair = try map_entry_mod.make(rt, am.entries()[@intCast(2 * i)], am.entries()[@intCast(2 * i + 1)]); result = try list_mod.consHeap(rt, pair, result); } return result; @@ -600,9 +624,9 @@ fn foldHash(v: Value, comptime keys_only: bool) u32 { var i: u32 = 0; while (i < am.count) : (i += 1) acc +%= if (keys_only) - equal.valueHash(am.entries[2 * i]) + equal.valueHash(am.entries()[2 * i]) else - entryHash(am.entries[2 * i], am.entries[2 * i + 1]); + entryHash(am.entries()[2 * i], am.entries()[2 * i + 1]); }, .hash_map => if (v.decodePtr(*const PersistentHashMap).root) |root| hamtFoldHash(root, &acc, keys_only), @@ -647,7 +671,7 @@ fn subsetOf(a: Value, b: Value, comptime check_val: bool) bool { const am = a.decodePtr(*const ArrayMap); var i: u32 = 0; while (i < am.count) : (i += 1) - if (!entryMatches(am.entries[2 * i], am.entries[2 * i + 1], b, check_val)) return false; + if (!entryMatches(am.entries()[2 * i], am.entries()[2 * i + 1], b, check_val)) return false; return true; }, .hash_map => { @@ -1027,8 +1051,8 @@ fn promoteArrayMap(rt: *Runtime, am: *const ArrayMap, k: Value, val: Value) !Val }; var i: u32 = 0; while (i < am.count) : (i += 1) { - const ek = am.entries[2 * i]; - const res = try hamtAssoc(rt, root, ek, am.entries[2 * i + 1], try keyHash(ek), 0); + const ek = am.entries()[2 * i]; + const res = try hamtAssoc(rt, root, ek, am.entries()[2 * i + 1], try keyHash(ek), 0); root = res.node; } const res = try hamtAssoc(rt, root, k, val, try keyHash(k), 0); @@ -1197,8 +1221,9 @@ pub fn withMeta(rt: *Runtime, v: Value, m: Value) !Value { switch (v.tag()) { .array_map => { const am = v.decodePtr(*const ArrayMap); - const new_am = try rt.gc.alloc(ArrayMap); - new_am.* = .{ .header = HeapHeader.init(.array_map), .count = am.count, .entries = am.entries, .meta = m }; + const new_am = try rt.gc.allocWithTail(ArrayMap, am.entries().len * @sizeOf(Value)); + new_am.* = .{ .header = HeapHeader.init(.array_map), .count = am.count, .meta = m }; + @memcpy(new_am.entriesMut(), am.entries()); return Value.encodeHeapPtr(.array_map, new_am); }, .hash_map => { @@ -1236,13 +1261,25 @@ test "count on nil = 0; count on non-map = 0" { try testing.expectEqual(@as(u32, 0), count(Value.initInteger(42))); } -test "ArrayMap layout: header at offset 0, 16 slots inlined" { +test "ArrayMap layout: header at offset 0, entries trail the 24-byte head" { try testing.expectEqual(@as(usize, 0), @offsetOf(ArrayMap, "header")); + try testing.expectEqual(@as(usize, 24), @sizeOf(ArrayMap)); const am: ArrayMap = .{ .header = HeapHeader.init(.array_map) }; - try testing.expectEqual(@as(usize, 16), am.entries.len); + try testing.expectEqual(@as(usize, 0), am.entries().len); try testing.expect(@alignOf(ArrayMap) >= 8); } +test "an array map cell is sized to its entry count" { + var fix = RuntimeFixture.init(); + defer fix.deinit(); + + const k = Value.initInteger(1); + const before = fix.rt.gc.stats.bytes_allocated; + _ = try fromLiteralPairs(&fix.rt, &.{ k, Value.initInteger(2), k, Value.initInteger(3) }); + // One distinct key: head + one key/value pair. + try testing.expectEqual(@as(usize, @sizeOf(ArrayMap) + 2 * @sizeOf(Value)), fix.rt.gc.stats.bytes_allocated - before); +} + test "HamtMapNode layout: header at offset 0, 64 slots + 2 bitmaps" { try testing.expectEqual(@as(usize, 0), @offsetOf(HamtMapNode, "header")); const node: HamtMapNode = .{ .header = HeapHeader.init(.hamt_map_node) }; @@ -1258,21 +1295,16 @@ test "PersistentHashMap layout: header at offset 0" { } test "ArrayMap.get: linear scan on hand-built 3-entry map" { - var am: ArrayMap align(8) = .{ - .header = HeapHeader.init(.array_map), - .count = 3, - .entries = blk: { - var e: [16]Value = @splat(Value.nil_val); - e[0] = Value.initInteger(1); - e[1] = Value.initInteger(100); - e[2] = Value.initInteger(2); - e[3] = Value.initInteger(200); - e[4] = Value.initInteger(3); - e[5] = Value.initInteger(300); - break :blk e; + // The head and its six trailing entries, laid out as one GC cell is. + var cell: extern struct { am: ArrayMap, entries: [6]Value } align(8) = .{ + .am = .{ .header = HeapHeader.init(.array_map), .count = 3 }, + .entries = .{ + Value.initInteger(1), Value.initInteger(100), + Value.initInteger(2), Value.initInteger(200), + Value.initInteger(3), Value.initInteger(300), }, }; - const v = Value.encodeHeapPtr(.array_map, &am); + const v = Value.encodeHeapPtr(.array_map, &cell.am); try testing.expectEqual(@as(u32, 3), count(v)); try testing.expectEqual(@as(i48, 100), (try get(v, Value.initInteger(1))).asInteger()); diff --git a/src/runtime/collection/transient/transient_array_map.zig b/src/runtime/collection/transient/transient_array_map.zig index 0315d8305..71b047cf8 100644 --- a/src/runtime/collection/transient/transient_array_map.zig +++ b/src/runtime/collection/transient/transient_array_map.zig @@ -101,7 +101,7 @@ pub fn fromMap(rt: *Runtime, source: Value) !Value { tm.count = am.count; var i: u32 = 0; while (i < 2 * am.count) : (i += 1) { - tm.entries[i] = am.entries[i]; + tm.entries[i] = am.entries()[i]; } tm.meta = am.meta; }, diff --git a/src/runtime/collection/vector.zig b/src/runtime/collection/vector.zig index 2ddb358b3..a6d55f1bb 100644 --- a/src/runtime/collection/vector.zig +++ b/src/runtime/collection/vector.zig @@ -93,18 +93,34 @@ pub const HamtNode = extern struct { } }; -/// 32-element tail array. `len` tracks the populated prefix (1..32); -/// `slots[0..len]` are the live Values. When `len` reaches 32 the -/// tail promotes to a leaf HamtNode under `root` (5.4.b conj). +/// The vector's last 1..32 elements. The slots are the cell's trailing +/// storage, sized to exactly `len` (one GC cell, the ADR-0184 shape): every +/// tail change copies the tail anyway (persistent conj / pop / assoc), so a +/// 32-slot reserve buys nothing and made a 2-vector's tail a 272-byte cell. +/// When `len` reaches 32 the tail promotes to a leaf HamtNode under `root` +/// (5.4.b conj). +// PERF: exact-size tail cell, 32 bytes for a 2-vector instead of 272 [refs: O-091] pub const TailNode = extern struct { header: HeapHeader, _pad: [2]u8 = .{ 0, 0 }, len: u32 = 0, - slots: [BRANCH_FACTOR]Value = @splat(Value.nil_val), comptime { std.debug.assert(@alignOf(TailNode) >= 8); std.debug.assert(@offsetOf(TailNode, "header") == 0); + std.debug.assert(@sizeOf(TailNode) % @alignOf(Value) == 0); + } + + /// The live elements, `len` of them. + pub fn items(self: *const TailNode) []const Value { + const base: [*]const Value = @ptrCast(@alignCast(@as([*]const u8, @ptrCast(self)) + @sizeOf(TailNode))); + return base[0..self.len]; + } + + /// The live elements, writable: only for a tail still being built. + pub fn itemsMut(self: *TailNode) []Value { + const base: [*]Value = @ptrCast(@alignCast(@as([*]u8, @ptrCast(self)) + @sizeOf(TailNode))); + return base[0..self.len]; } }; @@ -140,16 +156,18 @@ inline fn copyHamt(rt: *Runtime, slots: *const [BRANCH_FACTOR]Value) !*HamtNode return c; } -/// A fresh TailNode of length `len` whose live prefix is `prefix` (len >= -/// prefix.len). Slots past `len` stay nil so the node matches a `.{}`-built -/// one; slots in `[prefix.len, len)` are the caller's to fill. PERF: [refs: O-074] +/// A fresh TailNode of length `len` (1..32) whose live prefix is `prefix` +/// (len >= prefix.len). Slots in `[prefix.len, len)` are nil until the caller +/// fills them, so the trace never reads an uninitialised slot. PERF: [refs: O-074] inline fn newTail(rt: *Runtime, len: u32, prefix: []const Value) !*TailNode { - const t = try rt.gc.alloc(TailNode); + std.debug.assert(len >= 1 and len <= BRANCH_FACTOR and prefix.len <= len); + const t = try rt.gc.allocWithTail(TailNode, @as(usize, len) * @sizeOf(Value)); t.header = HeapHeader.init(.tail_node); t._pad = .{ 0, 0 }; t.len = len; - @memcpy(t.slots[0..prefix.len], prefix); - @memset(t.slots[len..], Value.nil_val); + const slots = t.itemsMut(); + @memcpy(slots[0..prefix.len], prefix); + @memset(slots[prefix.len..], Value.nil_val); return t; } @@ -183,7 +201,7 @@ pub fn traceHamtNode(gc_ptr: *anyopaque, header: *HeapHeader) void { pub fn traceTailNode(gc_ptr: *anyopaque, header: *HeapHeader) void { const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr)); const t: *TailNode = @ptrCast(@alignCast(header)); - for (t.slots[0..t.len]) |slot| { + for (t.items()) |slot| { if (slot.heapHeader()) |h| mark_sweep.mark(gc, h); } } @@ -228,16 +246,16 @@ pub fn conj(rt: *Runtime, v: Value, x: Value) !Value { // Fast path: tail has space (< BRANCH_FACTOR elements). const tail_size = if (old.tail) |t| t.len else 0; if (tail_size < BRANCH_FACTOR) { - const prefix: []const Value = if (old.tail) |t| t.slots[0..tail_size] else &.{}; + const prefix: []const Value = if (old.tail) |t| t.items() else &.{}; const new_tail = try newTail(rt, tail_size + 1, prefix); - new_tail.slots[tail_size] = x; + new_tail.itemsMut()[tail_size] = x; return try newVector(rt, old.count + 1, old.shift, old.root, new_tail, old.meta); } // Tail-full path: promote the old tail to a leaf HamtNode under // root; new tail holds just `x`. The promoted leaf goes into the // root trie via pushTail, possibly growing the trie depth. - const tail_as_leaf = try copyHamt(rt, &old.tail.?.slots); + const tail_as_leaf = try copyHamt(rt, old.tail.?.items()[0..BRANCH_FACTOR]); const old_count = old.count; var new_shift = old.shift; @@ -266,9 +284,7 @@ pub fn conj(rt: *Runtime, v: Value, x: Value) !Value { new_shift = 0; } - const new_tail = try rt.gc.alloc(TailNode); - new_tail.* = .{ .header = HeapHeader.init(.tail_node), .len = 1 }; - new_tail.slots[0] = x; + const new_tail = try newTail(rt, 1, &.{x}); return try newVector(rt, old_count + 1, new_shift, new_root, new_tail, old.meta); } @@ -290,9 +306,7 @@ pub fn pop(rt: *Runtime, v: Value) !Value { // Fast path: tail has > 1 element — copy tail with len-1. const tail_size = if (old.tail) |t| t.len else 0; if (tail_size > 1) { - const new_tail = try rt.gc.alloc(TailNode); - new_tail.* = .{ .header = HeapHeader.init(.tail_node), .len = tail_size - 1 }; - @memcpy(new_tail.slots[0 .. tail_size - 1], old.tail.?.slots[0 .. tail_size - 1]); + const new_tail = try newTail(rt, tail_size - 1, old.tail.?.items()[0 .. tail_size - 1]); return try newVector(rt, old.count - 1, old.shift, old.root, new_tail, old.meta); } @@ -300,9 +314,7 @@ pub fn pop(rt: *Runtime, v: Value) !Value { // become the new tail. May collapse trie depth if the root's // first slot is empty after the pull. const new_tail_leaf = arrayFor(old, old.count - 2); - const new_tail = try rt.gc.alloc(TailNode); - new_tail.* = .{ .header = HeapHeader.init(.tail_node), .len = BRANCH_FACTOR }; - @memcpy(&new_tail.slots, &new_tail_leaf.slots); + const new_tail = try newTail(rt, BRANCH_FACTOR, &new_tail_leaf.slots); var new_root: ?*HamtNode = null; var new_shift = old.shift; @@ -340,8 +352,8 @@ pub fn assoc(rt: *Runtime, v: Value, i: u32, x: Value) !Value { if (i >= tailoff(old.count)) { const tail_size = if (old.tail) |t| t.len else 0; - const new_tail = try newTail(rt, tail_size, old.tail.?.slots[0..tail_size]); - new_tail.slots[i - tailoff(old.count)] = x; + const new_tail = try newTail(rt, tail_size, old.tail.?.items()); + new_tail.itemsMut()[i - tailoff(old.count)] = x; return try newVector(rt, old.count, old.shift, old.root, new_tail, old.meta); } @@ -408,9 +420,7 @@ pub fn fromSlice(rt: *Runtime, items: []const Value) !Value { const off = tailoff(n); // multiple of 32; in-root element count const tail_len = n - off; // 1..32 for n > 0 - const tail = try rt.gc.alloc(TailNode); - tail.* = .{ .header = HeapHeader.init(.tail_node), .len = tail_len }; - @memcpy(tail.slots[0..tail_len], items[off..n]); + const tail = try newTail(rt, tail_len, items[off..n]); if (off == 0) { // n ≤ 32: tail-only, no root (matches conj's tail fast path). @@ -575,7 +585,7 @@ pub fn nth(v: Value, i: u32) Value { if (i >= tailoff(vec.count)) { // In-tail path: trivial index into the 32-slot tail. const t = vec.tail orelse return Value.nil_val; - return t.slots[i - tailoff(vec.count)]; + return t.items()[i - tailoff(vec.count)]; } // In-root path: walk down the HAMT via `(i >>> level) & MASK` at @@ -632,31 +642,43 @@ test "HamtNode struct layout: header at 0, 32 slots inlined" { try testing.expect(@alignOf(HamtNode) >= 8); } -test "TailNode struct layout: header at 0, 32 slots + len" { +/// A stack TailNode with its `n` trailing slots, laid out as one GC cell is. +fn StackTail(comptime n: usize) type { + return extern struct { + node: TailNode, + slots: [n]Value, + }; +} + +test "TailNode struct layout: header at 0, slots trail the 8-byte head" { try testing.expectEqual(@as(usize, 0), @offsetOf(TailNode, "header")); - const default_tail: TailNode = .{ .header = HeapHeader.init(.tail_node) }; - try testing.expectEqual(@as(usize, BRANCH_FACTOR), default_tail.slots.len); + try testing.expectEqual(@as(usize, 16), @sizeOf(TailNode)); try testing.expect(@alignOf(TailNode) >= 8); + try testing.expectEqual(@sizeOf(TailNode), @offsetOf(StackTail(2), "slots")); +} + +test "a tail cell is sized to its length" { + var fix = RuntimeFixture.init(); + defer fix.deinit(); + + const before = fix.rt.gc.stats.bytes_allocated; + _ = try fromSlice(&fix.rt, &.{ Value.initInteger(1), Value.initInteger(2) }); + // Vector head + a 2-slot tail, not a 32-slot one. + const tail_bytes = @sizeOf(TailNode) + 2 * @sizeOf(Value); + try testing.expectEqual(@as(usize, @sizeOf(Vector) + tail_bytes), fix.rt.gc.stats.bytes_allocated - before); } test "nth in-tail path: handcrafted vector of 3 elements" { - var tail = TailNode{ - .header = HeapHeader.init(.tail_node), - .len = 3, - .slots = blk: { - var s: [BRANCH_FACTOR]Value = @splat(Value.nil_val); - s[0] = Value.initInteger(10); - s[1] = Value.initInteger(20); - s[2] = Value.initInteger(30); - break :blk s; - }, + var tail = StackTail(3){ + .node = .{ .header = HeapHeader.init(.tail_node), .len = 3 }, + .slots = .{ Value.initInteger(10), Value.initInteger(20), Value.initInteger(30) }, }; var vec = Vector{ .header = HeapHeader.init(.vector), .count = 3, .shift = 0, .root = null, - .tail = &tail, + .tail = &tail.node, }; const v = Value.encodeHeapPtr(.vector, &vec); @@ -961,21 +983,16 @@ test "nth in-root path: handcrafted shift=5 vector with 33 elements" { break :blk s; }, }; - var tail = TailNode{ - .header = HeapHeader.init(.tail_node), - .len = 1, - .slots = blk: { - var s: [BRANCH_FACTOR]Value = @splat(Value.nil_val); - s[0] = Value.initInteger(132); // index 32 = first tail elem - break :blk s; - }, + var tail = StackTail(1){ + .node = .{ .header = HeapHeader.init(.tail_node), .len = 1 }, + .slots = .{Value.initInteger(132)}, // index 32 = first tail elem }; var vec = Vector{ .header = HeapHeader.init(.vector), .count = 33, .shift = 0, // root IS the leaf (1 level) .root = &leaf0, - .tail = &tail, + .tail = &tail.node, }; const v = Value.encodeHeapPtr(.vector, &vec); diff --git a/src/runtime/print.zig b/src/runtime/print.zig index 058863060..ce666a778 100644 --- a/src/runtime/print.zig +++ b/src/runtime/print.zig @@ -968,7 +968,7 @@ fn mapCommonNs(v: Value) ?[]const u8 { var common: ?[]const u8 = null; var i: u32 = 0; while (i < am.count) : (i += 1) { - const key = am.entries[2 * i]; + const key = am.entries()[2 * i]; const kns: ?[]const u8 = switch (key.tag()) { .keyword => keyword.asKeyword(key).ns, .symbol => symbol.asSymbol(key).ns, @@ -1695,9 +1695,9 @@ pub fn printMap(ports: ?Ports, w: *Writer, v: Value) anyerror!void { while (i < am.count) : (i += 1) { if (try lengthTruncated(w, @intCast(i), ", ")) break; if (i > 0) try w.writeAll(", "); - try printMapKey(ports, w, am.entries[2 * i], strip); + try printMapKey(ports, w, am.entries()[2 * i], strip); try w.writeByte(' '); - try printValue(ports, w, am.entries[2 * i + 1]); + try printValue(ports, w, am.entries()[2 * i + 1]); } } else { const phm = v.decodePtr(*const map_collection.PersistentHashMap); // repr-decode-ok: printer dual-path, hash_map arm @@ -1724,7 +1724,7 @@ pub fn printSet(ports: ?Ports, w: *Writer, v: Value) anyerror!void { while (i < am.count) : (i += 1) { if (try lengthTruncated(w, @intCast(i), " ")) break; if (i > 0) try w.writeByte(' '); - try printValue(ports, w, am.entries[2 * i]); + try printValue(ports, w, am.entries()[2 * i]); } } else { // hash_map-backed set (> 8 elements): walk the backing map's HAMT From 378ef3e65302ae499f9e248d7d09829dc3e2837e Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 21:34:58 -0300 Subject: [PATCH 13/51] perf(string): store short string payloads inline in the GC cell A String paid two allocations: the GC cell and a separate gpa.dupe of its bytes, freed again by the per-tag finaliser at sweep. perf of (count (str i)) in a loop showed libc malloc/free at about 12% of samples. Payloads up to page_heap.max_small_bytes - @sizeOf(String) now live in the tail of the String's own cell (allocWithTail). A storage enum in a former padding byte tells the finaliser and ownedBytes which arm applies; larger payloads keep the owned infra block. The inline alloc runs inside a fabrication region, since byte_view may be the tail of an unrooted String. Pinned user instructions: str loop 2790 -> 2300 per iteration over the empty loop; vs_bb string_ops 5959M -> 5219M, gc_churn 8369M -> 8011M. Smell-audited: 1: stale module doc that argued against inline storage rewritten; no address-adjacency test, explicit storage tag instead --- CHANGELOG.md | 11 ++++ src/runtime/collection/string.zig | 101 +++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0301a48e1..12101024f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Changed + +- A string of up to about 4 KB now keeps its bytes inside its own GC cell + instead of in a separate malloc block freed by a finaliser: one allocation + per string instead of two. Measured on a pinned core (user instructions, a + noisy host made wall time unreliable): `(count (str i))` in a loop costs 2300 + instructions per iteration over the empty loop, down from 2790; bench/vs_bb + string_ops 5959M -> 5219M (-12%), gc_churn 8369M -> 8011M (-4%). Wall time + at load ~40: string_ops 2.02x -> 1.49x bb, probe str_ms 321-439 -> 245-439 + ms (bb 71-122). + ## [1.15.4] - 2026-10-23 ### Added diff --git a/src/runtime/collection/string.zig b/src/runtime/collection/string.zig index 272fe4f1b..06c6f58d8 100644 --- a/src/runtime/collection/string.zig +++ b/src/runtime/collection/string.zig @@ -1,17 +1,16 @@ // SPDX-License-Identifier: EPL-2.0 //! Heap-backed string Value. //! -//! An immutable byte slice tagged `HeapTag.string`. The wrapper struct -//! owns its own `[]u8` (duplicated from caller bytes at `alloc` time), -//! so the resulting Value outlives any per-eval arena. The bytes are -//! freed by a per-tag GC finaliser (registered via `registerGcHooks`; -//! see L24-34) before mark-sweep rawFrees the wrapper. +//! An immutable byte slice tagged `HeapTag.string`, copied from the +//! caller's bytes at `alloc` time so the Value outlives any per-eval arena. //! -//! Why not a Phase-1 small-string optimisation (inline tail of bytes -//! after the header)? Because Clojure strings are arbitrary-length and -//! the analyser's `.string` literal already arrives as a slice; one -//! `gpa.dupe` is cheaper than the branch-on-length the inline form -//! would need. A future string-interning pass could revisit this. +//! Storage is chosen by length. A payload that fits a page-heap cell +//! (`max_inline_bytes`) lives in the tail of the String's own GC cell: one +//! allocation, freed with the cell by sweep. A larger one is an `infra` +//! allocation freed by the per-tag finaliser (`registerGcHooks`). Measured +//! 2026-10-23: the separate `gpa.dupe` + finaliser `free` per String was the +//! dominant non-interpreter cost of `(str i)` in a loop (libc malloc/free +//! about 12% of the profile), on top of the cell allocation it duplicated. const std = @import("std"); const value_mod = @import("../value/value.zig"); @@ -20,6 +19,7 @@ const HeapHeader = value_mod.HeapHeader; const Runtime = @import("../runtime.zig").Runtime; const tag_ops = @import("../gc/tag_ops.zig"); const gc_heap_mod = @import("../gc/gc_heap.zig"); +const page_heap_mod = @import("../gc/page_heap.zig"); /// Heap String. `bytes_ptr` + `bytes_len` decompose what was previously /// a `[]const u8` slice; the decomposition lets the struct be `extern`, @@ -27,17 +27,25 @@ const gc_heap_mod = @import("../gc/gc_heap.zig"); /// (required by `gc.alloc(T)`'s comptime invariant). Use `bytes()` to /// recover the slice view. /// -/// Lifetime: the byte storage is owned (duplicated at `alloc` time via -/// `gc.infra.dupe`); the wrapper struct itself is GC-managed through -/// `gc.alloc(String)`. The per-tag finaliser (`finaliseGc`, registered -/// at `Runtime.init` time via `registerGcHooks`) frees the bytes back -/// to `gc.infra` before sweep rawFrees the wrapper memory. +/// Lifetime: the wrapper is GC-managed. `storage` says where the bytes +/// are: `.inline_tail` bytes are part of the cell and die with it; +/// `.owned` bytes were `gc.infra.dupe`d and the per-tag finaliser +/// (`finaliseGc`) frees them before sweep frees the wrapper. pub const String = extern struct { header: HeapHeader, - _pad: [6]u8 = .{ 0, 0, 0, 0, 0, 0 }, + /// Where the payload lives. Occupies a former padding byte. + storage: Storage = .owned, + _pad: [5]u8 = .{ 0, 0, 0, 0, 0 }, bytes_ptr: [*]const u8, bytes_len: usize, + pub const Storage = enum(u8) { + /// `bytes_ptr` is an `infra` allocation the finaliser frees. + owned = 0, + /// `bytes_ptr` is the tail of this String's own GC cell. + inline_tail = 1, + }; + comptime { std.debug.assert(@alignOf(String) >= 8); std.debug.assert(@offsetOf(String, "header") == 0); @@ -48,12 +56,35 @@ pub const String = extern struct { } }; -/// Allocate a heap String holding a copy of `bytes`. The byte storage -/// is `dupe`d through `rt.gc.infra` (= `rt.gpa`) so the per-tag -/// finaliser can release it back; the wrapper struct goes through -/// `rt.gc.alloc(String)` so it appears on the GC's allocations list + -/// participates in mark / sweep like any other Value. +/// Longest payload stored inline, as the tail of the String's own GC cell, +/// so the whole cell stays a page-heap cell (`page_heap.max_small_bytes`). +pub const max_inline_bytes: usize = page_heap_mod.max_small_bytes - @sizeOf(String); + +/// Allocate a heap String holding a copy of `bytes`. +/// +/// A payload of up to `max_inline_bytes` goes into the tail of the String's +/// own GC cell: one page-heap allocation, and nothing for the finaliser to +/// free. The allocation runs inside a fabrication region (no collect in the +/// alloc prologue) because `byte_view` may be the inline tail of a String +/// the caller holds unrooted, and it must survive until it is copied. A +/// larger payload is `dupe`d through `rt.gc.infra` (= `rt.gpa`) before the +/// cell exists, and released by the per-tag finaliser. pub fn alloc(rt: *Runtime, byte_view: []const u8) !Value { + if (byte_view.len <= max_inline_bytes) { + const n = byte_view.len; + rt.gc.enterFabrication(); + defer rt.gc.exitFabrication(); + const s = try rt.gc.allocWithTail(String, n); + const tail: [*]u8 = @ptrCast(@as([*]String, @ptrCast(s)) + 1); + @memcpy(tail[0..n], byte_view); + s.* = .{ + .header = HeapHeader.init(.string), + .storage = .inline_tail, + .bytes_ptr = tail, + .bytes_len = n, + }; + return Value.encodeHeapPtr(.string, s); + } const owned = try rt.gc.infra.dupe(u8, byte_view); errdefer rt.gc.infra.free(owned); rt.gc.noteOwnedAlloc(owned.len); @@ -72,6 +103,7 @@ pub fn alloc(rt: *Runtime, byte_view: []const u8) !Value { pub fn finaliseGc(gc_ptr: *anyopaque, header: *HeapHeader) void { const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr)); const s: *String = @ptrCast(@alignCast(header)); + if (s.storage == .inline_tail) return; gc.infra.free(s.bytes()); } @@ -80,8 +112,10 @@ pub fn finaliseGc(gc_ptr: *anyopaque, header: *HeapHeader) void { /// `Runtime.init` calls this so registration lands before the first /// allocation. /// D-573: a live String's cost is its byte payload, not its 32-byte wrapper. +/// An inline payload is already part of the cell's recorded size. fn ownedBytes(header: *HeapHeader) usize { const str: *String = @ptrCast(@alignCast(header)); + if (str.storage == .inline_tail) return 0; return str.bytes_len; } @@ -160,6 +194,31 @@ test "alloc returns OOM without leaking under each allocation failure (uniform e try testing.checkAllAllocationFailures(testing.allocator, allocFailingHarness, .{}); } +test "a short payload is stored inline; a long one is an owned infra block" { + var th = std.Io.Threaded.init(testing.allocator, .{}); + defer th.deinit(); + var rt = Runtime.init(th.io(), testing.allocator); + defer rt.deinit(); + + const short = try alloc(&rt, "12345"); + const s = short.decodePtr(*String); + try testing.expectEqual(String.Storage.inline_tail, s.storage); + try testing.expectEqual(@as(usize, 0), ownedBytes(&s.header)); + try testing.expectEqualStrings("12345", asString(short)); + + const big = try testing.allocator.alloc(u8, max_inline_bytes + 1); + defer testing.allocator.free(big); + @memset(big, 'x'); + const long = try alloc(&rt, big); + const l = long.decodePtr(*String); + try testing.expectEqual(String.Storage.owned, l.storage); + try testing.expectEqual(big.len, ownedBytes(&l.header)); + try testing.expectEqualSlices(u8, big, asString(long)); + + const edge = try alloc(&rt, big[0..max_inline_bytes]); + try testing.expectEqual(String.Storage.inline_tail, edge.decodePtr(*String).storage); +} + test "alloc handles the empty string" { var th = std.Io.Threaded.init(testing.allocator, .{}); defer th.deinit(); From 0c56e92982c388f6d7e1b9cde4e996e09fc9c06f Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 22:46:11 -0300 Subject: [PATCH 14/51] bench(vs_bb): gc_churn phase-split probe Splits gc_churn into the retained build, the full churn loop, the small map+vector literal, (str i) and the empty recur loop, timed per piece. Runs on cljw and bb alike. The measurements behind CLJW-PERF-SMALL-LITERAL, CLJW-PERF-STR-1ARG and CLJW-PERF-RECUR-LOOP came from it. Smell-audited: 1: bench probe only, no runtime code --- bench/vs_bb/probe/gc_churn_split.clj | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bench/vs_bb/probe/gc_churn_split.clj diff --git a/bench/vs_bb/probe/gc_churn_split.clj b/bench/vs_bb/probe/gc_churn_split.clj new file mode 100644 index 000000000..27d0328c5 --- /dev/null +++ b/bench/vs_bb/probe/gc_churn_split.clj @@ -0,0 +1,34 @@ +;; gc_churn split by phase: build the retained heap, then the churn loop and +;; its parts. Runs on cljw and bb alike. +(defn ms [t0] (quot (- (System/nanoTime) t0) 1000000)) + +(defn timed [label f] + (let [t0 (System/nanoTime) r (f)] + (println label (ms t0) r))) + +(let [t0 (System/nanoTime) + retained (vec (map (fn [i] {:id i :v [i (inc i)]}) (range 200000)))] + (println "build_ms" (ms t0) (count retained)) + (timed "full_loop_ms" + #(loop [i 0 s 0] + (if (< i 600000) + (let [m {:a i :b [i i i] :c {:d (str i)}}] + (recur (inc i) (+ s (count (:b m)) (count (:c m))))) + s))) + (timed "map_vec_ms" + #(loop [i 0 s 0] + (if (< i 600000) + (let [m {:a i :b [i i i]}] + (recur (inc i) (+ s (count (:b m))))) + s))) + (timed "str_ms" + #(loop [i 0 s 0] + (if (< i 600000) + (recur (inc i) (+ s (count (str i)))) + s))) + (timed "empty_loop_ms" + #(loop [i 0 s 0] + (if (< i 600000) + (recur (inc i) (+ s 1)) + s))) + (println "retained" (count retained))) From 27a0e9827a883656d9a76bea3af222daa8d0fc77 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 23:44:04 -0300 Subject: [PATCH 15/51] perf(gc): collect trigger grows the heap by one live set, not two threshold = max(floor, last_live) instead of max(floor, 2 * last_live). Peak committed heap is about live + threshold, so the 2x growth factor held a large live set near 3x itself in RSS. Shrinking the retained empty pages instead does not lower the peak (the next cycle maps them again, pages_released 89 -> 11117), so the growth factor is the lever. Adds an adaptiveThreshold unit test and a heap-return suite case (peak committed under 2.5x live; base measures 3.0x, new 2.0x). Smell-audited: 1: one-line policy change in gc_trigger, no new knob, retention budget untouched, three doc comments updated --- CHANGELOG.md | 8 +++++++ src/runtime/gc/gc_trigger.zig | 28 ++++++++++++++++++------- src/runtime/gc/mark_sweep.zig | 2 +- src/runtime/gc/mark_sweep_test.zig | 14 +++++++++++-- src/runtime/gc/tag_ops.zig | 2 +- test/clj/suites/gc_heap_return_test.clj | 19 +++++++++++++++++ 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4262f94..9bdf97e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. step a `lazy-seq` thunk. `bench/vs_bb` lazy_chain went from 13.75x bb to 2.28x bb (1315 ms vs bb 576 ms in-process), with peak RSS 117 MB vs bb 198. +- **The collect trigger grows the heap by one live set, not two + (CLJW-PERF-GC-RETAIN).** The threshold is now `max(floor, last_live)` + instead of `max(floor, 2 * last_live)`; peak heap is about live plus one + threshold, so a large live set no longer peaks near 3x itself. Peak RSS: + 1M retained maps plus churn 1729 MB to 1153 MB (-33%), `bench/vs_bb` + gc_churn 352 MB to 242 MB (-31%), for +1.8% instructions on gc_churn and + +4.5% on the large-live probe; a small-live high-churn loop is unchanged. + ## [1.15.4] - 2026-10-23 ### Added diff --git a/src/runtime/gc/gc_trigger.zig b/src/runtime/gc/gc_trigger.zig index ca2b7a657..b8b0cda99 100644 --- a/src/runtime/gc/gc_trigger.zig +++ b/src/runtime/gc/gc_trigger.zig @@ -25,7 +25,8 @@ const GcHeap = gc_heap_mod.GcHeap; /// Default GC trigger threshold (bytes since last collection), and the default /// floor of the per-heap adaptive threshold. Adaptive at runtime: -/// `threshold = max(threshold_floor_bytes, last_live_bytes * 2)` per ADR-0028 §1. +/// `threshold = max(threshold_floor_bytes, last_live_bytes)` (ADR-0028 §1, +/// growth factor amended 2x to 1x by CLJW-PERF-GC-RETAIN, see `afterSweep`). /// Raised 1MB to 4MB by ADR-0164/D-519: for a churn workload `last_live` is /// about 0, so the floor IS the operative cadence; 4MB is a quarter of the /// collects (same total sweep work, fewer STW fences) at ~3MB extra peak. Tune @@ -108,6 +109,18 @@ pub fn retainBytesAfterCollect(gc: *const GcHeap, requested: bool) usize { return @max(min_retained_bytes, gc.threshold_bytes); } +/// The trigger for the next cycle: `max(floor, last_live)`, so the heap may +/// grow by one live set between collects (Go's `GOGC=100`). Peak committed +/// heap is about `live + threshold`, so the growth factor is the RSS lever: +/// at the former `2 * live`, a 482 MB live set peaked at 1.73 GB RSS; at +/// `1 * live` it peaks at 1.15 GB for 4 % more instructions. A smaller +/// share of the threshold RETAINED changes nothing (measured: the next cycle +/// maps the released pages again), because the peak is the fresh space the +/// cycle allocates, not the empty pages it keeps. +pub fn adaptiveThreshold(floor: usize, live: usize) usize { + return @max(floor, live); +} + /// D-519 (ADR-0164): threshold-driven auto-collect during eval. Runs a STW /// collect when pressure has passed `threshold_bytes`, at a quiescent-roots /// boundary. Called from BOTH the `alloc` boundary (the completeness floor for @@ -183,16 +196,17 @@ noinline fn collectIfSafe(gc: *GcHeap) void { /// retention budget (after the recompute, since the budget is one threshold's /// worth). /// -/// The threshold is `max(threshold_floor_bytes, last_live * 2)`. The floor is -/// the per-heap value (default, or `CLJW_GC_THRESHOLD_MB`) so the knob -/// survives this recompute; `steerFloor` moves it between collects. For a -/// churn workload `last_live` is about 0 and the floor governs (ADR-0164); -/// once the live set grows past half the floor, the adaptive arm takes over. +/// The threshold is `adaptiveThreshold`, `max(threshold_floor_bytes, +/// last_live)`. The floor is the per-heap value (default, or +/// `CLJW_GC_THRESHOLD_MB`) so the knob survives this recompute; `steerFloor` +/// moves it between collects. For a churn workload `last_live` is about 0 and +/// the floor governs (ADR-0164); once the live set grows past the floor, the +/// adaptive arm takes over. /// Measured on a Rush Hour BFS (3.17 GB over 26.2M allocations): 4 MB floor /// 755 collections 17.7 s, 64 MB floor 47 collections 7.6 s, about 13 ms of /// fixed cost per collect paid to reclaim a heap that was nearly all garbage. pub fn afterSweep(gc: *GcHeap) void { - gc.threshold_bytes = @max(gc.threshold_floor_bytes, gc.stats.last_live_bytes *| 2); + gc.threshold_bytes = adaptiveThreshold(gc.threshold_floor_bytes, gc.stats.last_live_bytes); gc.bytes_since_last_gc = 0; gc.owned_since_last_gc.store(0, .monotonic); gc.stats.collect_count += 1; diff --git a/src/runtime/gc/mark_sweep.zig b/src/runtime/gc/mark_sweep.zig index 9250614dd..f13d94faf 100644 --- a/src/runtime/gc/mark_sweep.zig +++ b/src/runtime/gc/mark_sweep.zig @@ -158,7 +158,7 @@ pub fn sweep(gc: *GcHeap) void { /// Full mark-sweep collection cycle: enumerate every root, mark /// transitively, sweep, then `gc_trigger.afterSweep` (threshold -/// `max(floor, last_live * 2)`, counter reset, empty-page release). +/// `max(floor, last_live)`, counter reset, empty-page release). /// /// The entry point lives here rather than on `GcHeap` because the cycle /// traverses `root_set.zig`, which imports `gc_heap.zig`. diff --git a/src/runtime/gc/mark_sweep_test.zig b/src/runtime/gc/mark_sweep_test.zig index ccc2f2094..e54b0520a 100644 --- a/src/runtime/gc/mark_sweep_test.zig +++ b/src/runtime/gc/mark_sweep_test.zig @@ -10,6 +10,7 @@ const gc_heap_mod = @import("gc_heap.zig"); const page_heap_mod = @import("page_heap.zig"); const heap_header = @import("../value/heap_header.zig"); const mark_sweep = @import("mark_sweep.zig"); +const gc_trigger_mod = @import("gc_trigger.zig"); const GcHeap = gc_heap_mod.GcHeap; const HeapHeader = heap_header.HeapHeader; @@ -213,7 +214,16 @@ test "collect: a registered worker's transaction roots survive (#4a' worker-tx)" try testing.expect(found); // survived via the registered worker's tx rooting } -test "collect: adaptive threshold = max(default, last_live_bytes * 2)" { +test "adaptiveThreshold: the heap grows by one live set between collects (CLJW-PERF-GC-RETAIN)" { + const floor = gc_heap_mod.default_gc_threshold_bytes; + try testing.expectEqual(floor, gc_trigger_mod.adaptiveThreshold(floor, 0)); + try testing.expectEqual(floor, gc_trigger_mod.adaptiveThreshold(floor, floor - 1)); + // Past the floor the threshold is the live set itself, not twice it: peak + // committed heap is about live + threshold, so 2x live meant ~3x RSS. + try testing.expectEqual(@as(usize, 100 * 1024 * 1024), gc_trigger_mod.adaptiveThreshold(floor, 100 * 1024 * 1024)); +} + +test "collect: adaptive threshold = max(default, last_live_bytes)" { var gc = GcHeap.init(testing.allocator); defer gc.deinit(); @@ -222,7 +232,7 @@ test "collect: adaptive threshold = max(default, last_live_bytes * 2)" { try testing.expectEqual(gc_heap_mod.default_gc_threshold_bytes, gc.threshold_bytes); // Pin a heap Value so it survives the cycle. last_live_bytes = sizeOf(Cell); - // since 2 * sizeOf(Cell) is far below default, threshold still stays at default. + // since sizeOf(Cell) is far below default, threshold still stays at default. const cell = try gc.alloc(Cell); cell.* = .{ .header = HeapHeader.init(.list) }; const value_mod_test = @import("../value/value.zig"); diff --git a/src/runtime/gc/tag_ops.zig b/src/runtime/gc/tag_ops.zig index 67d316780..49f13e295 100644 --- a/src/runtime/gc/tag_ops.zig +++ b/src/runtime/gc/tag_ops.zig @@ -98,7 +98,7 @@ pub var tag_finaliser_table: [64]?*const fn (gc: *anyopaque, header: *HeapHeader /// Per-tag OWNED-BYTES hook (D-573): the bytes a live object owns BEYOND its /// GC record block — the memory its finaliser would free. `sweep` adds this to /// `last_live_bytes` so the adaptive trigger (`threshold = max(floor, -/// last_live * 2)`) sees a cost-proportional live size. Without it, a heap of +/// last_live)`) sees a cost-proportional live size. Without it, a heap of /// Strings reported only the 32-byte wrappers while the byte payloads — the /// memory actually held, the graph actually costing RSS — were invisible: /// measured 78 MB reported against 359 MB RSS, a factor of 4.6, which made the diff --git a/test/clj/suites/gc_heap_return_test.clj b/test/clj/suites/gc_heap_return_test.clj index 3a8cfad4a..67e485cc4 100644 --- a/test/clj/suites/gc_heap_return_test.clj +++ b/test/clj/suites/gc_heap_return_test.clj @@ -42,6 +42,25 @@ (burst) (is (<= (committed) (+ first-peak (* 2 mb))))))) +(deftest churn-over-a-large-live-set-grows-by-one-live-set + ;; CLJW-PERF-GC-RETAIN: the trigger is max(floor, live), so churn over a + ;; live heap C peaks near 2C (live plus one live set of fresh space). The + ;; former 2x-live trigger peaked near 3C. + (let [held (vec (range 12000000)) + _ (System/gc) + c (committed) + peak (loop [i 0 peak c] + (if (< i 4000000) + (let [m {:a i :b [i i i]}] + (recur (+ i (count (:b m)) -2) + (if (zero? (mod i 4096)) (max peak (committed)) peak))) + peak))] + (testing "the live set dwarfs the steered floor" + (is (> c (* 96 mb)))) + (testing "peak committed heap stays under 2.5x the live heap" + (is (< peak (* 5/2 c)) (str "live " (quot c mb) " MB, peak " (quot peak mb) " MB"))) + (is (= 12000000 (count held))))) + (deftest memory-readings-stay-coherent (let [r (Runtime/getRuntime) t (.totalMemory r) From ee54e55474dc72ea50f4b144a200386d6ea692d1 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 23:44:17 -0300 Subject: [PATCH 16/51] fix(ci): rebuild cold when bench-vs-bb hits a broken restored cache The bench-vs-bb job restores the gate's x86_64-linux .zig-cache read-only. That cache can hold a WriteFiles manifest (stub_wf, which writes bootstrap_sources_stub.zig for cache_gen) whose o/ output dir is gone. Zig then fails with "failed to check cache ... file_hash FileNotFound" instead of rebuilding. It hit #99 twice on 2026-10-24, from a cache saved by a green gate run, so the save-side guard in ci.yml (never save a cancelled job) does not cover this case. The job never saves the cache, so the fix is local: on a failed build, drop .zig-cache and build cold once. Smell-audited: 1: read-only consumer recovers locally; no save-side change without a reproducer --- .github/workflows/bench-vs-bb.yml | 15 ++++++++++++++- CHANGELOG.md | 7 +++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bench-vs-bb.yml b/.github/workflows/bench-vs-bb.yml index b01908e27..615135841 100644 --- a/.github/workflows/bench-vs-bb.yml +++ b/.github/workflows/bench-vs-bb.yml @@ -110,9 +110,22 @@ jobs: echo "CLJW_BENCH_JVM_CP=$(ls "$d"/*.jar | paste -sd:)" >> "$GITHUB_ENV" java -version + # A restored `.zig-cache` can hold a WriteFiles manifest whose output dir + # is gone, and Zig then errors instead of rebuilding: + # error: failed to check cache: '.zig-cache/o//bootstrap_sources_stub.zig' + # file_hash FileNotFound + # Seen 2026-10-24 on a cache saved by a green gate run, so the save-side + # guard in ci.yml (never save a cancelled job) does not cover it. This + # job only reads the cache, so drop it and build cold once on failure. - name: Build cljw ReleaseSafe -Dwasm shell: bash - run: zig build -Dwasm -Doptimize=ReleaseSafe -p "$RUNNER_TEMP/cljw-bench" + run: | + build() { zig build -Dwasm -Doptimize=ReleaseSafe -p "$RUNNER_TEMP/cljw-bench"; } + if ! build; then + echo "::warning::build failed on the restored .zig-cache; retrying cold" + rm -rf .zig-cache + build + fi - name: Run bench/vs_bb shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 78465ab94..b18611806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Fixed + +- The non-blocking bench-vs-bb CI job no longer fails on a restored + `.zig-cache` whose WriteFiles output is missing (`file_hash FileNotFound` + on `bootstrap_sources_stub.zig`). The job only reads the cache, so on a + failed build it drops `.zig-cache` and builds cold once. + ### Changed - A string of up to about 4 KB now keeps its bytes inside its own GC cell From 162a8f666d280da244e8dfbbe092e4c2b0859143 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Fri, 23 Oct 2026 23:52:58 -0300 Subject: [PATCH 17/51] WIP perf(vm): cold-op dispatch inside switch, handler_top on push, inline fastBinaryNumber Recovered work-in-progress from the previous session (CLJW-PERF-RECUR-LOOP). Smell-audited: 1: WIP recovery, no new smells; cold ops dispatched via toCold prong --- bench/vs_bb/probe/recur_loop.clj | 14 +++ src/eval/backend/intrinsic.zig | 4 +- src/eval/backend/vm.zig | 141 +++++++++++++++---------------- 3 files changed, 86 insertions(+), 73 deletions(-) create mode 100644 bench/vs_bb/probe/recur_loop.clj diff --git a/bench/vs_bb/probe/recur_loop.clj b/bench/vs_bb/probe/recur_loop.clj new file mode 100644 index 000000000..cfc08248d --- /dev/null +++ b/bench/vs_bb/probe/recur_loop.clj @@ -0,0 +1,14 @@ +;; Loop-only probe for the VM dispatch floor (CLJW-PERF-RECUR-LOOP). +;; Usage: cljw recur_loop.clj +;; Count `instructions:u` pinned to one core; subtract the n=0 run for startup. +(def variant (or (first *command-line-args*) "empty")) +(def n (parse-long (or (second *command-line-args*) "600000"))) + +(defn run [v n] + (case v + "empty" (loop [i 0 s 0] (if (< i n) (recur (inc i) (+ s 1)) s)) + "inc" (loop [i 0] (if (< i n) (recur (inc i)) i)) + "plus" (loop [i 0] (if (< i n) (recur (+ i 1)) i)) + "two" (loop [i 0 s 0] (if (< i n) (recur (inc i) s) s)))) + +(println (run variant n)) diff --git a/src/eval/backend/intrinsic.zig b/src/eval/backend/intrinsic.zig index d01969e21..c64333a31 100644 --- a/src/eval/backend/intrinsic.zig +++ b/src/eval/backend/intrinsic.zig @@ -321,7 +321,9 @@ pub fn fastNth2(coll: Value, i_val: Value) ?Value { /// Binary arith/comparison fast path. Returns `null` unless the operands are /// two inline fixnums or a float with a float/fixnum, so the caller defers /// every other case (incl. all error cases) to the builtin. -pub fn fastBinaryNumber(_: *Runtime, op: ArithOp, a: Value, b: Value) !?Value { +// PERF: inline so the fixnum test and op switch fold into each VM arm; an +// out-of-line call (frame, canary, ?Value via memory) was ~20% of a loop [refs: O-101] +pub inline fn fastBinaryNumber(_: *Runtime, op: ArithOp, a: Value, b: Value) !?Value { if (a.tag() != .integer or b.tag() != .integer) return fastNonFixnum(op, a, b); return fastFixnum(op, a.asInteger(), b.asInteger()); } diff --git a/src/eval/backend/vm.zig b/src/eval/backend/vm.zig index e7e4a4d04..fb814020c 100644 --- a/src/eval/backend/vm.zig +++ b/src/eval/backend/vm.zig @@ -110,7 +110,7 @@ const VmArena = struct { frame_top: u16 = 0, /// try/catch + cleanup handlers. Each `eval` borrows a window of up to /// `HANDLER_STACK_MAX` starting at `handler_top`, publishing - /// `handler_top = its base + its live count` before every step so a nested + /// `handler_top = its base + its live count` on every handler push so a nested /// eval borrows above the live ones. Off the native stack: the window was a /// 768-byte `eval` local paid by every re-entry (ADR-0195 budget). handlers: [HANDLER_ARENA_SLOTS]Handler = undefined, @@ -361,8 +361,9 @@ pub fn eval( // with the alloc-boundary site via the shared `bytes_since_last_gc` reset; // one predicted-not-taken compare per op when the threshold is not yet crossed. rt.gc.maybeAutoCollect(); - // Publish this eval's live handlers so a nested eval borrows above them. - ar.handler_top = handler_base + handler_count; + // `ar.handler_top` (where a nested eval borrows its handler window) is + // published by the push-handler ops, not here per op: a top left high + // after a pop only wastes slots, it never overlaps a live handler. var flatten_req: ?FlattenReq = null; const step_result = stepOnce(true, rt, env, cur.locals, cur.chunk, &ar.stack, &ar.loc, &ar.op_top, &ip, handlers, &handler_count, &flatten_req); // A flattenPush FRAMES_MAX overflow must reach the shared error arm @@ -489,7 +490,7 @@ pub fn eval( // naive form is a plain `fn`; behaviour is identical (diff oracle). [refs: O-017] inline fn stepOnce( /// `true` for the copy inlined into `eval`'s loop: the cold opcodes - /// (`isColdOp`) leave it for the out-of-line `stepCold` copy, so their + /// (prongs that call `toCold`) leave it for the out-of-line `stepCold` copy, so their /// scratch (fmt buffers, the make-fn stackFallback, literal builders) is /// not part of the frame every `eval` re-entry pays (ADR-0195 budget). comptime hot: bool, @@ -508,13 +509,6 @@ inline fn stepOnce( /// loop flattens it into an in-VM frame instead of a host `vt.callFn` re-entry. flatten_out: *?FlattenReq, ) anyerror!?Value { - if (hot) { - const at = ip_ptr.*; - if (at < chunk.instructions.len and isColdOp(chunk.instructions[at].op())) { - @branchHint(.unlikely); - return stepCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out); - } - } var sp = sp_ptr.*; var ip = ip_ptr.*; var handler_count = handler_count_ptr.*; @@ -575,7 +569,7 @@ inline fn stepOnce( const count: u16 = instr.operand >> 8; tree_walk.patchLetfnClosures(locals, base, count); }, - .op_def, .op_def_unbound => if (hot) unreachable else { + .op_def, .op_def_unbound => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { const var_idx = instr.operand & opcode_mod.DEF_VAR_IDX_MASK; if (var_idx >= chunk.constants.len) return raiseInternal("vm: def Var index out of range"); @@ -597,7 +591,7 @@ inline fn stepOnce( stack[sp] = Value.encodeHeapPtr(.var_ref, var_ptr); sp += 1; }, - .op_var_meta => if (hot) unreachable else { + .op_var_meta => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Stack is [.., var, meta] — pop the meta map, set it as // the Var's meta (the merged user ^meta/:doc + :line/:column/:file // the analyzer minted); the var stays as def's result value. @@ -620,7 +614,7 @@ inline fn stepOnce( stack[sp] = var_ptr.deref(); sp += 1; }, - .op_ns_import => if (hot) unreachable else { + .op_ns_import => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Register one `(:import …)` simple->fqcn into the // current ns. Pushes nil (the ns form's running value). if (instr.operand >= chunk.import_sites.len) @@ -634,7 +628,7 @@ inline fn stepOnce( stack[sp] = Value.nil_val; sp += 1; }, - .op_set_var => if (hot) unreachable else { + .op_set_var => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { if (instr.operand >= chunk.constants.len) return raiseInternal("vm: op_set_var constant index out of range"); if (sp == 0) return raiseInternal("vm: op_set_var on empty stack"); @@ -648,7 +642,7 @@ inline fn stepOnce( return error_catalog.raise(.var_set_not_bound, instr_loc, .{ .@"var" = full }); } }, - .op_set_field => if (hot) unreachable else { + .op_set_field => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // `(set! field v)` on a deftype mutable field. Stack: // [receiver, value]. operand = field-name String constant. if (instr.operand >= chunk.constants.len) @@ -703,7 +697,9 @@ inline fn stepOnce( // the slow binder) and handler-bearing chunks (the bounded throw-unwind // assumes flattened frames are handler-free). Anything else falls // through to the slow `vt.callFn` path — the F-012 oracle seam. - if (callee.tag() == .fn_val) { + // PERF: always_inline; the stepCold copy pushed `tag` out of line + // here (one call per executed op_call) [refs: O-100] + if (@call(.always_inline, Value.tag, .{callee}) == .fn_val) { const f = callee.decodePtr(*Function); if (tree_walk.selectMethod(f, arg_count)) |m| { if (m.bytecode) |bc| { @@ -762,7 +758,7 @@ inline fn stepOnce( sp -= 2; const a = stack[sp]; const b = stack[sp + 1]; - const aop = intrinsic.fromOpcode(instr_op).?; + const aop = @call(.always_inline, intrinsic.fromOpcode, .{instr_op}).?; const fast: ?Value = if (rt.core_arith_pristine) try intrinsic.fastBinaryNumber(rt, aop, a, b) else @@ -1029,7 +1025,7 @@ inline fn stepOnce( stack[sp] = stack[sp - 1]; sp += 1; }, - .op_throw => if (hot) unreachable else { + .op_throw => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { if (sp == 0) return raiseInternal("vm: op_throw on empty stack"); sp -= 1; dispatch.last_thrown_exception = stack[sp]; @@ -1043,7 +1039,7 @@ inline fn stepOnce( dispatch.last_thrown_context = error_mod.snapshotContext(); return error.ThrownValue; }, - .op_make_fn => if (hot) unreachable else { + .op_make_fn => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // The compiler stashes either a final closure-less // Function (slot_base == 0) or a template Function // (slot_base > 0, closure_bindings still null) in the @@ -1117,7 +1113,7 @@ inline fn stepOnce( if (sp < instr.operand) return raiseInternal("vm: op_recur underflow"); }, - .op_invoke_builtin => if (hot) unreachable else { + .op_invoke_builtin => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Reserved for analyzer-resolved direct builtin calls; // the compiler does not emit this (every call routes // through `op_call` + vtable). Per @@ -1132,6 +1128,7 @@ inline fn stepOnce( if (handler_count >= handlers.len) return handlerOverflow(handlers.len, instr_loc); handlers[handler_count] = .{ .catch_ip = catch_ip, .saved_sp = sp, .kind = .catch_clause }; handler_count += 1; + publishHandlerTop(handlers, handler_count); }, .op_push_cleanup => { const offset: i16 = @bitCast(instr.operand); @@ -1140,6 +1137,7 @@ inline fn stepOnce( if (handler_count >= handlers.len) return handlerOverflow(handlers.len, instr_loc); handlers[handler_count] = .{ .catch_ip = cleanup_ip, .saved_sp = sp, .kind = .cleanup }; handler_count += 1; + publishHandlerTop(handlers, handler_count); }, .op_reraise => { // Re-fire the error the cleanup-unwind branch stashed, @@ -1157,7 +1155,7 @@ inline fn stepOnce( return raiseInternal("vm: op_pop_handler on empty handler stack"); handler_count -= 1; }, - .op_push_binding_frame => if (hot) unreachable else { + .op_push_binding_frame => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Pops 2N entries [encVar0, val0, …] and installs a // BindingFrame on the per-thread binding chain (the // threadlocal is load-bearing: Clojure dynamic bindings @@ -1198,7 +1196,7 @@ inline fn stepOnce( // refresh in case this frame rebinds *ns*. env.refreshCurrentNs(); }, - .op_pop_binding_frame => if (hot) unreachable else { + .op_pop_binding_frame => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { const f = env_mod.current_frame orelse return raiseInternal("vm: op_pop_binding_frame on empty frame stack"); env_mod.popFrame(); @@ -1207,7 +1205,7 @@ inline fn stepOnce( // Restore current_ns to the outer *ns* after the frame pops. env.refreshCurrentNs(); }, - .op_match_class => if (hot) unreachable else { + .op_match_class => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { if (sp == 0) return raiseInternal("vm: op_match_class on empty stack"); if (instr.operand >= chunk.constants.len) @@ -1222,7 +1220,7 @@ inline fn stepOnce( stack[sp] = if (matches) Value.true_val else Value.false_val; sp += 1; }, - .op_match_type_keyword => if (hot) unreachable else { + .op_match_type_keyword => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { if (sp == 0) return raiseInternal("vm: op_match_type_keyword on empty stack"); if (instr.operand >= chunk.constants.len) @@ -1235,7 +1233,7 @@ inline fn stepOnce( stack[sp] = if (matches) Value.true_val else Value.false_val; sp += 1; }, - .op_in_ns => if (hot) unreachable else { + .op_in_ns => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Mirror of tree_walk's evalInNs. `(in-ns 'foo)` is a // naked ns switch — NO auto-refer of clojure.core. // `.clj` heads use `(ns foo (:refer-clojure))`, which @@ -1254,7 +1252,7 @@ inline fn stepOnce( stack[sp] = Env.nsValue(target_ns); sp += 1; }, - .op_ns_with_refer_clojure => if (hot) unreachable else { + .op_ns_with_refer_clojure => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Mirror of tree_walk's evalNs when `refer_clojure = true`: // op_in_ns logic + referAll(clojure.core). clojure.core is // the single auto-refer source (Zig builtins intern there @@ -1273,7 +1271,7 @@ inline fn stepOnce( stack[sp] = Value.nil_val; sp += 1; }, - .op_ns_with_filter => if (hot) unreachable else { + .op_ns_with_filter => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Mirror of tree_walk's evalNs refer-clojure branch // with the `:exclude`/`:only` filter. Enter the ns, apply the // ns meta + docstring, then refer clojure.core @@ -1300,7 +1298,7 @@ inline fn stepOnce( stack[sp] = Value.nil_val; sp += 1; }, - .op_require => if (hot) unreachable else { + .op_require => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Mirror of tree_walk's evalRequire. if (instr.operand >= chunk.constants.len) return raiseInternal("vm: op_require constant index out of range"); @@ -1317,7 +1315,7 @@ inline fn stepOnce( stack[sp] = Value.nil_val; sp += 1; }, - .op_require_with_libspec => if (hot) unreachable else { + .op_require_with_libspec => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // Mirror of tree_walk's evalRequire full body. Pops the // LibspecEntry from the chunk side-table, runs the // op_require prelude, then applies alias + refers. @@ -1367,7 +1365,7 @@ inline fn stepOnce( // past `compiler.literal_step` elements the literal is built in steps, // each `op_*_extend` appending one step to the collection below it, so // the operand stack never holds more than one step (D-346). - inline .op_vector_literal, .op_map_literal, .op_set_literal => |op| if (hot) unreachable else { + inline .op_vector_literal, .op_map_literal, .op_set_literal => |op| if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { const n: u16 = instr.operand; if (sp < n) return raiseInternal("vm: collection literal underflows operand stack"); if (op == .op_map_literal and n % 2 != 0) return raiseInternal("vm: odd map literal"); @@ -1376,7 +1374,7 @@ inline fn stepOnce( stack[sp] = coll; sp += 1; }, - inline .op_vector_extend, .op_map_extend, .op_set_extend => |op| if (hot) unreachable else { + inline .op_vector_extend, .op_map_extend, .op_set_extend => |op| if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // The collection under construction stays in its (rooted) stack // slot while each append allocates. const n: u16 = instr.operand; @@ -1385,7 +1383,7 @@ inline fn stepOnce( try literal.extend(rt, comptime literalKind(op), &stack[sp - n - 1], stack[sp - n .. sp]); sp -= n; }, - .op_ctor_call => if (hot) unreachable else { + .op_ctor_call => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // operand = index into the ctor_sites side-table (the // class name carries full width, no 8-bit name_idx packing). if (instr.operand >= chunk.ctor_sites.len) @@ -1403,7 +1401,7 @@ inline fn stepOnce( stack[sp] = new_val; sp += 1; }, - .op_method_call => if (hot) unreachable else { + .op_method_call => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // operand = call_site_idx. The unified instance-member // resolver (ADR-0050) runs field-first (deftype/record // field_layout), then method_table; `field_only` (the @@ -1488,7 +1486,7 @@ inline fn stepOnce( } } }, - .op_static_method_call => if (hot) unreachable else { + .op_static_method_call => if (hot) return toCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out, &sp, &ip, &handler_count) else { // operand = call_site_idx. Static dispatch — the // descriptor is the analyze-time pointer in // the call-site (no receiver to derive it from). Raw @@ -1548,43 +1546,35 @@ noinline fn stepCold( return stepOnce(false, rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out); } -/// Opcodes the hot `stepOnce` hands to `stepCold`: definitions, namespace -/// forms, fn construction, dynamic binding, throw/catch matching, interop and -/// collection literals. Each either runs once per form or allocates anyway, so -/// the extra call is noise; what they would cost inline is native stack on -/// every `eval` frame. -fn isColdOp(op: Opcode) bool { - return switch (op) { - .op_def, - .op_def_unbound, - .op_var_meta, - .op_ns_import, - .op_set_var, - .op_set_field, - .op_throw, - .op_make_fn, - .op_invoke_builtin, - .op_push_binding_frame, - .op_pop_binding_frame, - .op_match_class, - .op_match_type_keyword, - .op_in_ns, - .op_ns_with_refer_clojure, - .op_ns_with_filter, - .op_require, - .op_require_with_libspec, - .op_ctor_call, - .op_method_call, - .op_static_method_call, - .op_vector_literal, - .op_map_literal, - .op_set_literal, - .op_vector_extend, - .op_map_extend, - .op_set_extend, - => true, - else => false, - }; +/// A cold opcode's prong in the hot `stepOnce`: hand the instruction to +/// `stepCold` and adopt the state it wrote back. `sp_ptr`/`ip_ptr` still hold +/// the pre-step values (the hot copy publishes its locals only in its +/// `defer`), so `stepCold` re-fetches the same instruction; refreshing the hot +/// locals from the pointers keeps that `defer` from undoing its work. +/// Dispatching from inside the switch costs the hot opcodes nothing; a +/// pre-dispatch "is this op cold?" test cost every executed op [refs: O-100]. +inline fn toCold( + rt: *Runtime, + env: *Env, + locals: []Value, + chunk: *const BytecodeChunk, + stack: []Value, + loc_stack: []SourceLocation, + sp_ptr: *u16, + ip_ptr: *usize, + handlers: []Handler, + handler_count_ptr: *u16, + flatten_out: *?FlattenReq, + sp: *u16, + ip: *usize, + handler_count: *u16, +) anyerror!?Value { + @branchHint(.unlikely); + const r = stepCold(rt, env, locals, chunk, stack, loc_stack, sp_ptr, ip_ptr, handlers, handler_count_ptr, flatten_out); + sp.* = sp_ptr.*; + ip.* = ip_ptr.*; + handler_count.* = handler_count_ptr.*; + return r; } fn matchExceptionClass(class_name: []const u8, thrown: Value) bool { @@ -1605,6 +1595,13 @@ fn matchExceptionTypeKeyword(rt: *Runtime, kw_val: Value, thrown: Value) bool { return @intFromEnum(got) == @intFromEnum(kw_val); } +/// Publish this eval's live handler top so a nested eval borrows its window +/// above it. Run on every push (rare op) rather than once per executed op. +inline fn publishHandlerTop(handlers: []Handler, count: u16) void { + const base = (@intFromPtr(handlers.ptr) - @intFromPtr(&vm_arena.handlers)) / @sizeOf(Handler); + vm_arena.handler_top = @intCast(base + count); +} + /// No handler slot left. A full `HANDLER_STACK_MAX` window is one eval's /// try nesting (internal limit); a short one means the thread's handler /// arena is spent by nested evals, i.e. deep recursion: StackOverflowError. From 1edd38ea1e81fa0ee81fffd073164b86d53721d1 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 00:29:06 -0300 Subject: [PATCH 18/51] build(zwasm): pin fork PR #9, Go reactor components load Move the zwasm pin from d77568a0 (fork PR #7) to 787bf6c9, the head of fork PR #9 (fix/component-go-reactor-init), stacked on #8 and #7. #8 lets the interpreter's call-frame stack grow past 256 frames with a configurable cap. #9 makes a canon lower that returns a list or string allocate through the realloc named by its own canonopt instead of the lift-side cabi_realloc, which a preview1-adapted Go reactor needs. With this pin the lazywal Go reactor component loads and answers, and all ten phase16_wasm e2e scripts pass. --- build.zig.zon | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index c9f2cb488..edd6d311e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -14,6 +14,16 @@ // zwasm v2 embedding API (F-001 / D-037). `lazy` so the DEFAULT build + // gate never resolve it — only `-Dwasm` (b.lazyDependency) does. // + // 2026-10-24: pinned to 787bf6c9, the head of fork PR #9 + // (fix/component-go-reactor-init), stacked on #8 (22c2dc9f, + // fix/interp-frame-depth: the interp call-frame stack grows past 256 + // with a configurable cap) and #7 (d77568a0, below). #9: a canon + // lower that returns a list or string allocates through the realloc + // named by its own canonopt, not the lift-side cabi_realloc. A Go + // reactor component built with the preview1 adapter (lazywal) now + // loads and answers. Same fix-branch caveat as #7: re-point to fork + // `main` once #7, #8 and #9 land. + // // 2026-10-23: pinned to d77568a0, the head of fork PR #7 // (fix/wasi-p2-adapter-imports, one commit on fork `main` e9d74934): // a component built with the wasi_snapshot_preview1 adapter @@ -91,8 +101,8 @@ // moment anything else claims that name. The `.hash` is // content-addressed, so the host of the URL never changes it. .zwasm = .{ - .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=fix/wasi-p2-adapter-imports#d77568a0fb25663896beaebbd6e738aed0f04d92", - .hash = "zwasm-2.7.0-FT1FvxgznwA-KsQhDxkCOpB9xz0RCYigMGg4YiAOLMBh", + .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=fix/component-go-reactor-init#787bf6c93c2b799305c4ee35b7f7f183e0408e13", + .hash = "zwasm-2.7.0-FT1FvzhhnwCWJOlb95vNM0JTRiQDhERDVHbj-KpHF-9a", .lazy = true, }, }, From a020f22237eab01f23ef10bb7ce96b682f9e7c88 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 00:40:24 -0300 Subject: [PATCH 19/51] perf(vm): inc/dec intrinsics; drop per-op cold test and handler-top publish (O-100..O-102) - `(inc x)` / `(dec x)` on canonical clojure.core/inc|dec compile to op_inc / op_dec. intrinsic.fastUnary computes an i48 fixnum or a float inline; any other operand, i48 overflow, or an alter-var-root'd inc/dec (shares core_arith_pristine via the new rt.unary_vars cache) defers to the builtin Var, so promotion, ratios/BigInt and error carets are unchanged (the arg's loc rides the loc_stack, so no fused local form). - Cold opcodes dispatch to stepCold from inside the switch (toCold) instead of an isColdOp test before every op; handler_top is published on handler push, not per op; fastBinaryNumber is inline. - Bytecode format v15 (new opcodes); diff tests for inc/dec parity + deopt. Measured with taskset -c 2 perf stat -e instructions:u, per iteration net of n=0 (ReleaseSafe -Dwasm), base = staging build: empty (recur (inc i) (+ s 1)): 1585 -> 833 inc (recur (inc i)): 1307 -> 601 plus (recur (+ i 1)): 663 -> 565 Smell-audited: 1: new opcode pair mirrors the op_add family's cache/deopt contract; a fused op_inc_local was tried and dropped because it lost the arg-precise error caret --- CHANGELOG.md | 11 +++++++ src/eval/backend/intrinsic.zig | 50 ++++++++++++++++++++++++++++++++ src/eval/backend/vm.zig | 24 +++++++++++++++ src/eval/backend/vm/compiler.zig | 15 ++++++++++ src/eval/backend/vm/opcode.zig | 11 +++++++ src/eval/bytecode/serialize.zig | 4 ++- src/lang/bootstrap.zig | 6 ++++ src/lang/diff_test.zig | 23 +++++++++++++++ src/lang/primitive/core.zig | 6 ++++ src/runtime/runtime.zig | 5 ++++ 10 files changed, 154 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78465ab94..c5cad67ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ### Changed +- `(inc x)` and `(dec x)` on canonical `clojure.core/inc`/`dec` now compile to + VM intrinsics (`op_inc` / `op_dec`) instead of a generic call into the + builtin. Fixnums and floats are computed inline; any other operand, an i48 + overflow and a redefined `inc`/`dec` (`alter-var-root`) still go through the + builtin. The VM no longer runs a per-op cold-opcode test or publishes the + handler top on every op, and the arith fast path is inlined. The empty + `(loop [i 0 s 0] (if (< i n) (recur (inc i) (+ s 1)) s))` costs about 833 + user instructions per iteration, down from 1585 (pinned core, + `instructions:u`); `(loop [i 0] (if (< i n) (recur (inc i)) i))` 601, down + from 1307. Bytecode format v15 (v14 artifacts are rejected). + - A string of up to about 4 KB now keeps its bytes inside its own GC cell instead of in a separate malloc block freed by a finaliser: one allocation per string instead of two. Measured on a pinned core (user instructions, a diff --git a/src/eval/backend/intrinsic.zig b/src/eval/backend/intrinsic.zig index c64333a31..d2c97d649 100644 --- a/src/eval/backend/intrinsic.zig +++ b/src/eval/backend/intrinsic.zig @@ -228,6 +228,56 @@ pub fn recognize(rt: *const Runtime, var_ptr: *const env_mod.Var) ?Opcode { return null; } +// --- Unary inc/dec intrinsics (O-102) --- +// +// `(inc x)` / `(dec x)` were a generic op_get_var + op_call into the `inc` +// builtin, which re-enters `plus` with a 2-element slice: ~600 instructions +// per call against ~0 for the fused `(+ x 1)`. op_inc / op_dec run +// `fastUnary`; anything it declines defers to the cached Var, so the numeric +// tower, overflow promotion and errors stay the builtin's. Gated on +// `core_arith_pristine` like the binary family. + +/// Index order MUST match `Runtime.unary_vars` (0=inc, 1=dec). +pub const UnaryOp = enum { inc, dec }; + +pub fn unaryCoreName(op: UnaryOp) []const u8 { + return switch (op) { + .inc => "inc", + .dec => "dec", + }; +} + +/// Compile-time recogniser for `inc`/`dec` (canonical Var pointer identity). +pub fn recognizeUnary(rt: *const Runtime, var_ptr: *const env_mod.Var) ?UnaryOp { + for (rt.unary_vars, 0..) |cached, i| { + const pv = cached orelse continue; + if (@intFromPtr(pv) == @intFromPtr(var_ptr)) return @enumFromInt(i); + } + return null; +} + +pub fn fromUnaryOpcode(op: Opcode) ?UnaryOp { + return switch (op) { + .op_inc => .inc, + .op_dec => .dec, + else => null, + }; +} + +/// `(inc x)` / `(dec x)` fast path: an inline fixnum whose result stays in the +/// i48 window, or a float. `null` for everything else (heap Long, BigInt, +/// Ratio, BigDecimal, non-numbers, i48 edge) so the builtin handles it. +pub inline fn fastUnary(op: UnaryOp, a: Value) ?Value { + const d: i64 = if (op == .inc) 1 else -1; + if (a.tag() == .integer) { + const res = @as(i64, a.asInteger()) + d; + if (res < nb.NB_I48_MIN or res > nb.NB_I48_MAX) return null; + return Value.initInteger(res); + } + if (a.tag() == .float) return Value.initFloat(a.asFloat() + @as(f64, @floatFromInt(d))); + return null; +} + // --- Collection-accessor intrinsics (ADR-0130 extended; O-043) --- // // `op_get` (2-arg `(get coll k)`) and `op_nth` (3-arg `(nth coll i default)`) diff --git a/src/eval/backend/vm.zig b/src/eval/backend/vm.zig index fb814020c..7da68f89b 100644 --- a/src/eval/backend/vm.zig +++ b/src/eval/backend/vm.zig @@ -874,6 +874,30 @@ inline fn stepOnce( stack[sp] = result; sp += 1; }, + .op_inc, .op_dec => { + // PERF: unary inc/dec intrinsic; replaces its operand in place. + // Fixnum/float fast path, else the cached (maybe redefined) Var, + // same deopt contract as the binary family. [refs: O-102] + const uop = @call(.always_inline, intrinsic.fromUnaryOpcode, .{instr_op}).?; + if (sp < 1) return raiseInternal("vm: unary intrinsic underflow"); + sp -= 1; + const a = stack[sp]; + const fast: ?Value = if (rt.core_arith_pristine) intrinsic.fastUnary(uop, a) else null; + const result = fast orelse blk: { + const vt = rt.vtable orelse + return error_catalog.raiseInternal(.{}, "Runtime vtable not installed; cannot dispatch unary intrinsic"); + const pv = rt.unary_vars[@intFromEnum(uop)] orelse + return raiseInternal("vm: unary intrinsic emitted without a cached Var"); + const op_var: *const env_mod.Var = @ptrCast(@alignCast(pv)); + const one = [1]Value{a}; + const prev_arg_sources = error_mod.swapArgSources(loc_stack[sp .. sp + 1]); + defer _ = error_mod.swapArgSources(prev_arg_sources); + break :blk try vt.callFn(rt, env, op_var.deref(), &one, instr_loc); + }; + loc_stack[sp] = instr_loc; + stack[sp] = result; + sp += 1; + }, .op_add_local_const, .op_sub_local_const, .op_mul_local_const, .op_lt_local_const, .op_le_local_const, .op_gt_local_const, .op_ge_local_const, .op_eq_local_const, .op_mod_local_const, .op_rem_local_const, .op_quot_local_const, .op_ne_local_const => { // PERF: local-const arith superinstruction — operands come // from `locals[slot]` + `constants[idx]` (packed in the operand), NOT the diff --git a/src/eval/backend/vm/compiler.zig b/src/eval/backend/vm/compiler.zig index 8569bf20c..f4cdf4be2 100644 --- a/src/eval/backend/vm/compiler.zig +++ b/src/eval/backend/vm/compiler.zig @@ -413,6 +413,21 @@ const Compiler = struct { return; } } + // PERF: `(inc x)` / `(dec x)` → op_inc/op_dec instead of op_get_var + + // op_call into the builtin, which re-entered `plus` through a slice + // (~600 instructions a call). No fused local form: the arg's own loc + // must reach the loc_stack for an arg-precise caret [refs: O-102] + if (n.args.len == 1 and n.callee.* == .var_ref) { + if (intrinsic.recognizeUnary(self.rt, n.callee.var_ref.var_ptr)) |uop| { + try self.compileNode(&n.args[0]); + if (n.loc.line != 0) { + self.current_line = n.loc.line; + self.current_column = n.loc.column; + } + try self.emit(if (uop == .inc) .op_inc else .op_dec, 0); + return; + } + } // PERF: collection-accessor intrinsics (ADR-0130 extended; O-043). `(get // coll k)` (2-arg) → op_get; `(nth coll i default)` (3-arg) → op_nth. // Same Var-pointer gate + runtime `core_coll_pristine` deopt as the arith diff --git a/src/eval/backend/vm/opcode.zig b/src/eval/backend/vm/opcode.zig index bf0d737e1..24b3b5237 100644 --- a/src/eval/backend/vm/opcode.zig +++ b/src/eval/backend/vm/opcode.zig @@ -369,6 +369,13 @@ pub const Opcode = enum(u8) { op_map_extend = 0x58, op_set_extend = 0x59, + /// O-102: unary `(inc x)` / `(dec x)` intrinsics for canonical + /// `clojure.core/inc`/`dec` (Var pointer identity, `core_arith_pristine` + /// deopt). Pop x, push the result (net 0). `intrinsic.fastUnary` handles + /// an i48 fixnum or a float; anything else calls the cached Var. + op_inc = 0x5A, + op_dec = 0x5B, + /// True when this opcode carries a **signed-i16 instruction-position /// offset** in `operand`, relative to the instruction after itself /// (vm.zig:188-201 + :317 `applyJump`). Peephole's IP-remap pass @@ -465,6 +472,8 @@ pub const Opcode = enum(u8) { .op_get, .op_nth, .op_nth2, + .op_inc, + .op_dec, => false, }; } @@ -567,6 +576,8 @@ pub const Opcode = enum(u8) { .op_get, .op_nth, .op_nth2, + .op_inc, + .op_dec, => false, }; } diff --git a/src/eval/bytecode/serialize.zig b/src/eval/bytecode/serialize.zig index eac881f79..0a395f760 100644 --- a/src/eval/bytecode/serialize.zig +++ b/src/eval/bytecode/serialize.zig @@ -98,7 +98,9 @@ pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' }; // v10: def operands capture Var constants instead of caller-relative names. // v9 added op_var_meta; v8 unified host-class FQCNs; v7 compacted WireInstr // and interned the name pool; v6 merged rt into clojure.core. -pub const VERSION: u16 = 14; +// v15: the op_inc / op_dec opcodes (O-102). A +// v14 decoder rejects them, so v14 is rejected too (as v9 did for op_var_meta). +pub const VERSION: u16 = 15; pub const SerializeError = error{ OutOfMemory, diff --git a/src/lang/bootstrap.zig b/src/lang/bootstrap.zig index bf05bed3c..5835730e3 100644 --- a/src/lang/bootstrap.zig +++ b/src/lang/bootstrap.zig @@ -577,6 +577,12 @@ pub fn cacheArithIntrinsics(rt: *Runtime, env: *Env) void { const v: ?*env_mod.Var = if (core) |c| c.resolve(name) else null; if (v) |p| rt.arith_vars[@intFromEnum(op)] = p; } + // Unary inc/dec intrinsics share the arith pristine flag. + inline for (std.meta.tags(intrinsic.UnaryOp)) |op| { + const name = intrinsic.unaryCoreName(op); + const v: ?*env_mod.Var = if (core) |c| c.resolve(name) else null; + if (v) |p| rt.unary_vars[@intFromEnum(op)] = p; + } rt.core_arith_pristine = true; // Collection-accessor intrinsics (op_get / op_nth; ADR-0130 extended, O-043). diff --git a/src/lang/diff_test.zig b/src/lang/diff_test.zig index 66037382c..3dc2bd875 100644 --- a/src/lang/diff_test.zig +++ b/src/lang/diff_test.zig @@ -250,6 +250,29 @@ test "diff: op_add deopt on alter-var-root of + (ADR-0130 F-011 hole)" { try f.check("(do (alter-var-root (var +) (fn* [_] (fn* [a b] 999))) (+ 1 2))", 999); } +test "diff: inc/dec intrinsics (O-102) — VM op_inc/op_dec ≡ builtin (inline)" { + var f: Fixture = undefined; + try Fixture.init(&f, testing.allocator); + defer f.deinit(); + try f.check("(inc 41)", 42); + try f.check("(dec 43)", 42); + try f.check("(let* [x 9] (inc x))", 10); + try f.check("(let* [x 9] (dec x))", 8); + try f.check("(let* [x -1] (inc (inc x)))", 1); + try f.checkEqual("(inc 0.5)"); + try f.checkEqual("(let* [x 1.5] (dec x))"); + try f.check("(loop* [i 0] (if (< i 10) (recur (inc i)) i))", 10); + // Non-number: both backends raise through the builtin. + try f.checkEqual("(try (inc nil) (catch Exception _ :caught))"); +} + +test "diff: inc deopt on alter-var-root of inc (O-102)" { + var f: Fixture = undefined; + try Fixture.init(&f, testing.allocator); + defer f.deinit(); + try f.check("(do (alter-var-root (var inc) (fn* [_] (fn* [a] 777))) (let* [x 1] (inc x)))", 777); +} + test "diff: arith intrinsic family (ADR-0130 am1) — sub/mul/comparisons/= (inline)" { var f: Fixture = undefined; try Fixture.init(&f, testing.allocator); diff --git a/src/lang/primitive/core.zig b/src/lang/primitive/core.zig index ca2d3c103..7e21d94fd 100644 --- a/src/lang/primitive/core.zig +++ b/src/lang/primitive/core.zig @@ -1784,6 +1784,12 @@ pub fn alterVarRootFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceL } } } + // inc/dec (op_inc / op_dec) share the arith deopt flag. + for (rt.unary_vars) |cached| { + if (cached) |pv| { + if (@intFromPtr(pv) == @intFromPtr(v)) rt.core_arith_pristine = false; + } + } // Same deopt for the collection-accessor intrinsics (op_get / op_nth, O-043): // an alter-var-root on the cached `get`/`nth` Var must honour the new root. for (rt.coll_vars) |cached| { diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig index 2d863b53c..76c632b8e 100644 --- a/src/runtime/runtime.zig +++ b/src/runtime/runtime.zig @@ -210,6 +210,11 @@ pub const Runtime = struct { /// cannot import the Layer-1 intrinsic module). arith_vars: [12]?*anyopaque = .{ null, null, null, null, null, null, null, null, null, null, null, null }, core_arith_pristine: bool = true, + /// Cached canonical `clojure.core/inc` + `dec` Vars for the unary intrinsics + /// (op_inc / op_dec and their `_local` forms). Index = `intrinsic.UnaryOp` + /// (0=inc, 1=dec). They share `core_arith_pristine`: an `alter-var-root` on + /// either clears it, so the VM defers to the redefined root. + unary_vars: [2]?*anyopaque = .{ null, null }, /// Cached canonical `clojure.core/get` + `nth` Vars for the collection-accessor /// intrinsics (op_get / op_nth; ADR-0130 extended, O-043). Same lifecycle as From e75098d1532236b411bcae1200d85a539a6dd096 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 00:49:12 -0300 Subject: [PATCH 20/51] docs(runtime): unary_vars comment names only op_inc/op_dec The fused _local form was tried and rejected (it lost the arg-precise error caret), so the comment no longer mentions it. Smell-audited: 1: comment-only fix of a reference to a rejected design --- src/runtime/runtime.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig index 76c632b8e..a2c6517e5 100644 --- a/src/runtime/runtime.zig +++ b/src/runtime/runtime.zig @@ -211,7 +211,7 @@ pub const Runtime = struct { arith_vars: [12]?*anyopaque = .{ null, null, null, null, null, null, null, null, null, null, null, null }, core_arith_pristine: bool = true, /// Cached canonical `clojure.core/inc` + `dec` Vars for the unary intrinsics - /// (op_inc / op_dec and their `_local` forms). Index = `intrinsic.UnaryOp` + /// (op_inc / op_dec). Index = `intrinsic.UnaryOp` /// (0=inc, 1=dec). They share `core_arith_pristine`: an `alter-var-root` on /// either clears it, so the VM defers to the redefined root. unary_vars: [2]?*anyopaque = .{ null, null }, From 883312f6c49004ef7ad03d0cb06a6ca990c87c16 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 01:09:21 -0300 Subject: [PATCH 21/51] perf(gc): inline the small-cell allocation fast path perf record on lazy_chain put about 25% of user instructions in the allocation path, but the `allocations` side-list append was only 2.6% (1.1% on gc_churn). Most of the rest was the out-of-line cellAlloc call (error-union spill, stack canary, a runtime size-class lookup) and the gc_mutex lock/unlock pair. Inline PageHeap.alloc's head-page free-slot/bump path and GcHeap.cellAlloc into each typed allocSized instance, so the size class and small/large choice fold at compile time. The full-page and new-page cases move to noinline allocSlow, and large cells to cellAllocLarge. The live-list record is stored with appendAssumeCapacity when there is room. Wrapping adds on the page counters, which are bounded by capacity. Pinned instructions:u: lazy_chain 9.069G -> 8.267G (-8.8%), gc_churn 8.049G -> 7.748G (-3.7%). Peak RSS unchanged. Smell-audited: 1: inline fast path plus noinline slow paths; no new state, no change to the sweep or side-list contract --- CHANGELOG.md | 7 +++++++ src/runtime/gc/gc_heap.zig | 22 +++++++++++++++++++--- src/runtime/gc/page_heap.zig | 30 ++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78465ab94..879351b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ### Changed +- Allocating a small GC cell is cheaper: the page heap's free-slot and bump + path is now inlined into each typed allocation site, with the size class + and the small/large choice folded at compile time, and the live-list + record is stored in place when the list has room. On a pinned core (user + instructions): lazy_chain 9.07 G to 8.27 G (-8.8 %), gc_churn 8.05 G to + 7.75 G (-3.7 %), about 85 fewer instructions per allocation. Peak RSS is + unchanged. - A string of up to about 4 KB now keeps its bytes inside its own GC cell instead of in a separate malloc block freed by a finaliser: one allocation per string instead of two. Measured on a pinned core (user instructions, a diff --git a/src/runtime/gc/gc_heap.zig b/src/runtime/gc/gc_heap.zig index d3c646ecd..065882d2c 100644 --- a/src/runtime/gc/gc_heap.zig +++ b/src/runtime/gc/gc_heap.zig @@ -346,11 +346,19 @@ pub const GcHeap = struct { const obj: *T = @ptrCast(@alignCast(raw)); const hdr: *HeapHeader = @ptrCast(@alignCast(raw)); - try self.allocations.append(self.infra, .{ + const rec: gc_types.AllocRecord = .{ .header = hdr, .size = effective_size, .alignment = align_t, - }); + }; + // CLJW-PERF-ALLOC-FASTPATH: the side list almost always has room + // (it only grows, and sweep swap-removes), so store in place and + // leave the out-of-line `append` to the growth case. + if (self.allocations.items.len < self.allocations.capacity) { + self.allocations.appendAssumeCapacity(rec); + } else { + try self.allocations.append(self.infra, rec); + } self.stats.bytes_allocated += effective_size; self.stats.alloc_count += 1; self.bytes_since_last_gc += effective_size; @@ -359,11 +367,19 @@ pub const GcHeap = struct { /// Backing memory for one cell: a page-heap slot when the cell is small, /// an `infra` block otherwise. Caller holds `gc_mutex`. - fn cellAlloc(self: *GcHeap, size: usize, alignment: std.mem.Alignment) error{OutOfMemory}![*]u8 { + /// Inline (CLJW-PERF-ALLOC-FASTPATH): `alignment` and usually `size` are + /// comptime-known at each `allocSized` instance, so the small/large + /// choice and the size class fold away and the page fast path lands in + /// the caller with no call, error-union spill or stack canary. + inline fn cellAlloc(self: *GcHeap, size: usize, alignment: std.mem.Alignment) error{OutOfMemory}![*]u8 { if (page_heap_mod.isSmall(size, alignment)) { self.stats.small_allocs += 1; return self.pages.alloc(size) orelse error.OutOfMemory; } + return self.cellAllocLarge(size, alignment); + } + + noinline fn cellAllocLarge(self: *GcHeap, size: usize, alignment: std.mem.Alignment) error{OutOfMemory}![*]u8 { const mem = self.infra.rawAlloc(size, alignment, @returnAddress()) orelse return error.OutOfMemory; self.large_live_bytes += size; return mem; diff --git a/src/runtime/gc/page_heap.zig b/src/runtime/gc/page_heap.zig index f4ffc96ef..e8de8bb3e 100644 --- a/src/runtime/gc/page_heap.zig +++ b/src/runtime/gc/page_heap.zig @@ -93,7 +93,7 @@ const small_lookup = blk: { /// The class serving `size` bytes, or null when `size` is above /// `max_small_bytes`. -pub fn classOf(size: usize) ?u8 { +pub inline fn classOf(size: usize) ?u8 { if (size <= 512) return small_lookup[(size + 7) / 8]; if (size > max_small_bytes) return null; var c: usize = small_lookup[512 / 8]; @@ -162,8 +162,34 @@ pub const PageHeap = struct { /// Allocate one slot for a cell of `size` bytes (`isSmall` must hold). /// Returns null only when the OS refuses a new page. - pub fn alloc(self: *PageHeap, size: usize) ?[*]u8 { + /// + /// Inline fast path (CLJW-PERF-ALLOC-FASTPATH): the head page of the + /// class has a freed slot or unbumped space, which is nearly every call. + /// Everything else (a full head page, an empty class, a new page) is + /// `allocSlow`, kept out of line so the fast path stays a handful of + /// instructions inside `GcHeap.allocSized`. The counters use wrapping + /// arithmetic: `used <= capacity` and `bump + slot_size < 2 * page_bytes` + /// bound them far below overflow, so ReleaseSafe's checks bought nothing. + pub inline fn alloc(self: *PageHeap, size: usize) ?[*]u8 { const c = classOf(size).?; + if (self.avail[c]) |page| { + if (page.free) |slot| { + page.free = slot.next; + page.used +%= 1; + return @as([*]u8, @ptrCast(slot)) - 8; + } + const next = page.bump +% page.slot_size; + if (next <= page_bytes) { + const p = page.base() + page.bump; + page.bump = next; + page.used +%= 1; + return p; + } + } + return self.allocSlow(c); + } + + noinline fn allocSlow(self: *PageHeap, c: u8) ?[*]u8 { while (true) { const page = self.avail[c] orelse self.newPage(c) orelse return null; if (page.free) |slot| { From 4359467a63df64595a3d74891d9d2976426ebb2a Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 02:14:32 -0300 Subject: [PATCH 22/51] perf(collections): edit owned transient HAMT paths in place Smell-audited: 1: Isolate owner-checked mutable trie logic in a small module; freeze nodes before publishing maps. --- CHANGELOG.md | 8 + bench/vs_bb/workloads/into_map_only.clj | 1 + .../vs_bb/workloads/into_transient_checks.clj | 19 ++ src/runtime/collection/map.zig | 3 + .../collection/transient/editable_hamt.zig | 170 ++++++++++++++++++ .../transient/transient_array_map.zig | 18 +- test/e2e/into_editable_hamt.clj | 22 +++ 7 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 bench/vs_bb/workloads/into_map_only.clj create mode 100644 bench/vs_bb/workloads/into_transient_checks.clj create mode 100644 src/runtime/collection/transient/editable_hamt.zig create mode 100644 test/e2e/into_editable_hamt.clj diff --git a/CHANGELOG.md b/CHANGELOG.md index 1616740ca..78183bb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Changed + +- Transient maps and sets now edit owned HAMT paths in place during `into`, + cloning only shared nodes; `persistent!` freezes ownership so later transients + cannot mutate published maps. The `into_collections` workload drops from + 18.215B to 17.111B user instructions (-6.1%); isolated 20k-entry map `into` + drops from 2.599B to 2.016B (-22.4%) on one pinned core. + ### Fixed - The non-blocking bench-vs-bb CI job no longer fails on a restored diff --git a/bench/vs_bb/workloads/into_map_only.clj b/bench/vs_bb/workloads/into_map_only.clj new file mode 100644 index 000000000..583a7ad17 --- /dev/null +++ b/bench/vs_bb/workloads/into_map_only.clj @@ -0,0 +1 @@ +(let [v (vec (range 20000)) t (System/nanoTime) r (loop [i 0 s 0] (if (< i 15) (let [m (into {} (map (fn [x] [x (* 2 x)])) v)] (recur (inc i) (+ s (count m)))) s))] (println "RESULT" r) (println "INPROC_MS" (quot (- (System/nanoTime) t) 1000000))) diff --git a/bench/vs_bb/workloads/into_transient_checks.clj b/bench/vs_bb/workloads/into_transient_checks.clj new file mode 100644 index 000000000..2b34eb432 --- /dev/null +++ b/bench/vs_bb/workloads/into_transient_checks.clj @@ -0,0 +1,19 @@ +(let [base (into {} (map (fn [i] [i i]) (range 120))) + edited (persistent! (assoc! (transient base) 1 :changed 120 :new)) + removed (persistent! (dissoc! (transient edited) 1)) + s (into #{} (map #(mod % 1000)) (range 20000)) + xform (into {:seed 7} (map (fn [i] [i (* 2 i)])) (range 120)) + spent (transient {})] + (persistent! spent) + (assert (= 1 (get base 1))) + (assert (= :changed (get edited 1))) + (assert (= :new (get edited 120))) + (assert (= 121 (count edited))) + (assert (= nil (get removed 1))) + (assert (= 120 (count removed))) + (assert (= 1000 (count s))) + (assert (= 121 (count xform))) + (assert (= 7 (get xform :seed))) + (assert (= 238 (get xform 119))) + (assert (try (assoc! spent 2 3) false (catch Exception e true))) + (println "PASS transient map, set, xform, snapshot, spent")) diff --git a/src/runtime/collection/map.zig b/src/runtime/collection/map.zig index 59d13dcb4..6eacd0e0e 100644 --- a/src/runtime/collection/map.zig +++ b/src/runtime/collection/map.zig @@ -102,6 +102,9 @@ pub const HamtMapNode = extern struct { _pad: [2]u8 = .{ 0, 0 }, data_map: u32 = 0, node_map: u32 = 0, + // Zero on persistent nodes; a transient's identity on editable nodes. + // A new transient clones any node whose owner differs, preserving sharing. + edit_owner: usize = 0, slots: [64]Value = @splat(Value.nil_val), comptime { diff --git a/src/runtime/collection/transient/editable_hamt.zig b/src/runtime/collection/transient/editable_hamt.zig new file mode 100644 index 000000000..b5a3b8edd --- /dev/null +++ b/src/runtime/collection/transient/editable_hamt.zig @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: EPL-2.0 +//! Editable HAMT nodes for transient maps. A node with another owner is cloned +//! before mutation; persistent source maps therefore remain immutable. +const Runtime = @import("../../runtime.zig").Runtime; +const Value = @import("../../value/value.zig").Value; +const HeapHeader = @import("../../value/value.zig").HeapHeader; +const map = @import("../map.zig"); +const equal = @import("../../equal.zig"); +const Node = map.HamtMapNode; + +fn bit(hash: u32, shift: u32) u32 { + return @as(u32, 1) << @as(u5, @intCast((hash >> @intCast(shift)) & 31)); +} +fn index(bitmap: u32, b: u32) u32 { + return @popCount(bitmap & (b - 1)); +} +fn nodeValue(node: *Node) Value { + return Value.encodeHeapPtrRuntime(@enumFromInt(node.header.tag), node); +} +fn pair(rt: *Runtime, k1: Value, v1: Value, h1: u32, k2: Value, v2: Value, h2: u32, shift: u32, owner: usize) anyerror!*Node { + if (shift > 30) { + const bucket = try rt.gc.alloc(Node); + bucket.* = .{ .header = HeapHeader.init(.hash_collision_map_node), .data_map = 3, .edit_owner = owner }; + bucket.slots[0] = k1; + bucket.slots[1] = v1; + bucket.slots[2] = k2; + bucket.slots[3] = v2; + return bucket; + } + const b1 = bit(h1, shift); + const b2 = bit(h2, shift); + const node = try rt.gc.alloc(Node); + node.* = .{ .header = HeapHeader.init(.hamt_map_node), .edit_owner = owner }; + if (b1 == b2) { + node.node_map = b1; + node.slots[63] = nodeValue(try pair(rt, k1, v1, h1, k2, v2, h2, shift + 5, owner)); + } else { + node.data_map = b1 | b2; + const i = index(node.data_map, b1); + const j = index(node.data_map, b2); + node.slots[2 * i] = k1; + node.slots[2 * i + 1] = v1; + node.slots[2 * j] = k2; + node.slots[2 * j + 1] = v2; + } + return node; +} +fn editable(rt: *Runtime, node: *Node, owner: usize) !*Node { + if (node.edit_owner == owner) return node; + const copy = try rt.gc.alloc(Node); + copy.* = node.*; + copy.edit_owner = owner; + return copy; +} + +const Result = struct { node: *Node, added: bool }; +fn bucketPut(rt: *Runtime, source: *Node, k: Value, v: Value, owner: usize) anyerror!Result { + const n: u32 = @popCount(source.data_map); + var i: u32 = 0; + while (i < n) : (i += 1) { + if (try equal.eqConsult(source.slots[2 * i], k)) { + const node = try editable(rt, source, owner); + node.slots[2 * i + 1] = v; + return .{ .node = node, .added = false }; + } + } + if (source.node_map != 0) { + const res = try bucketPut(rt, source.slots[63].decodePtr(*Node), k, v, owner); + const node = try editable(rt, source, owner); + node.slots[63] = nodeValue(res.node); + return .{ .node = node, .added = res.added }; + } + const node = try editable(rt, source, owner); + if (n < 31) { + node.slots[2 * n] = k; + node.slots[2 * n + 1] = v; + node.data_map = (node.data_map << 1) | 1; + } else { + const child = try rt.gc.alloc(Node); + child.* = .{ .header = HeapHeader.init(.hash_collision_map_node), .data_map = 1, .edit_owner = owner }; + child.slots[0] = k; + child.slots[1] = v; + node.node_map = 1; + node.slots[63] = nodeValue(child); + } + return .{ .node = node, .added = true }; +} + +// Collision buckets are rare and use the persistent insertion path below; +// ordinary branches only allocate when the node belongs to a different owner. +fn put(rt: *Runtime, source: *Node, k: Value, v: Value, h: u32, shift: u32, owner: usize) !Result { + const b = bit(h, shift); + if (source.data_map & b != 0) { + const di = index(source.data_map, b); + const old_key = source.slots[2 * di]; + if (try equal.eqConsult(old_key, k)) { + const node = try editable(rt, source, owner); + node.slots[2 * di + 1] = v; + return .{ .node = node, .added = false }; + } + const sub = try pair(rt, old_key, source.slots[2 * di + 1], try equal.hashConsult(old_key), k, v, h, shift + 5, owner); + const node = try editable(rt, source, owner); + const n: u32 = @popCount(node.data_map); + var i = di; + while (i + 1 < n) : (i += 1) { + node.slots[2 * i] = node.slots[2 * (i + 1)]; + node.slots[2 * i + 1] = node.slots[2 * (i + 1) + 1]; + } + node.slots[2 * (n - 1)] = Value.nil_val; + node.slots[2 * (n - 1) + 1] = Value.nil_val; + node.data_map &= ~b; + const ni = index(node.node_map, b); + var j: u32 = @popCount(node.node_map); + while (j > ni) : (j -= 1) node.slots[63 - j] = node.slots[63 - (j - 1)]; + node.slots[63 - ni] = nodeValue(sub); + node.node_map |= b; + return .{ .node = node, .added = true }; + } + if (source.node_map & b != 0) { + const ni = index(source.node_map, b); + const child_val = source.slots[63 - ni]; + if (child_val.tag() == .hash_collision_map_node) { + const res = try bucketPut(rt, child_val.decodePtr(*Node), k, v, owner); + const node = try editable(rt, source, owner); + node.slots[63 - ni] = nodeValue(res.node); + return .{ .node = node, .added = res.added }; + } + const res = try put(rt, child_val.decodePtr(*Node), k, v, h, shift + 5, owner); + const node = try editable(rt, source, owner); + node.slots[63 - ni] = nodeValue(res.node); + return .{ .node = node, .added = res.added }; + } + const node = try editable(rt, source, owner); + const di = index(node.data_map, b); + var i: u32 = @popCount(node.data_map); + while (i > di) : (i -= 1) { + node.slots[2 * i] = node.slots[2 * (i - 1)]; + node.slots[2 * i + 1] = node.slots[2 * (i - 1) + 1]; + } + node.slots[2 * di] = k; + node.slots[2 * di + 1] = v; + node.data_map |= b; + return .{ .node = node, .added = true }; +} + +// Retire editable ownership before publishing a persistent map. Without this, +// a later transient allocated at the same address could edit the old map. +fn freezeNode(node: *Node, owner: usize) void { + if (node.edit_owner != owner) return; + node.edit_owner = 0; + var i: u32 = 0; + while (i < @popCount(node.node_map)) : (i += 1) + freezeNode(node.slots[63 - i].decodePtr(*Node), owner); +} + +pub fn freeze(overflow: Value, owner: usize) void { + const wrapper = overflow.decodePtr(*map.PersistentHashMap); + if (wrapper.root) |root| freezeNode(root, owner); +} + +/// Mutate a transient's overflow map without minting a new persistent wrapper +/// and copying every HAMT ancestor for each assoc!. owner is its transient cell. +pub fn assoc(rt: *Runtime, overflow: Value, k: Value, v: Value, owner: usize) !void { + rt.gc.enterFabrication(); + defer rt.gc.exitFabrication(); + const wrapper = overflow.decodePtr(*map.PersistentHashMap); + const result = try put(rt, wrapper.root.?, k, v, try equal.hashConsult(k), 0, owner); + wrapper.root = result.node; + if (result.added) wrapper.count += 1; +} diff --git a/src/runtime/collection/transient/transient_array_map.zig b/src/runtime/collection/transient/transient_array_map.zig index 71b047cf8..a276671b8 100644 --- a/src/runtime/collection/transient/transient_array_map.zig +++ b/src/runtime/collection/transient/transient_array_map.zig @@ -56,6 +56,7 @@ const mark_sweep = @import("../../gc/mark_sweep.zig"); const root_set = @import("../../gc/root_set.zig"); const map_mod = @import("../map.zig"); +const editable_hamt = @import("editable_hamt.zig"); const vector_mod = @import("../vector.zig"); const sub_vector_mod = @import("../sub_vector.zig"); const map_entry_mod = @import("../map_entry.zig"); @@ -90,6 +91,8 @@ fn keyEq(a: Value, b: Value) bool { /// `map.assoc` is copy-on-write, so the source is never mutated). /// `.nil` produces an empty transient. pub fn fromMap(rt: *Runtime, source: Value) !Value { + rt.gc.enterFabrication(); + defer rt.gc.exitFabrication(); const tm = try rt.gc.alloc(TransientArrayMap); tm.* = .{ .header = HeapHeader.init(.transient_map) }; @@ -106,9 +109,14 @@ pub fn fromMap(rt: *Runtime, source: Value) !Value { tm.meta = am.meta; }, .hash_map => { - tm.overflow = source; - tm.count = map_mod.count(source); - tm.meta = map_mod.metaOf(source); + // The wrapper is mutable while transient, but its trie is shared + // until the first owner-checked edit. Never mutate the source map. + const src = source.decodePtr(*const map_mod.PersistentHashMap); + const copy = try rt.gc.alloc(map_mod.PersistentHashMap); + copy.* = src.*; + tm.overflow = Value.encodeHeapPtr(.hash_map, copy); + tm.count = src.count; + tm.meta = src.meta; }, else => unreachable, // caller (primitive layer) gates the tag set } @@ -132,6 +140,7 @@ pub fn toPersistent(rt: *Runtime, tm_val: Value, loc: SourceLocation) !Value { out = try map_mod.assoc(rt, out, tm.entries[2 * i], tm.entries[2 * i + 1]); } } else { + editable_hamt.freeze(tm.overflow, @intFromPtr(tm)); out = tm.overflow; // already a persistent .hash_map } if (tm.meta.isNil()) return out; @@ -145,7 +154,7 @@ pub fn assoc(rt: *Runtime, tm_val: Value, k: Value, v: Value, loc: SourceLocatio // Hash mode: delegate to the persistent HAMT (dedups + promotes for // free; the result equals the persistent-conj path byte-for-byte). if (!tm.overflow.isNil()) { - tm.overflow = try map_mod.assoc(rt, tm.overflow, k, v); + try editable_hamt.assoc(rt, tm.overflow, k, v, @intFromPtr(tm)); tm.count = map_mod.count(tm.overflow); return tm_val; } @@ -216,6 +225,7 @@ pub fn dissoc(rt: *Runtime, tm_val: Value, k: Value, loc: SourceLocation) !Value // Hash mode: delegate to persistent dissoc. Stays in hash mode even // if count drops ≤ 8 (matches the persistent side's no demotion). if (!tm.overflow.isNil()) { + editable_hamt.freeze(tm.overflow, @intFromPtr(tm)); tm.overflow = try map_mod.dissoc(rt, tm.overflow, k); tm.count = map_mod.count(tm.overflow); return tm_val; diff --git a/test/e2e/into_editable_hamt.clj b/test/e2e/into_editable_hamt.clj new file mode 100644 index 000000000..37ed6814e --- /dev/null +++ b/test/e2e/into_editable_hamt.clj @@ -0,0 +1,22 @@ +;; Editable HAMT must preserve the original and the published snapshot. +(let [source (into {} (map (fn [i] [i i]) (range 200))) + snapshot (persistent! (assoc! (transient source) 4 :changed 201 :added)) + copied (persistent! (assoc! (transient snapshot) 5 :second)) + removed (persistent! (dissoc! (transient copied) 4)) + s (into #{} (map #(mod % 1000)) (range 20000)) + m (with-meta {} {:origin :into}) + result (into m (map (fn [i] [i i])) (range 200)) + sorted (into (sorted-map) [[2 :b] [1 :a]]) + spent (transient {})] + (persistent! spent) + (assert (= 4 (get source 4))) + (assert (= :changed (get snapshot 4))) + (assert (= 5 (get snapshot 5))) + (assert (= :second (get copied 5))) + (assert (= nil (get removed 4))) + (assert (= 200 (count removed))) + (assert (= 1000 (count s))) + (assert (= {:origin :into} (meta result))) + (assert (= [1 2] (vec (keys sorted)))) + (assert (try (assoc! spent :x 1) false (catch Exception e true))) + (println "PASS editable HAMT into")) From 2813a60cc1232646acc85684ce0d1c5bf52128bc Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 02:39:01 -0300 Subject: [PATCH 23/51] fix(collections): fresh header on cloned editable HAMT cells editable() and the transient's wrapper copy used a whole-struct copy, which carried the source cell's HeapHeader (GC mark, frozen flag, lock bits) into the new cell. Build the header fresh, as map.copyHamtNode does. Smell-audited: 1: align the clone sites with the existing copyHamtNode convention --- src/runtime/collection/transient/editable_hamt.zig | 3 +++ src/runtime/collection/transient/transient_array_map.zig | 1 + 2 files changed, 4 insertions(+) diff --git a/src/runtime/collection/transient/editable_hamt.zig b/src/runtime/collection/transient/editable_hamt.zig index b5a3b8edd..fe9ddfd6f 100644 --- a/src/runtime/collection/transient/editable_hamt.zig +++ b/src/runtime/collection/transient/editable_hamt.zig @@ -49,6 +49,9 @@ fn editable(rt: *Runtime, node: *Node, owner: usize) !*Node { if (node.edit_owner == owner) return node; const copy = try rt.gc.alloc(Node); copy.* = node.*; + // A fresh header, as `map.copyHamtNode` does: never inherit the source + // cell's GC mark, frozen flag or lock bits. + copy.header = HeapHeader.init(@enumFromInt(node.header.tag)); copy.edit_owner = owner; return copy; } diff --git a/src/runtime/collection/transient/transient_array_map.zig b/src/runtime/collection/transient/transient_array_map.zig index a276671b8..2d30d8129 100644 --- a/src/runtime/collection/transient/transient_array_map.zig +++ b/src/runtime/collection/transient/transient_array_map.zig @@ -114,6 +114,7 @@ pub fn fromMap(rt: *Runtime, source: Value) !Value { const src = source.decodePtr(*const map_mod.PersistentHashMap); const copy = try rt.gc.alloc(map_mod.PersistentHashMap); copy.* = src.*; + copy.header = HeapHeader.init(.hash_map); tm.overflow = Value.encodeHeapPtr(.hash_map, copy); tm.count = src.count; tm.meta = src.meta; From 647e1bd8b24653e51997f11105ab3341cec61af5 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 03:43:48 -0300 Subject: [PATCH 24/51] perf(reduce): index vector reduce-kv without interpreted loop Smell-audited: 1: isolated the indexed reduce-kv driver in a small primitive module; rejected a lazy-map shortcut that broke memoization --- CHANGELOG.md | 9 ++++++ src/lang/clj/clojure/core.clj | 8 ++---- src/lang/primitive/collection.zig | 2 ++ src/lang/primitive/reduce_kv_vector.zig | 37 +++++++++++++++++++++++++ test/clj/suites/composition_test.clj | 33 ++++++++++++++++++---- test/clj/suites/reduce_helpers_test.clj | 9 ++++++ 6 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 src/lang/primitive/reduce_kv_vector.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d66c4a0..94dfc49d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. user instructions per iteration, down from 1585 (pinned core, `instructions:u`); `(loop [i 0] (if (< i n) (recur (inc i)) i))` 601, down from 1307. Bytecode format v15 (v14 artifacts are rejected). + +- `reduce-kv` on vectors and subvecs now indexes in Zig instead of interpreting + the per-element loop, preserving reduced early termination and the original + indexed keys. On staging with the newer VM and allocator, a pinned-core + 40-pass, 100k-element vector probe falls from 16.01B to 4.54B user + instructions (-71.7%). The `reduce_map` lazy-map shortcut was rejected + because it skipped observable lazy realization/memoization; its parity-safe + path stays at 29.29B instructions, and `transduce` stays at 28.82B. + - A string of up to about 4 KB now keeps its bytes inside its own GC cell instead of in a separate malloc block freed by a finaliser: one allocation per string instead of two. Measured on a pinned core (user instructions, a diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj index 717f3f093..08e9f357f 100644 --- a/src/lang/clj/clojure/core.clj +++ b/src/lang/clj/clojure/core.clj @@ -1139,11 +1139,9 @@ (def reduce-kv (fn* [f init m] (if (vector? m) - (loop* [i 0 acc init] - (if (< i (count m)) - (let* [r (f acc i (nth m i))] - (if (reduced? r) (deref r) (recur (inc i) r))) - acc)) + ;; The indexed collection can drive the reduction directly in Zig; + ;; no interpreted loop/recur, seq walk, or pair allocation per item. + (cljw.internal/__reduce-kv-vector f init m) ;; IKVReduce first (a deftype declaring `kvreduce`, D-400 — clj ;; consults the interface before any fallback); the sentinel keyword ;; is interned, so `identical?` detects "no impl" exactly. diff --git a/src/lang/primitive/collection.zig b/src/lang/primitive/collection.zig index 14222aa57..6d15f635d 100644 --- a/src/lang/primitive/collection.zig +++ b/src/lang/primitive/collection.zig @@ -68,6 +68,7 @@ const root_set = @import("../../runtime/gc/root_set.zig"); // O-033: the reentrant-call helper `update-in`'s leaf `f` rides (no cycle — // `higher_order` imports `runtime/collection/*`, not this Layer-2 file). const invokeCallable = @import("higher_order.zig").invokeCallable; +const reduce_kv_vector = @import("reduce_kv_vector.zig"); // --- conj --- @@ -1384,6 +1385,7 @@ const ENTRIES = [_]Entry{ .{ .name = "conj", .f = &conjFn }, .{ .name = "__update-in", .f = &updateInFn }, .{ .name = "__kv-reduce-or", .f = &kvReduceOrFn }, + .{ .name = "__reduce-kv-vector", .f = &reduce_kv_vector.run }, .{ .name = "queue?", .f = &queueQFn }, .{ .name = "__queue-pop", .f = &queuePopFn }, .{ .name = "__vector-pop", .f = &vectorPopFn }, diff --git a/src/lang/primitive/reduce_kv_vector.zig b/src/lang/primitive/reduce_kv_vector.zig new file mode 100644 index 000000000..46ad39a02 --- /dev/null +++ b/src/lang/primitive/reduce_kv_vector.zig @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: EPL-2.0 +//! Indexed reduce-kv on a persistent vector or subvec. The collection is +//! already keyed by its index, so no seq, pair allocation, or interpreted +//! loop/recur is needed. All user callbacks still use normal invocation. +const Value = @import("../../runtime/value/value.zig").Value; +const Runtime = @import("../../runtime/runtime.zig").Runtime; +const Env = @import("../../runtime/env.zig").Env; +const SourceLocation = @import("../../runtime/error/info.zig").SourceLocation; +const error_catalog = @import("../../runtime/error/catalog.zig"); +const vector = @import("../../runtime/collection/vector.zig"); +const sub_vector = @import("../../runtime/collection/sub_vector.zig"); +const reduced = @import("../../runtime/collection/reduced.zig"); +const root_set = @import("../../runtime/gc/root_set.zig"); +const invokeCallable = @import("higher_order.zig").invokeCallable; + +pub fn run(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + try error_catalog.checkArity("-reduce-kv-vector", args, 3, loc); + const f = args[0]; + const coll = args[2]; + const sub = coll.tag() == .sub_vector; + const n = if (sub) sub_vector.count(coll) else vector.count(coll); + // GC-ROOT: callback can reenter eval/GC; retain the original collection, + // reducing fn and latest accumulator across every user call. + var roots: [3]Value = .{ f, args[1], coll }; + const sp: u16 = roots.len; + var frame: root_set.EvalFrame = .{ .stack = &roots, .sp = &sp, .locals = &.{}, .parent = root_set.eval_frame_head }; + root_set.eval_frame_head = &frame; + defer root_set.eval_frame_head = frame.parent; + var i: u32 = 0; + while (i < n) : (i += 1) { + const item = if (sub) sub_vector.nth(coll, i) else vector.nth(coll, i); + const r = try invokeCallable(rt, env, f, &.{ roots[1], Value.initInteger(@intCast(i)), item }, loc); + if (reduced.isReduced(r)) return reduced.unreduce(r); + roots[1] = r; + } + return roots[1]; +} diff --git a/test/clj/suites/composition_test.clj b/test/clj/suites/composition_test.clj index 687f56f07..9810bf047 100644 --- a/test/clj/suites/composition_test.clj +++ b/test/clj/suites/composition_test.clj @@ -23,14 +23,37 @@ (is (= 4 (count (apply (fn* [& xs] xs) [1 2 3 4]))))) ; count_apply (deftest reduce-with-and-without-an-init - (is (= 10 (reduce + 0 [1 2 3 4]))) ; reduce_init - (is (= 10 (reduce + [1 2 3 4]))) ; reduce_no_init + (is (= 10 (reduce + 0 [1 2 3 4]))) + (is (= 10 (reduce + [1 2 3 4]))) (testing "an empty coll with no init uses the fn's zero-arity" - (is (= 0 (reduce + [])))) ; reduce_empty + (is (= 0 (reduce + [])))) (testing "the accumulator drives the fn, so it can stop growing" (is (= 6 (reduce (fn* [acc x] (if (> acc 5) acc (+ acc x))) - 0 [1 2 3 4 5 6])))) ; reduce_early - (is (= #{1 2 3} (reduce conj (hash-set) [1 2 3])))) ; reduce_conj_set + 0 [1 2 3 4 5 6])))) + (is (= #{1 2 3} (reduce conj (hash-set) [1 2 3]))) + (testing "multi-vector lazy maps retain realization and memoized effects" + (let [calls (atom 0) + s (map (fn [a b] (swap! calls inc) (+ a b)) [1 2 3] [4 5 6])] + (is (= 21 (reduce + s))) + (is (= 3 @calls)) + (is (realized? s)) + (is (= 21 (reduce + 0 s))) + (is (= 3 @calls)))) + (testing "early termination, empty, shortest source and 3-source map" + (let [calls (atom 0) + s (map (fn [a b] (swap! calls inc) (+ a b)) [1 2 3] [4 5 6])] + (is (= 5 (reduce (fn [acc x] (reduced x)) 0 s))) + (is (= 1 @calls))) + (is (= 0 (reduce + (map + [] [1])))) + (is (= 42 (reduce + 42 (map + [] [1])))) + (is (= 5 (reduce + (map + [1 2] [4])))) + (is (= 21 (reduce + (map + [1 2] [3 4] [5 6]))))) + (testing "a previously forced head is never mapped twice" + (let [calls (atom 0) + s (map (fn [a b] (swap! calls inc) (+ a b)) [1 2] [3 4])] + (is (= 4 (first s))) + (is (= 10 (reduce + s))) + (is (= 2 @calls))))) ; reduce_conj_set (deftest into-targets-any-collection (is (= [1 2 3] (into [] [1 2 3]))) ; into_vec_from_vec diff --git a/test/clj/suites/reduce_helpers_test.clj b/test/clj/suites/reduce_helpers_test.clj index 8e74477c2..a80f9454a 100644 --- a/test/clj/suites/reduce_helpers_test.clj +++ b/test/clj/suites/reduce_helpers_test.clj @@ -20,3 +20,12 @@ (is (= "(1 2 3)" (pr-str (flatten [1 [2] 3]))) "flatten_seq") (is (= "true" (pr-str (seq? (flatten [1 2])))) "flatten_isseq") (is (= "[0 1 3 6]" (pr-str (into [] (reductions + 0 [1 2 3])))) "reductions")) + +(deftest reduce-kv-indexed-cases + (is (= 46 (reduce-kv (fn [acc i x] (+ acc (* i x))) 0 [4 5 7 9]))) + (is (= 9 (reduce-kv (fn [acc i x] (+ acc i x)) 0 (subvec [50 1 2 3 100] 1 4)))) + (is (= 42 (reduce-kv (fn [acc _ _] (throw (Exception. "unreachable"))) 42 []))) + (let [calls (atom 0)] + (is (= 9 (reduce-kv (fn [acc i x] (swap! calls inc) (reduced (+ acc i x))) 5 [4 10 20]))) + (is (= 1 @calls))) + (is (= 3 (reduce-kv (fn [acc _ v] (+ acc v)) 0 {:a 1 :b 2})))) From 36be4aa0e850d882b584454b3e11ef2774cc3853 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 04:21:47 -0300 Subject: [PATCH 25/51] fix(reduce): reduce-kv on a MapEntry or user vector type takes the generic loop vector? also admits a MapEntry and a deftype / reify declaring IPersistentVector, but __reduce-kv-vector read every vector? value as a vector or subvec cell. The primitive now takes the generic count/nth loop as a fallback fn and calls it for any other tag. A fallback fn rather than a sentinel return, so a reducing fn that returns the sentinel can never re-run the reduction. Smell-audited: 1: native path limited to the two cell types it decodes; generic loop kept for the rest --- src/lang/clj/clojure/core.clj | 14 +++++++++++--- src/lang/primitive/reduce_kv_vector.zig | 9 ++++++++- test/clj/suites/reduce_helpers_test.clj | 5 ++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj index 08e9f357f..3b62a6a2f 100644 --- a/src/lang/clj/clojure/core.clj +++ b/src/lang/clj/clojure/core.clj @@ -1139,9 +1139,17 @@ (def reduce-kv (fn* [f init m] (if (vector? m) - ;; The indexed collection can drive the reduction directly in Zig; - ;; no interpreted loop/recur, seq walk, or pair allocation per item. - (cljw.internal/__reduce-kv-vector f init m) + ;; A vector or subvec is reduced by index in Zig (no interpreted + ;; loop/recur, seq walk or pair allocation per item). `vector?` also + ;; admits a MapEntry and a deftype / reify declaring IPersistentVector; + ;; the primitive hands those to this generic count/nth loop. + (cljw.internal/__reduce-kv-vector f init m + (fn* [f init m] + (loop* [i 0 acc init] + (if (< i (count m)) + (let* [r (f acc i (nth m i))] + (if (reduced? r) (deref r) (recur (inc i) r))) + acc)))) ;; IKVReduce first (a deftype declaring `kvreduce`, D-400 — clj ;; consults the interface before any fallback); the sentinel keyword ;; is interned, so `identical?` detects "no impl" exactly. diff --git a/src/lang/primitive/reduce_kv_vector.zig b/src/lang/primitive/reduce_kv_vector.zig index 46ad39a02..6d2db5668 100644 --- a/src/lang/primitive/reduce_kv_vector.zig +++ b/src/lang/primitive/reduce_kv_vector.zig @@ -2,6 +2,8 @@ //! Indexed reduce-kv on a persistent vector or subvec. The collection is //! already keyed by its index, so no seq, pair allocation, or interpreted //! loop/recur is needed. All user callbacks still use normal invocation. +//! `vector?` also admits a MapEntry and a deftype / reify declaring +//! IPersistentVector; those go to the caller's generic `fallback` fn. const Value = @import("../../runtime/value/value.zig").Value; const Runtime = @import("../../runtime/runtime.zig").Runtime; const Env = @import("../../runtime/env.zig").Env; @@ -14,9 +16,14 @@ const root_set = @import("../../runtime/gc/root_set.zig"); const invokeCallable = @import("higher_order.zig").invokeCallable; pub fn run(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { - try error_catalog.checkArity("-reduce-kv-vector", args, 3, loc); + // (__reduce-kv-vector f init coll fallback) + try error_catalog.checkArity("__reduce-kv-vector", args, 4, loc); const f = args[0]; const coll = args[2]; + switch (coll.tag()) { + .vector, .sub_vector => {}, + else => return invokeCallable(rt, env, args[3], args[0..3], loc), + } const sub = coll.tag() == .sub_vector; const n = if (sub) sub_vector.count(coll) else vector.count(coll); // GC-ROOT: callback can reenter eval/GC; retain the original collection, diff --git a/test/clj/suites/reduce_helpers_test.clj b/test/clj/suites/reduce_helpers_test.clj index a80f9454a..0de848939 100644 --- a/test/clj/suites/reduce_helpers_test.clj +++ b/test/clj/suites/reduce_helpers_test.clj @@ -28,4 +28,7 @@ (let [calls (atom 0)] (is (= 9 (reduce-kv (fn [acc i x] (swap! calls inc) (reduced (+ acc i x))) 5 [4 10 20]))) (is (= 1 @calls))) - (is (= 3 (reduce-kv (fn [acc _ v] (+ acc v)) 0 {:a 1 :b 2})))) + (is (= 3 (reduce-kv (fn [acc _ v] (+ acc v)) 0 {:a 1 :b 2}))) + ;; A MapEntry is `vector?` but not a vector cell: indexed as [k v]. + (is (= [[0 :a] [1 1]] (reduce-kv (fn [acc i x] (conj acc [i x])) [] (first {:a 1})))) + (is (= :a (reduce-kv (fn [_ _ x] (reduced x)) nil (first {:a 1}))) "mapentry_reduced")) From 4535ad7fd6c37e9cfd2f4925e5367ca1ddcb36d8 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 24 Oct 2026 04:50:04 -0300 Subject: [PATCH 26/51] chore(placement): regenerate for cljw.internal/__reduce-kv-vector Smell-audited: 1: generated index regenerated by scripts/gen_placement.sh, no hand edit --- data/placement.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/placement.yaml b/data/placement.yaml index d3129e5f1..f4168bbe1 100644 --- a/data/placement.yaml +++ b/data/placement.yaml @@ -24,8 +24,8 @@ schema_version: "2.0" summary: namespaces_required: 36 - total: 1468 - zig: 435 + total: 1469 + zig: 436 clj: 807 value: 220 unbound: 6 @@ -121,6 +121,7 @@ vars: "cljw.internal/__queue-pop": { impl: zig, flags: "-" } "cljw.internal/__re-find": { impl: zig, flags: "-" } "cljw.internal/__read-instant-timestamp": { impl: zig, flags: "-" } + "cljw.internal/__reduce-kv-vector": { impl: zig, flags: "-" } "cljw.internal/__reify!": { impl: zig, flags: "-" } "cljw.internal/__remove-method!": { impl: zig, flags: "-" } "cljw.internal/__resolve": { impl: zig, flags: "-" } From 14ac96284e01c3e2b4ccbca107fcfa900ec39fa8 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 29 Oct 2026 11:12:19 -0300 Subject: [PATCH 27/51] perf(seq): memoise StringSeq count; rest/drop inherit it The A13 StringSeq view (16c3c92c) rescanned the remaining UTF-8 on every (count ss), so a count-guarded walk was O(n^2): 242 ms at n=32000 vs clj 1.2 ms. The cell now holds cached_count (u32, sentinel = unknown) in the former pad bytes; still 32 B. countOf scans once and memoises; rest/drop/withMeta hand the successor count-k. Code-point semantics unchanged (CLJW-UNICODE still open). Bench .dev/bench/string_seq.clj (ReleaseSafe, n=2000/8000/32000): count-guarded walk 1.15/13.59/242.04 ms -> 0.32/1.27/5.25 (QUADRATIC -> LINEAR) same, 2-byte utf8 1.97/27.79/466.58 -> 0.33/1.33/5.45 (count ss) x2000 2.80/10.06/49.49 -> 0.41/0.37/0.35 (O(n) -> O(1)) Refs: CLJW-STRING-SEQ --- .dev/bench/string_seq.clj | 64 +++++++++++++++++++ src/runtime/collection/string_seq.zig | 47 ++++++++++++-- .../collection/string_seq_count_test.zig | 55 ++++++++++++++++ 3 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 .dev/bench/string_seq.clj create mode 100644 src/runtime/collection/string_seq_count_test.zig diff --git a/.dev/bench/string_seq.clj b/.dev/bench/string_seq.clj new file mode 100644 index 000000000..7170a35c1 --- /dev/null +++ b/.dev/bench/string_seq.clj @@ -0,0 +1,64 @@ +;; String-seq workloads ([CLJW-STRING-SEQ], A13 `.string_seq`). +;; +;; Two questions, same method as traversal.clj / scaling.clj: +;; - FIXED-OPS probes (2000 ops after warmup) against strings of growing size: +;; flat time across sizes = O(1) per op; growing with size = O(n) per op. +;; - WHOLE-WALK probes: total time 4x per 4x size = linear, 16x = quadratic. +;; +;; Run: cljw .dev/bench/string_seq.clj clj -M .dev/bench/string_seq.clj + +(def SIZES [2000 8000 32000]) +(def OPS 2000) + +(defn- ms [f] + (let [t0 (System/nanoTime)] (f) (/ (- (System/nanoTime) t0) 1e6))) + +(defn- best-of [k f] (apply min (repeatedly k #(ms f)))) + +(defn- verdict [times] + (let [ratios (vec (map (fn [a b] (if (zero? a) 0.0 (/ b a))) (butlast times) (rest times))) + worst (if (seq ratios) (apply max ratios) 0.0)] + [ratios worst])) + +(defn- report [label times kind] + (let [[ratios worst] (verdict times) + v (case kind + :per-op (cond (< worst 2.0) "O(1) per op" (< worst 6.0) "O(n) per op" :else "WORSE") + :walk (cond (< worst 6.0) "LINEAR" (< worst 11.0) "superlinear" :else "QUADRATIC <-- BAD"))] + (println (format "%-34s t=%-28s ratio=%-14s %s" label + (vec (map #(format "%.2f" %) times)) + (vec (map #(format "%.1f" %) ratios)) v)))) + +(defn- S [n] (apply str (repeat n "x"))) +(defn- U [n] (apply str (repeat n "\u00e9"))) ; 2-byte codepoints + +(defn- per-op [label build run] + (report label + (vec (for [n SIZES] + (let [c (build n)] + (dotimes [_ 300] (run c)) + (best-of 5 #(dotimes [_ OPS] (run c)))))) + :per-op)) + +(defn- walk [label build run] + (report label + (vec (for [n SIZES] + (let [c (build n)] (run c) (best-of 3 #(run c))))) + :walk)) + +(println "=== string seq: fixed" OPS "ops per size, sizes" SIZES "===") +(per-op "(seq s) truthiness" S (fn [s] (seq s))) +(per-op "(first (seq s))" S (fn [s] (first (seq s)))) +(per-op "(rest (seq s))" S (fn [s] (rest (seq s)))) +(per-op "(count ss) same seq" (comp seq S) (fn [ss] (count ss))) +(per-op "(count ss) same seq, utf8" (comp seq U) (fn [ss] (count ss))) +(per-op "(count (rest ss))" (comp seq S) (fn [ss] (count (rest ss)))) + +(println "\n=== whole walks ===") +(walk "loop next" S #(loop [s (seq %) n 0] (if s (recur (next s) (inc n)) n))) +(walk "loop next, count-guarded" S #(loop [s (seq %) n 0] (if (> (count s) 1) (recur (next s) (inc n)) n))) +(walk "count-guarded, utf8" U #(loop [s (seq %) n 0] (if (> (count s) 1) (recur (next s) (inc n)) n))) +(walk "reduce over (seq s)" S #(reduce (fn [a _] (inc a)) 0 (seq %))) +(walk "into [] (seq s)" S #(into [] (seq %))) +(walk "apply str (seq s)" S #(apply str (seq %))) +(walk "frequencies" S #(frequencies %)) diff --git a/src/runtime/collection/string_seq.zig b/src/runtime/collection/string_seq.zig index e1b6b87f6..bb39254bc 100644 --- a/src/runtime/collection/string_seq.zig +++ b/src/runtime/collection/string_seq.zig @@ -71,10 +71,15 @@ const string = @import("string.zig"); /// StringSeq (A13) — `backing` viewed from `offset` to its end. pub const StringSeq = extern struct { header: HeapHeader, - _pad: [2]u8 = .{ 0, 0 }, /// Read cursor in BYTES. Invariants: on a codepoint boundary, and /// `offset < backingBytes(backing).len`. offset: u32 = 0, + /// Codepoints remaining from `offset`, or `COUNT_UNKNOWN` until first + /// asked. A memo, not state: the backing is immutable, so once known it + /// is exact forever, and `rest`/`drop` hand the successor `count - k` so + /// a walk never rescans. Occupies the bytes the old `_pad` + alignment + /// gap held, so the cell is still 32 bytes. [refs: CLJW-STRING-SEQ] + cached_count: u32 = COUNT_UNKNOWN, /// The viewed string — a `.string`. backing: Value = Value.nil_val, /// Optional metadata map. A seq is IObj on the JVM (`ASeq` implements it), @@ -88,6 +93,10 @@ pub const StringSeq = extern struct { } }; +/// `cached_count` sentinel: not yet scanned. A view can never hold this many +/// codepoints — `offset` is a u32, so the backing is < 4 GiB of bytes. +pub const COUNT_UNKNOWN: u32 = std.math.maxInt(u32); + /// One decoded codepoint and the byte width it occupied. pub const Step = struct { cp: u21, len: u32 }; @@ -133,11 +142,18 @@ pub fn viewable(backing: Value) bool { /// View `backing` from byte `offset`. nil when the view would be empty — a /// StringSeq is never empty (see the header invariant). pub fn make(rt: *Runtime, backing: Value, offset: u32) !Value { + return makeCounted(rt, backing, offset, COUNT_UNKNOWN); +} + +/// `make` with the remaining codepoint count already known (or +/// `COUNT_UNKNOWN`) — how `rest`/`drop` carry the memo forward. +fn makeCounted(rt: *Runtime, backing: Value, offset: u32, count: u32) !Value { if (offset >= backingBytes(backing).len) return Value.nil_val; const ss = try rt.gc.alloc(StringSeq); ss.* = .{ .header = HeapHeader.init(.string_seq), .offset = offset, + .cached_count = count, .backing = backing, }; return Value.encodeHeapPtr(.string_seq, ss); @@ -165,11 +181,20 @@ pub fn first(v: Value) Value { return Value.initChar(@intCast(decodeAt(bytes, ss.offset).cp)); } -/// `(count ss)` — codepoints remaining. O(remaining bytes); see the header on -/// why this one cannot be a subtraction. ASCII (the overwhelming case for the -/// strings that made this matter) costs one length read. +/// `(count ss)` — codepoints remaining. The first ask on a chain scans the +/// remaining bytes (see the header on why it cannot be a subtraction); the +/// answer is memoised in the cell and inherited by `rest`/`drop`, so every +/// later ask on this view or any successor is O(1). Without the memo a +/// count-guarded walk (`(> (count s) 1)` per step) is O(n^2). pub fn countOf(v: Value) u32 { - const rest_bytes = remainingBytes(v); + const ss = v.decodePtr(*StringSeq); + if (ss.cached_count != COUNT_UNKNOWN) return ss.cached_count; + const n = scanCount(remainingBytes(v)); + ss.cached_count = n; + return n; +} + +fn scanCount(rest_bytes: []const u8) u32 { var n: u32 = 0; var i: usize = 0; while (i < rest_bytes.len) { @@ -192,7 +217,9 @@ pub fn rest(rt: *Runtime, v: Value) !Value { const ss = v.decodePtr(*const StringSeq); const bytes = backingBytes(ss.backing); const step = decodeAt(bytes, ss.offset); - return try make(rt, ss.backing, ss.offset + step.len); + // Carry the count memo: one codepoint fewer, if it was known. + const c = if (ss.cached_count == COUNT_UNKNOWN) COUNT_UNKNOWN else ss.cached_count - 1; + return try makeCounted(rt, ss.backing, ss.offset + step.len, c); } /// Byte offset `n` codepoints past `from`, clamped to the end of `bytes`. @@ -222,7 +249,8 @@ pub fn nth(v: Value, i: u32) Value { pub fn drop(rt: *Runtime, v: Value, n: u32) !Value { const ss = v.decodePtr(*const StringSeq); const bytes = backingBytes(ss.backing); - return try make(rt, ss.backing, advance(bytes, ss.offset, n)); + const c = if (ss.cached_count == COUNT_UNKNOWN) COUNT_UNKNOWN else ss.cached_count -| n; + return try makeCounted(rt, ss.backing, advance(bytes, ss.offset, n), c); } pub fn metaOf(v: Value) Value { @@ -239,6 +267,7 @@ pub fn withMeta(rt: *Runtime, v: Value, m: Value) !Value { ss.* = .{ .header = HeapHeader.init(.string_seq), .offset = src.offset, + .cached_count = src.cached_count, .backing = src.backing, .meta = m, }; @@ -262,6 +291,10 @@ pub fn registerGcHooks() void { const testing = std.testing; +test { + _ = @import("string_seq_count_test.zig"); +} + const RuntimeFixture = struct { threaded: std.Io.Threaded, rt: Runtime, diff --git a/src/runtime/collection/string_seq_count_test.zig b/src/runtime/collection/string_seq_count_test.zig new file mode 100644 index 000000000..ec69c2763 --- /dev/null +++ b/src/runtime/collection/string_seq_count_test.zig @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EPL-2.0 +//! StringSeq count memo ([CLJW-STRING-SEQ]): the first `countOf` on a chain +//! scans; `rest` / `drop` / `withMeta` carry the answer forward so a +//! count-guarded walk stays linear. Split out of `string_seq.zig`, which is +//! already past the file-size ceiling. + +const std = @import("std"); +const testing = std.testing; +const Value = @import("../value/value.zig").Value; +const Runtime = @import("../runtime.zig").Runtime; +const string = @import("string.zig"); +const ss = @import("string_seq.zig"); + +fn cellCount(v: Value) u32 { + return v.decodePtr(*const ss.StringSeq).cached_count; +} + +test "countOf memoises and rest/drop/withMeta inherit the memo" { + var threaded = std.Io.Threaded.init(testing.allocator, .{}); + defer threaded.deinit(); + var rt = Runtime.init(threaded.io(), testing.allocator); + defer rt.deinit(); + + const s = try string.alloc(&rt, "aé→𝄞xyz"); // 7 codepoints, mixed widths + const v = try ss.make(&rt, s, 0); + try testing.expectEqual(ss.COUNT_UNKNOWN, cellCount(v)); + // No count taken: successors stay unknown, not a wrong guess. + try testing.expectEqual(ss.COUNT_UNKNOWN, cellCount(try ss.rest(&rt, v))); + + try testing.expectEqual(@as(u32, 7), ss.countOf(v)); + try testing.expectEqual(@as(u32, 7), cellCount(v)); + + var cur = v; + var want: u32 = 7; + while (!cur.isNil()) : (want -= 1) { + // Inherited, then confirmed against a fresh scan of the same cursor. + try testing.expectEqual(want, cellCount(cur)); + const fresh = try ss.make(&rt, s, ss.offsetOf(cur)); + try testing.expectEqual(want, ss.countOf(fresh)); + cur = try ss.rest(&rt, cur); + } + try testing.expectEqual(@as(u32, 0), want); + + const d = try ss.drop(&rt, v, 3); + try testing.expectEqual(@as(u32, 4), cellCount(d)); + try testing.expect((try ss.drop(&rt, v, 99)).isNil()); + + const m = try ss.withMeta(&rt, v, Value.initInteger(1)); + try testing.expectEqual(@as(u32, 7), cellCount(m)); +} + +test "StringSeq stays a 32-byte cell with the memo field" { + try testing.expectEqual(@as(usize, 32), @sizeOf(ss.StringSeq)); + try testing.expectEqual(@as(usize, 12), @offsetOf(ss.StringSeq, "cached_count")); +} From 933aaf03d4ee2d2b54f0941ef210812cea0ee5e8 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 29 Oct 2026 11:38:16 -0300 Subject: [PATCH 28/51] perf(wasm): cache component export FuncSig per handle; stack-lower call args wasm/component-call resolved the export's WitType signature (new arena, by-name export walk, full type-tree rebuild) on every call. Cache it per ComponentLoaded box (component_sig_cache.zig), the component analogue of the core Loaded.sig_cache. Lowered args use a stack-fallback scratch. Interleaved A/B (crossing.cljw, 6 rounds, load avg ~20): component cell median 1111 -> 977 ns (-12%); component/core-call 1.61x -> 1.45x (ratio of medians). Remaining per-call cost is inside zwasm invokeTypedCore (re-resolves func type + lifted func by name, re-flattens canon types, own arena each call): needs a zwasm-side prepared-call API. Card CLJW-COMPONENT-CALL-CACHE 20261029110722-52ab6c88. --- src/runtime/cljw/wasm/component.zig | 33 ++++++---- src/runtime/cljw/wasm/component_sig_cache.zig | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/runtime/cljw/wasm/component_sig_cache.zig diff --git a/src/runtime/cljw/wasm/component.zig b/src/runtime/cljw/wasm/component.zig index 1830e34b8..f7a62b837 100644 --- a/src/runtime/cljw/wasm/component.zig +++ b/src/runtime/cljw/wasm/component.zig @@ -43,6 +43,7 @@ const ctypes = zwasm.feature.component.types; const ComponentValue = comp.ComponentValue; const WitType = comp.WitType; const WasiHost = zwasm.wasi.host.Host; +const SigCache = @import("component_sig_cache.zig").SigCache; /// Read + FS-jail-resolve a component path argument. fn readComponentBytes(rt: *Runtime, path_val: Value, loc: SourceLocation) anyerror![]const u8 { @@ -646,12 +647,10 @@ fn witTypeHoldsResource(ty: WitType) bool { /// resolveFuncSig → `invokeWithSig`, on an already-open `Opened`. The /// `component-call` entry point; the one-shot resolves the signature itself /// because it decides the component's lifetime from it. -fn invokeOnOpened(rt: *Runtime, opened: *comp.Opened, fname: []const u8, call_args: []const Value, loc: SourceLocation, component_handle: Value) anyerror!Value { - var arena = std.heap.ArenaAllocator.init(rt.gpa); - defer arena.deinit(); - const sig = (opened.resolveFuncSig(arena.allocator(), fname) catch null) orelse +fn invokeOnOpened(rt: *Runtime, box: *ComponentLoaded, fname: []const u8, call_args: []const Value, loc: SourceLocation, component_handle: Value) anyerror!Value { + const sig = (try box.funcSig(fname)) orelse return error_catalog.raise(.wasm_export_not_found, loc, .{ .name = fname }); - return invokeWithSig(rt, opened, sig, fname, call_args, loc, component_handle); + return invokeWithSig(rt, &box.opened, sig, fname, call_args, loc, component_handle); } /// lower args → invokeTyped → lift result, against an already-resolved signature. @@ -660,13 +659,17 @@ fn invokeOnOpened(rt: *Runtime, opened: *comp.Opened, fname: []const u8, call_ar /// already belong to it. `nil` means the component is anonymous (a one-shot whose /// result holds no resource) — then no resource argument can be valid either. fn invokeWithSig(rt: *Runtime, opened: *comp.Opened, sig: anytype, fname: []const u8, call_args: []const Value, loc: SourceLocation, component_handle: Value) anyerror!Value { - var arena = std.heap.ArenaAllocator.init(rt.gpa); - defer arena.deinit(); if (call_args.len != sig.params.len) return error_catalog.raise(.wasm_arity_mismatch, loc, .{ .name = fname, .expected = sig.params.len, .actual = call_args.len }); + var arena = std.heap.ArenaAllocator.init(rt.gpa); + defer arena.deinit(); + // Scalar-sized calls lower entirely on the stack; only a large payload + // (long string / list) reaches the arena's heap. + var sfa = std.heap.stackFallback(512, arena.allocator()); + const scratch = sfa.get(); - const in = try arena.allocator().alloc(ComponentValue, call_args.len); - for (call_args, sig.params, 0..) |a, p, i| in[i] = try lower(rt, arena.allocator(), a, p.ty, loc, component_handle); + const in = try scratch.alloc(ComponentValue, call_args.len); + for (call_args, sig.params, 0..) |a, p, i| in[i] = try lower(rt, scratch, a, p.ty, loc, component_handle); const out = opened.invokeTyped(fname, in, rt.gpa) catch |e| switch (e) { error.OutOfFuel => return error_catalog.raise(.wasm_component_fuel_exhausted, loc, .{}), @@ -702,6 +705,13 @@ const ComponentLoaded = struct { engine: *zwasm.Engine, host: *WasiHost, opened: comp.Opened, + /// Export name -> resolved `FuncSig`, per handle (CLJW-COMPONENT-CALL-CACHE). + /// Borrows names from `opened`'s TypeInfo, so it is torn down first. + sigs: SigCache, + + fn funcSig(self: *ComponentLoaded, name: []const u8) std.mem.Allocator.Error!?comp.FuncSig { + return self.sigs.get(&self.opened, name); + } }; /// `.host_instance` finaliser for a component handle: state[0] is the @@ -729,7 +739,7 @@ fn openComponentBoxed(rt: *Runtime, bytes: []const u8, opts: comp.InstantiateOpt errdefer opened.deinit(); const box = try rt.gpa.create(ComponentLoaded); - box.* = .{ .engine = engine, .host = host, .opened = opened }; + box.* = .{ .engine = engine, .host = host, .opened = opened, .sigs = SigCache.init(rt.gpa) }; return box; } @@ -746,6 +756,7 @@ fn freeComponentBox(rt: *Runtime, box: *ComponentLoaded) void { fn componentFinalise(infra: std.mem.Allocator, state: *[host_instance.STATE_WORDS]u64) void { const box: *ComponentLoaded = @ptrFromInt(state[0]); + box.sigs.deinit(); box.opened.deinit(); box.host.deinit(); box.engine.deinit(); @@ -822,7 +833,7 @@ pub fn componentCallFn(rt: *Runtime, env: *Env, args: []const Value, loc: Source const box: *ComponentLoaded = @ptrFromInt(host_instance.asHostInstance(comp_handle).state[0]); // Pass the component handle so an `own` result wraps into a GC-resource that // roots THIS component (ADR-0159). - return invokeOnOpened(rt, &box.opened, string_mod.asString(args[1]), args[2..], loc, comp_handle); + return invokeOnOpened(rt, box, string_mod.asString(args[1]), args[2..], loc, comp_handle); } const testing = std.testing; diff --git a/src/runtime/cljw/wasm/component_sig_cache.zig b/src/runtime/cljw/wasm/component_sig_cache.zig new file mode 100644 index 000000000..8bc935fb4 --- /dev/null +++ b/src/runtime/cljw/wasm/component_sig_cache.zig @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: EPL-2.0 +//! Per-handle export-signature cache for `wasm/component-call` +//! (CLJW-COMPONENT-CALL-CACHE). The component analogue of the core +//! `Loaded.sig_cache` (engine.zig). +//! +//! Without it every `component-call` built a fresh arena, walked the export +//! table by name (`exportedFuncIndex`, a `mem.eql` scan) and re-resolved the +//! whole `WitType` tree (`resolveFuncSig`) before lowering a single argument. +//! A resolved `FuncSig` depends only on the component's immutable `TypeInfo`, +//! so it is resolved once per (handle, export name) and kept for the handle's +//! lifetime. Its trees live in one arena owned by the cache; label and field +//! names borrow from the `Opened`'s `TypeInfo`, which outlives the cache +//! (the box tears the cache down before the `Opened`). +//! +//! Backend: impl-only +//! Impl deps: none + +const std = @import("std"); +const zwasm = @import("zwasm"); + +const comp = zwasm.feature.component.host; +pub const FuncSig = comp.FuncSig; + +pub const SigCache = struct { + /// Owns every cached `FuncSig` tree and every key. + arena: std.heap.ArenaAllocator, + /// Export name -> resolved signature. A name that does not resolve is + /// not cached, so a missing export keeps raising on every call. + map: std.StringHashMapUnmanaged(FuncSig) = .empty, + + pub fn init(gpa: std.mem.Allocator) SigCache { + return .{ .arena = std.heap.ArenaAllocator.init(gpa) }; + } + + pub fn deinit(self: *SigCache) void { + // Keys and map storage both live in the arena. + self.arena.deinit(); + } + + /// The signature of export `name` on `opened`, resolving it on first use. + /// `null` when the name does not resolve to a concrete func (or its + /// signature holds an unsupported type) — the caller raises not-found, + /// exactly as the uncached path did. + pub fn get(self: *SigCache, opened: *const comp.Opened, name: []const u8) std.mem.Allocator.Error!?FuncSig { + if (self.map.get(name)) |sig| return sig; + const a = self.arena.allocator(); + // Same `catch null` as the uncached path: a resolve failure reads as + // export-not-found. + const sig = (opened.resolveFuncSig(a, name) catch null) orelse return null; + // The caller's `name` is a GC string: the key must be an owned copy. + const key = try a.dupe(u8, name); + try self.map.put(a, key, sig); + return sig; + } + + pub fn count(self: *const SigCache) usize { + return self.map.count(); + } +}; + +test "an empty cache tears down cleanly" { + var c = SigCache.init(std.testing.allocator); + defer c.deinit(); + try std.testing.expectEqual(@as(usize, 0), c.count()); +} From 44c4535fd2e5c2f0353bf8af31cda50ece818fdf Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Thu, 29 Oct 2026 13:58:44 -0300 Subject: [PATCH 29/51] fix(wasm): add Clojure peer marker to component_sig_cache.zig --- src/runtime/cljw/wasm/component_sig_cache.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/cljw/wasm/component_sig_cache.zig b/src/runtime/cljw/wasm/component_sig_cache.zig index 8bc935fb4..ce99eb5b1 100644 --- a/src/runtime/cljw/wasm/component_sig_cache.zig +++ b/src/runtime/cljw/wasm/component_sig_cache.zig @@ -14,6 +14,7 @@ //! //! Backend: impl-only //! Impl deps: none +//! Clojure peer: wasm/component-call const std = @import("std"); const zwasm = @import("zwasm"); From a5806e67012341d7e7654682f71ed2f31e3bf7d2 Mon Sep 17 00:00:00 2001 From: clojurewasm-release Date: Thu, 29 Oct 2026 19:13:13 +0000 Subject: [PATCH 30/51] chore(release): v1.15.5 --- CHANGELOG.md | 2 ++ build.zig.zon | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a38b9c8d..bfce65dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +## [1.15.5] - 2026-10-29 + ### Changed - Transient maps and sets now edit owned HAMT paths in place during `into`, diff --git a/build.zig.zon b/build.zig.zon index edd6d311e..97890e403 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cljw, - .version = "1.15.4", + .version = "1.15.5", .fingerprint = 0x1869d207073beffa, .minimum_zig_version = "0.16.0", .dependencies = .{ From c7d7f1a9e248dbb8412bf8c1a3a45c73362b33fa Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Wed, 4 Nov 2026 17:00:44 -0300 Subject: [PATCH 31/51] test(reduce): pin reduce-kv indexed-path edges against JVM Clojure nil coll, metadata, empty and nested subvec, reduced keys from zero on a subvec, one-layer unwrap of a doubly reduced result, reduced init passed through, nil f, transient rejection, a wide trie, and an allocating callback across GC. Each case was checked on Clojure 1.12. --- test/clj/suites/reduce_helpers_test.clj | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/clj/suites/reduce_helpers_test.clj b/test/clj/suites/reduce_helpers_test.clj index 0de848939..8fad541b8 100644 --- a/test/clj/suites/reduce_helpers_test.clj +++ b/test/clj/suites/reduce_helpers_test.clj @@ -32,3 +32,28 @@ ;; A MapEntry is `vector?` but not a vector cell: indexed as [k v]. (is (= [[0 :a] [1 1]] (reduce-kv (fn [acc i x] (conj acc [i x])) [] (first {:a 1})))) (is (= :a (reduce-kv (fn [_ _ x] (reduced x)) nil (first {:a 1}))) "mapentry_reduced")) + +;; Edges of the Zig indexed path, each checked against JVM Clojure 1.12. +(deftest reduce-kv-indexed-edges + (is (= [] (reduce-kv (fn [acc k _] (conj acc k)) [] nil)) "nil_coll") + (is (= 6 (reduce-kv (fn [acc _ x] (+ acc x)) 0 (with-meta [1 2 3] {:m 1}))) "meta_vector") + (is (= :init (reduce-kv (fn [_ _ _] :no) :init (subvec [1 2] 1 1))) "empty_subvec") + (is (= [[0 2] [1 3]] (reduce-kv (fn [acc i x] (conj acc [i x])) [] + (subvec (subvec [0 1 2 3 4] 1 4) 1 3))) "subvec_of_subvec") + (let [calls (atom 0)] + (is (= [1 1 2] (reduce-kv (fn [acc i x] (swap! calls inc) + (if (= i 1) (reduced [acc i x]) (+ acc x))) + 0 (subvec [50 1 2 3 100] 1 4))) "subvec_reduced_keys_from_zero") + (is (= 2 @calls) "subvec_reduced_stops")) + ;; One layer of reduced is stripped, as clj's kv-reduce does: a doubly + ;; reduced result comes back still reduced. + (let [r (reduce-kv (fn [_ _ x] (reduced (reduced x))) 0 [1 2])] + (is (reduced? r) "nested_reduced_one_layer") + (is (= 1 @r) "nested_reduced_inner")) + (is (reduced? (reduce-kv (fn [acc _ _] acc) (reduced 7) [])) "reduced_init_untouched") + (is (= 0 (reduce-kv nil 0 [])) "nil_f_empty_never_called") + (is (thrown? Exception (reduce-kv nil 0 [1])) "nil_f_called") + (is (thrown? Exception (reduce-kv (fn [acc _ _] acc) 0 (transient [1 2]))) "transient_rejected") + (is (= 4999950000 (reduce-kv (fn [acc i _] (+ acc i)) 0 (vec (range 100000)))) "wide_trie") + (is (= 20000 (count (reduce-kv (fn [acc i x] (conj acc (str i x))) [] (vec (range 20000))))) + "allocating_callback_survives_gc")) From 48073b35987136c0494a1ff4a8ce1f746f3d9709 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Wed, 4 Nov 2026 17:03:31 -0300 Subject: [PATCH 32/51] fix(hierarchy): derive raises clj's AssertionError, and class ancestors reach Object JVM oracle (clj_diff_sweep): a bad derive tag or parent is an AssertionError carrying clj's assert text ("Assert failed: (namespace parent)" and the other four), a redundant or cyclic edge is a plain Exception, and (ancestors C) for any class but Object includes Object. cljw raised ex-info with its own wording for all of these and left Object out of every class's ancestors, so clojure.core-test.ancestors failed 4. derive now uses clj's own asserts in clj's order; ancestors adds Object when the class isa Object. Corpus hierarchy_derive.txt (23) pins both. --- CHANGELOG.md | 7 ++++ src/lang/clj/clojure/core.clj | 50 ++++++++++------------- test/clj/suites/hierarchy_test.clj | 37 +++++++++++++++++ test/diff/clj_corpus/hierarchy_derive.txt | 46 +++++++++++++++++++++ 4 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 test/diff/clj_corpus/hierarchy_derive.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a38b9c8d..c55f7010f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ### Fixed +- `derive` rejects a bad tag or parent with clj's own `AssertionError` + (`Assert failed: (namespace parent)` and the rest, in clj's order), and a + redundant or cyclic edge with a plain `Exception`, where it raised + `ex-info` with cljw-only wording. +- `(ancestors C)` for a class includes `Object`, in the global hierarchy and + in a passed-in one, as clj's superclass walk does; `(ancestors Object)` and + an interface stay without it. - The non-blocking bench-vs-bb CI job no longer fails on a restored `.zig-cache` whose WriteFiles output is missing (`file_hash FileNotFound` on `bootstrap_sources_stub.zig`). The job only reads the cache, so on a diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj index 717f3f093..5e6ec31cd 100644 --- a/src/lang/clj/clojure/core.clj +++ b/src/lang/clj/clojure/core.clj @@ -2347,42 +2347,29 @@ ;; un-namespaced parent there would let one library's :shape collide ;; with another's; a hierarchy VALUE you passed in is your own. ;; - ;; The global overload calls namespace on parent before derive; - ;; Class parents therefore throw, unlike Class *children* of the - ;; three-argument overload. Both global tags must be namespaced. - (when-not (or (symbol? parent) (keyword? parent)) - (throw (ClassCastException. - (str "derive: parent must be a named symbol or keyword, got " (pr-str parent))))) - (when-not (namespace parent) - (throw (ex-info (str "derive: parent must be namespaced, got " (pr-str parent)) {}))) - (when-not (or (class? tag) - (and (or (symbol? tag) (keyword? tag)) (namespace tag))) - (throw (ex-info (str "derive: tag must be a class or a namespaced symbol/keyword, got " - (pr-str tag)) - {}))) + ;; The checks are clj's own asserts, in clj's order, so a bad argument + ;; is an AssertionError with clj's exact text. `namespace` on a Class + ;; parent throws ClassCastException before the assert, as in clj. + (assert (namespace parent)) + (assert (or (class? tag) (and (instance? clojure.lang.Named tag) (namespace tag)))) (swap! -global-hierarchy derive tag parent) nil) ([h tag parent] - ;; The 3-arity permits a Class tag but requires a Named parent. - ;; It does not require the parent to be namespaced. - (when-not (or (class? tag) (symbol? tag) (keyword? tag)) - (throw (ex-info (str "derive: tag must be a class, symbol or keyword, got " (pr-str tag)) {}))) - (when-not (or (symbol? parent) (keyword? parent)) - (throw (ex-info (str "derive: parent must be a symbol or keyword, got " (pr-str parent)) {}))) - ;; clj: self-derive → AssertionError; a non-map hierarchy field → - ;; NullPointerException (it invokes the field as a fn). Both throw here - ;; (exception-Kind divergence is AD-007). The 2-arity global path always - ;; passes a valid hierarchy, so those guards never fire on it. - (when (= tag parent) - (throw (ex-info (str "(not= tag parent) — cannot derive " tag " from itself") {}))) + ;; The 3-arity permits a Class tag and an un-namespaced parent, but the + ;; parent must be Named. + (assert (not= tag parent)) + (assert (or (class? tag) (instance? clojure.lang.Named tag))) + (assert (instance? clojure.lang.Named parent)) + ;; clj invokes the hierarchy's fields as fns, so a non-map field is an + ;; NPE there; it throws here too (exception-Kind divergence is AD-007). (when-not (and (map? (:parents h)) (map? (:descendants h)) (map? (:ancestors h))) (throw (ex-info "derive: hierarchy must have map :parents / :descendants / :ancestors" {}))) (let [tp (:parents h) td (:descendants h) ta (:ancestors h)] (if (contains? (get tp tag) parent) h (if (contains? (get ta tag) parent) - (throw (ex-info (str tag " already has " parent " as ancestor") {})) + (throw (Exception. (str tag " already has " parent " as ancestor"))) (if (contains? (get ta parent) tag) - (throw (ex-info (str "Cyclic derivation: " parent " has " tag " as ancestor") {})) + (throw (Exception. (str "Cyclic derivation: " parent " has " tag " as ancestor"))) {:parents (assoc tp tag (conj (get tp tag #{}) parent)) :ancestors (-derive-tf ta tag td parent ta) :descendants (-derive-tf td parent ta tag td)}))))))) @@ -2411,8 +2398,13 @@ (def ancestors (fn* ([tag] (ancestors (deref -global-hierarchy) tag)) ([h tag] (let [explicit (get (:ancestors h) tag) - native (cljw.internal/__class-parents tag)] - (not-empty (reduce conj (or explicit #{}) native)))))) + native (cljw.internal/__class-parents tag) + ;; clj walks the superclass chain, which ends at Object + ;; for every class but Object itself and interfaces. + object (when (and (class? tag) (not= tag Object) + (cljw.internal/__class-isa? tag Object)) + [Object])] + (not-empty (reduce conj (reduce conj (or explicit #{}) native) object)))))) (def descendants (fn* ([tag] (descendants (deref -global-hierarchy) tag)) diff --git a/test/clj/suites/hierarchy_test.clj b/test/clj/suites/hierarchy_test.clj index e12a8210b..368b7c6a0 100644 --- a/test/clj/suites/hierarchy_test.clj +++ b/test/clj/suites/hierarchy_test.clj @@ -146,3 +146,40 @@ (deftest underive-with-non-hierarchy-map-throws (is (thrown? Throwable (underive {} ::g4-a ::g4-b)))) + +;; derive checks its arguments with clj's own asserts, so a bad tag or parent +;; is an AssertionError carrying clj's exact message, never ex-info. +(defn- assert-msg [f] + (try (f) nil (catch AssertionError e (ex-message e)))) + +(deftest derive-validation-is-an-assertion-error + (is (= "Assert failed: (namespace parent)" + (assert-msg #(derive ::g5-a :g5-b)))) + (is (= "Assert failed: (or (class? tag) (and (instance? clojure.lang.Named tag) (namespace tag)))" + (assert-msg #(derive :g5-a ::g5-b)))) + (is (= "Assert failed: (not= tag parent)" + (assert-msg #(derive (make-hierarchy) :g5-a :g5-a)))) + (is (= "Assert failed: (or (class? tag) (instance? clojure.lang.Named tag))" + (assert-msg #(derive (make-hierarchy) "s" :g5-a)))) + (is (= "Assert failed: (instance? clojure.lang.Named parent)" + (assert-msg #(derive (make-hierarchy) ::g5-a String)))) + (is (thrown? ClassCastException (derive ::g5-a String)))) + +;; A cyclic edge is a plain Exception in clj, not ex-info. +(deftest derive-cycle-is-a-plain-exception + (let [h (derive (make-hierarchy) ::g6-a ::g6-b) + e (try (derive h ::g6-b ::g6-a) nil (catch Exception e e))] + (is (some? e)) + (is (not (instance? clojure.lang.ExceptionInfo e))) + (is (= "Cyclic derivation: :suites.hierarchy-test/g6-a has :suites.hierarchy-test/g6-b as ancestor" + (ex-message e))))) + +;; Every class but Object has Object among its ancestors, in the global +;; hierarchy and in an ad-hoc one. +(deftest class-ancestors-include-object + (is (contains? (ancestors clojure.lang.PersistentHashSet) Object)) + (is (contains? (ancestors Long) Object)) + (is (contains? (ancestors (derive (make-hierarchy) clojure.lang.PersistentVector ::g7-o) + clojure.lang.PersistentVector) + Object)) + (is (nil? (ancestors Object)))) diff --git a/test/diff/clj_corpus/hierarchy_derive.txt b/test/diff/clj_corpus/hierarchy_derive.txt new file mode 100644 index 000000000..95c1392c4 --- /dev/null +++ b/test/diff/clj_corpus/hierarchy_derive.txt @@ -0,0 +1,46 @@ +(try (derive (make-hierarchy) :user/a String) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (instance? clojure.lang.Named parent)" +(try (derive (make-hierarchy) :user/a 1) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (instance? clojure.lang.Named parent)" +(try (derive (make-hierarchy) 1 :user/a) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (or (class? tag) (instance? clojure.lang.Named tag))" +(try (derive (make-hierarchy) :a :a) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (not= tag parent)" +(try (derive (make-hierarchy) "s" :a) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (or (class? tag) (instance? clojure.lang.Named tag))" +(try (derive :user/a :b) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (namespace parent)" +(try (derive :a :user/b) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (or (class? tag) (and (instance? clojure.lang.Named tag) (namespace tag)))" +(try (derive 1 :user/b) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (or (class? tag) (and (instance? clojure.lang.Named tag) (namespace tag)))" +(try (derive :user/q :user/q) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (not= tag parent)" +(try (derive 'user/a :b) (catch AssertionError e (ex-message e))) +;;=> "Assert failed: (namespace parent)" +(try (derive :user/a String) (catch ClassCastException e :cce)) +;;=> :cce +(try (derive (derive (make-hierarchy) :user/g6-a :user/g6-b) :user/g6-b :user/g6-a) (catch Exception e [(instance? clojure.lang.ExceptionInfo e) (ex-message e)])) +;;=> [false "Cyclic derivation: :user/g6-a has :user/g6-b as ancestor"] +(try (-> (make-hierarchy) (derive :a :b) (derive :b :c) (derive :a :c)) (catch Exception e [(instance? clojure.lang.ExceptionInfo e) (ex-message e)])) +;;=> [false ":a already has :c as ancestor"] +(contains? (ancestors clojure.lang.PersistentHashSet) Object) +;;=> true +(contains? (ancestors clojure.lang.PersistentVector) Object) +;;=> true +(contains? (ancestors Long) Object) +;;=> true +(contains? (ancestors java.util.Date) Object) +;;=> true +(contains? (ancestors (derive (make-hierarchy) clojure.lang.PersistentHashSet :user/o) clojure.lang.PersistentHashSet) Object) +;;=> true +(contains? (ancestors (make-hierarchy) Long) Object) +;;=> true +(contains? (ancestors Exception) Object) +;;=> true +(ancestors Object) +;;=> nil +(ancestors Comparable) +;;=> nil +(ancestors :user/none) +;;=> nil From 114272de7798367f317c17c534026433812ce711 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Wed, 4 Nov 2026 17:03:58 -0300 Subject: [PATCH 33/51] docs(divergences): retire AD-068, derive now rejects a Class parent as clj does 5ad783de reversed the class-parent leniency the row accepted, and the JVM oracle now agrees on every class-parent case: ClassCastException in the 2-arity, AssertionError in the 3-arity. The row described behaviour cljw no longer has; clojure.core-test.derive passes 32/0. --- .dev/accepted_divergences.yaml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml index c0b0c3c03..317e9402b 100644 --- a/.dev/accepted_divergences.yaml +++ b/.dev/accepted_divergences.yaml @@ -1081,28 +1081,6 @@ accepted: example: '(let [l (lazy-seq 1)] (try (seq l) (catch Exception _ nil)) (count l)) => cljw throws again / clj 0.' derives_from: "ADR-0143 (failed realization is never cached) + ADR-0197 (one publication state, immutable inner links and retained retry captures)." pin: "test/clj/suites/lazy_seqable_test.clj — non-seqable-bodies-raise-on-every-touch and realization-state-follows-ad067; test/golden/cases/lazy_seqable_bodies.expected — scalar retries, closed thunk retry, inner realized." - - id: "AD-068" - area: "hierarchy" - summary: |- - `derive` accepts a CLASS as the parent, where clj raises - `ClassCastException`. clj's 2-arity asserts `(namespace parent)`, and a - `Class` is not `Named`, so `(derive ::tag String)` throws there. - ADR-0109 makes a host class a first-class hierarchy participant in cljw: - `isa?` already answers on class parents, `defmulti` dispatches on them, - and `(derive ::x Object)` is the way a keyword tag joins that graph. - The edge is REAL rather than implied by `Object` being the universal - supertype — `(isa? ::x Object)` is false until the derive installs it — - so refusing a class parent would make an ADR-0109 hierarchy expressible - only through `make-hierarchy`, which the global `defmulti` path does not - read. Every OTHER bad shape is rejected exactly as clj rejects it: a - non-named parent, an un-namespaced parent in the 2-arity, and a tag that - is neither a class nor a namespaced symbol/keyword. - The 3-arity is deliberately more permissive than the 2-arity on - namespacing, in both runtimes: a hierarchy value you pass in is your own, - while the global one is shared across every namespace. - example: '(derive :user/tag String) => cljw installs the edge / clj throws ClassCastException. The reverse, (derive ::x Object) then (isa? ::x Object), is true in cljw and unreachable in clj.' - derives_from: "ADR-0109 (one host-class-VALUE resolver; a class is a hierarchy participant)." - pin: "test/e2e/phase14_opaque_host_class.sh — the derive_object case; clojure.core-test.derive carries the one residual upstream failure this row explains." - id: AD-069 area: numerics summary: '`(int x)` / `(byte x)` / `(short x)` yield a Long: cljw has no Integer / Byte / Short types, so `(instance? Integer (int 0))` is false where clj is true. The narrowing RANGE is still enforced the way clj enforces it, so `(int 2147483648)` raises in both; only the RESULT TYPE differs.' From 93853a23b399f3fa4b1b2c49c59a43e7b454b9d1 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Wed, 4 Nov 2026 17:04:16 -0300 Subject: [PATCH 34/51] fix(gc): pin glibc M_MMAP_THRESHOLD at process start (WIP) WIP, saved before machine shutdown. Smoke was running when interrupted: zig_build_test_vm and zig_build_test_tree_walk passed, the rest did not finish. Interim bb-mcp measurement under load: flat at 64 MB over 250 calls, base 62 to 78 MB. Card 20261022210251-596075d6. --- CHANGELOG.md | 5 ++++ src/main.zig | 5 ++++ src/runtime/gc/gc_trigger.zig | 12 ++------ src/runtime/gc/glibc_policy.zig | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 src/runtime/gc/glibc_policy.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a38b9c8d..c2a673bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ### Fixed +- On glibc, cljw now pins malloc's mmap threshold at 128 KiB at startup + (unless `GLIBC_TUNABLES` is set), so long-lived servers that churn + 64-256 KiB buffers, such as bb-mcp, no longer creep in RSS as glibc raises + the threshold and fragments its per-thread arenas. Other libcs are untouched. + - The non-blocking bench-vs-bb CI job no longer fails on a restored `.zig-cache` whose WriteFiles output is missing (`file_hash FileNotFound` on `bootstrap_sources_stub.zig`). The job only reads the cache, so on a diff --git a/src/main.zig b/src/main.zig index 89238d4f4..a79b8a276 100644 --- a/src/main.zig +++ b/src/main.zig @@ -25,8 +25,12 @@ const cli = @import("app/cli.zig"); const error_render = @import("app/error_render.zig"); const io_default = @import("runtime/concurrency/io_default.zig"); const runtime_thread = @import("runtime/concurrency/runtime_thread.zig"); +const glibc_policy = @import("runtime/gc/glibc_policy.zig"); pub fn main(init: std.process.Init) !void { + // Pin glibc's mmap threshold before any worker thread or large buffer + // exists, unless the user tunes malloc via GLIBC_TUNABLES (no-op off glibc). + glibc_policy.pinMmapThreshold(init.environ_map.get("GLIBC_TUNABLES") != null); // Upgrade the process-wide `io_default` singleton from its lazy // single-threaded default to the real process io BEFORE any runtime work, // so Phase-B concurrency sync (`std.Io.Mutex`/`Io.Condition` reached by @@ -108,6 +112,7 @@ test { _ = @import("runtime/gc/mark_sweep.zig"); _ = @import("runtime/gc/mark_sweep_test.zig"); _ = @import("runtime/gc/gc_trigger.zig"); + _ = @import("runtime/gc/glibc_policy.zig"); _ = @import("runtime/gc/gc_types.zig"); _ = @import("runtime/gc/conservative_scan.zig"); _ = @import("runtime/gc/page_heap.zig"); diff --git a/src/runtime/gc/gc_trigger.zig b/src/runtime/gc/gc_trigger.zig index b8b0cda99..2115928fa 100644 --- a/src/runtime/gc/gc_trigger.zig +++ b/src/runtime/gc/gc_trigger.zig @@ -17,9 +17,9 @@ //! so callers write `rt.gc.noteOwnedAlloc(n)`. const std = @import("std"); -const builtin = @import("builtin"); const gc_heap_mod = @import("gc_heap.zig"); const root_set_mod = @import("root_set.zig"); +const glibc_policy = @import("glibc_policy.zig"); const GcHeap = gc_heap_mod.GcHeap; @@ -157,15 +157,7 @@ pub const idle_collect_min_bytes: usize = 1024 * 1024; pub fn collectBeforeIdle(gc: *GcHeap) void { if (pressureBytes(gc) < idle_collect_min_bytes) return; requestCollect(gc); - trimProcessHeap(); -} - -extern "c" fn malloc_trim(pad: usize) c_int; - -/// Return glibc's free arena memory to the OS. Other allocators unmap large -/// frees themselves, so this is a no-op off glibc. -fn trimProcessHeap() void { - if (comptime builtin.link_libc and builtin.target.abi.isGnu()) _ = malloc_trim(0); + glibc_policy.trimProcessHeap(); } /// True iff a stop-the-world collect may run on this thread right now: no diff --git a/src/runtime/gc/glibc_policy.zig b/src/runtime/gc/glibc_policy.zig new file mode 100644 index 000000000..e33316df7 --- /dev/null +++ b/src/runtime/gc/glibc_policy.zig @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EPL-2.0 +//! glibc malloc tuning for the process heap: the startup mmap-threshold pin +//! and the idle `malloc_trim` (CLJW-BBMCP-MEMORY). +//! +//! cljw's runtime gpa is `c_allocator`, so on glibc every non-GC buffer goes +//! through glibc malloc. glibc raises its mmap threshold dynamically (up to +//! 32 MiB) the first time a mmapped chunk is freed, after which the 64-256 KiB +//! response buffers a server like bb-mcp churns land in the worker threads' +//! arenas, fragment, and are only partly trimmable: RSS creeps per request. +//! `pinMmapThreshold` sets `M_MMAP_THRESHOLD` explicitly, which also disables +//! the dynamic adjustment, so large buffers stay on mmap/munmap. +//! +//! Both functions compile to nothing off glibc (macOS, musl, wasm): those +//! allocators unmap large frees themselves. A user-set `GLIBC_TUNABLES` wins: +//! the pin is skipped so the operator's tuning is not overridden. + +const builtin = @import("builtin"); + +/// True when this build links glibc, the only libc these calls exist in. +const is_glibc = builtin.link_libc and builtin.target.abi.isGnu(); + +/// glibc's `M_MMAP_THRESHOLD` mallopt parameter (malloc.h). +const m_mmap_threshold: c_int = -3; + +/// Allocations at or above this size are served by mmap. 128 KiB is glibc's +/// own default starting threshold; pinning it is what stops the creep. +pub const mmap_threshold_bytes: c_int = 128 * 1024; + +extern "c" fn mallopt(param: c_int, value: c_int) c_int; +extern "c" fn malloc_trim(pad: usize) c_int; + +/// Pin glibc's mmap threshold at process start, before any worker thread +/// exists. `user_tunables` is true when `GLIBC_TUNABLES` is set in the +/// environment; the pin then defers to it. +pub fn pinMmapThreshold(user_tunables: bool) void { + if (comptime !is_glibc) return; + if (user_tunables) return; + _ = mallopt(m_mmap_threshold, mmap_threshold_bytes); +} + +/// Return glibc's free arena memory to the OS (called when the mutator goes +/// idle). A no-op off glibc. +pub fn trimProcessHeap() void { + if (comptime is_glibc) _ = malloc_trim(0); +} + +test "glibc policy calls are safe on every target" { + pinMmapThreshold(true); + trimProcessHeap(); +} From 7a27fe746cd3ef251011b60314ed2465a8aaee40 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 18:10:36 -0300 Subject: [PATCH 35/51] feat(examples): add fractal wasm kernels and native cljw benchmark --- .gitignore | 3 + docs/examples/fractal/bench.clj | 59 ++++++++++++ docs/examples/fractal/kernel.wasm | Bin 0 -> 3187 bytes docs/examples/fractal/kernel.zig | 123 +++++++++++++++++++++++++ docs/examples/fractal/kernel_simd.wasm | Bin 0 -> 3319 bytes docs/examples/fractal/verify.mjs | 43 +++++++++ 6 files changed, 228 insertions(+) create mode 100644 docs/examples/fractal/bench.clj create mode 100755 docs/examples/fractal/kernel.wasm create mode 100644 docs/examples/fractal/kernel.zig create mode 100755 docs/examples/fractal/kernel_simd.wasm create mode 100644 docs/examples/fractal/verify.mjs diff --git a/.gitignore b/.gitignore index ae37174e6..c5b158716 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ docs/examples/polyglot/go/jsonsum.wasm # Per-worktree ling scratch and resume notes (never committed) .scratch/ + +# Generated fractal demo frame +docs/examples/fractal/out.ppm diff --git a/docs/examples/fractal/bench.clj b/docs/examples/fractal/bench.clj new file mode 100644 index 000000000..3b12d5975 --- /dev/null +++ b/docs/examples/fractal/bench.clj @@ -0,0 +1,59 @@ +;; Run from the repository root: zig-out/bin/cljw docs/examples/fractal/bench.clj +;; Render timing excludes framebuffer transfer, checksum and PPM encoding. +(ns fractal.bench (:require [clojure.java.io :as io])) + +(def root "docs/examples/fractal/") +(def views [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 512) :w 512 :h 512 :iter 256} + {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000} + {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}]) + +(defn checksum [pixels] + (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels)) + +(defn ppm! [pixels w h] + ;; P3 is portable text PPM, avoiding host-specific binary stream methods. + (with-open [out (io/writer (str root "out.ppm"))] + (.write out (str "P3\n" w " " h "\n255\n")) + (dotimes [i (* w h)] + (.write out (str (bit-and 255 (aget pixels (* i 4))) " " + (bit-and 255 (aget pixels (+ (* i 4) 1))) " " + (bit-and 255 (aget pixels (+ (* i 4) 2))) "\n"))))) + +(def references (atom {})) +(defn run-frame! [module engine export view] + (let [{:keys [name cx cy scale w h iter]} view + _ (assert (= 0 (.set_view module cx cy scale))) + start (System/nanoTime) + iterations (wasm/call module export w h iter) + ms (/ (- (System/nanoTime) start) 1.0e6) + pixels (wasm/mem-read-array module :u8 (.fb_ptr module) (* w h 4)) + sum (checksum pixels) + result [iterations sum]] + (println engine export name "ms" ms "Miter/s" (/ iterations (* ms 1000.0)) + "iterations" iterations "checksum" sum) + (if-let [reference (get @references name)] + (assert (= reference result) (str "parity mismatch: " engine " " export " " name)) + (swap! references assoc name result)) + (when (and (= engine :interp) (= export "render") (= name "full")) + (ppm! pixels w h)))) + +(doseq [engine [:jit :interp]] + (let [module (wasm/load (str root "kernel.wasm") {:engine engine :fuel 0})] + (let [exports (wasm/exports module)] + (println "exports" engine exports) + (doseq [export ["render" "render_simd"]] + (let [signature (first (filter #(= export (:name %)) exports))] + (assert (= [:i32 :i32 :i32] (:params signature))) + (assert (= [:i64] (:results signature)))))) + (assert (= 1048576 (.fb_cap module))) + (doseq [export ["render" "render_simd"] view views] + (run-frame! module engine export view)))) +(println "PASS scalar artifact: strict engine and vector-source parity") + +;; SIMD instructions are JIT-only in zwasm. Keep this artifact separate so the +;; scalar module remains usable by the interpreter and browsers without SIMD. +(let [module (wasm/load (str root "kernel_simd.wasm") {:engine :jit :fuel 0})] + (doseq [export ["render" "render_simd"] view views] + (run-frame! module :jit-simd export view)) + (println "PASS simd128 JIT strict parity")) +(println "Wrote docs/examples/fractal/out.ppm") diff --git a/docs/examples/fractal/kernel.wasm b/docs/examples/fractal/kernel.wasm new file mode 100755 index 0000000000000000000000000000000000000000..46041eb1bd6af96e040523ef9235266790589964 GIT binary patch literal 3187 zcmeHJOK4nG7(S1AB{%cTJUdPO??FKjTnZKJ%$=sG!A-4JsGBBjN9bT|nua#Qkcq^W z;zERq+EDAA>%vuV*=}NLMP0ND5nSk^20>i7Fyf*`3gdU~V;;fA)|Cq{+C-cuF~)a=r(~pDY|2;^OkBuND9mR$=V8g&^N}Im z;?~mAb!J5OL?ScwnUiy`3uesKUpro(J97HO%yGsd-iYzYD@TtUo1SGt6SMPkOvX;v z=Z~B@Q9sKK15?fuY zY0X~3lCb!*ab~e6BxaVF3a!;T!NZmV`c6kPYp!=(uJAu_U2wS~gIr7AbGO4@b`Qoe zkKla$n=G4AV%NsuZaf7>!hNC?)ixq)4C-wFmi3!S5)=_yHrBO!} zY@an%t)Egwm^5k@db>*_(x5nYfZ29z5XG{YB|*i8DJV$=%C~JS7R#z&j#89z7H_EB z{x~!Gg>b_)Mfj%d=&g-v^?Jj6kIeQ+*E%p3T^ovB8w#Ecr2!jqRHV!Vr77*GGUX}n zsGS20V-}yE^9-fDSsD>rEX-*O;}C$o+U7KkmCZC|XslsoV0z9oy+j4q^qgn<4%$g& zOyt3>N@gCuC~996wJ!`x&`84)^QvflptA}-LRl>>JjIZ_tL83*Mo@GDS{1Dom%+Dw zv!au`am@`H++A>Nl^tKcUEa%Bs+u|vAuM%HfBg+eJAZuRhmFVo_|>dZ_I?@sV!1uO za>;!v4#XaEpPwtXLbxxt^*8X`=DK~U?H#dkvg+KEPeb#fb!m&PfsF4SSVqp*8;15G z4jU;81@PTY0fQv%YPvv?+UNT2vgcgUb*`k$DmV~5-@8|~Xq*WObhDyEopz{1rT>suz%;|ge4j(0n9-rQ&87%mub+jPVr&lUXn}WYGh;(;WeU%o8o^&_tPAZ zp8%321911SJvE4e=^k@NRKc2|jDu=)F|At>K32{jNEG|VIdCR9g)lDayQ#M}sMUpP_b zwOKLd#l?7wbKbJh_3ZY#6_9MV*WQ2>yQSNmSbm!{=vcq?@%kO&gNt7Ovg9=|mOerZ zGW6{(o4Ed^aZ^V4qT8+E8*1uC1bM>N6tDTxVu!ok$Y#g)!QB?IZX!=B0bwNKf_RO+ z;QsVM$z?OtWjnCfS*y_nlUp~>nqmZ919~O!_i2QL_r`jo^~M6ynq!Nr$@Pvr*i{3C zCMk3v4k7##>!;5iYpAg9Bu`maK40m`#%hhuQ|Qh0AYkjZf`_}R;2L$5Y_GWn&MvR3 z2pFlpKX(T=B`S-;R`yKes*2fa2$dE?-)3Wbq)oEjCx%t+k0Dp;DZMZ1_@{jI)xtMJ zD_>Nc@lO}t9{cjtt%~i8hyHvYm+|)?A=1-k{NSD+Gr!*e-)(vGz2g&KYrex5)w_>Q zeyhog!IkaUKmes3?rRAF5#+4%+mrXgzdVCw+Y9DC&KE^hi$5A?AmGqx+?_0G=)sx} pt2kF{9l{(3mq4`2NjOpOAbzZDYL6O5iTfFwV(zoC?){sUzW~gXZj%52 literal 0 HcmV?d00001 diff --git a/docs/examples/fractal/kernel.zig b/docs/examples/fractal/kernel.zig new file mode 100644 index 000000000..846412396 --- /dev/null +++ b/docs/examples/fractal/kernel.zig @@ -0,0 +1,123 @@ +//! Mandelbrot RGBA8 kernel, freestanding with no imports. +//! Build from the repository root (Zig 0.16.0): +//! zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel.wasm +//! zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -mcpu=generic+simd128 -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel_simd.wasm +//! ABI: set_view(f64, f64, f64) -> i32 (0 finite, -1 nonfinite); +//! render(i32, i32, i32) -> i64 and render_simd(i32, i32, i32) -> i64. +//! set_view stores even an invalid view. Render validates before any write. +//! fb_ptr() -> i32 addresses RGBA8 storage; fb_cap() -> i32 is pixel capacity. +//! The default build also exposes render_simd, with vector operations scalarized. +//! Invalid dimensions or nonfinite coordinates return -1 without writing pixels. +//! Iterations are capped at 2^26-1 so totals remain exactly representable by cljw. +const std = @import("std"); +const capacity = 1024 * 1024; +var framebuffer: [capacity * 4]u8 = undefined; +var view_cx: f64 = -0.5; +var view_cy: f64 = 0; +var view_scale: f64 = 3.5 / 512.0; + +export fn set_view(cx: f64, cy: f64, scale: f64) i32 { + view_cx = cx; + view_cy = cy; + view_scale = scale; + return if (std.math.isFinite(cx) and std.math.isFinite(cy) and std.math.isFinite(scale)) 0 else -1; +} + +export fn fb_ptr() usize { + return @intFromPtr(&framebuffer); +} +export fn fb_cap() i32 { + return capacity; +} + +fn valid(cx: f64, cy: f64, scale: f64, w: i32, h: i32, max_iter: i32) bool { + return w > 0 and h > 0 and w <= capacity and h <= capacity and + @as(i64, w) * h <= capacity and max_iter > 0 and + std.math.isFinite(cx) and std.math.isFinite(cy) and std.math.isFinite(scale); +} + +fn color(pixel: usize, n: u32, limit: u32, magnitude: f64) void { + const out = framebuffer[pixel * 4 ..][0..4]; + out.* = .{ 0, 0, 0, 255 }; + if (n == limit) return; + // Continuous escape count, mapped through a periodic triangular palette. + const bounded = if (std.math.isFinite(magnitude)) @max(magnitude, 4.000001) else 1.0e300; + const smooth = @as(f64, @floatFromInt(n)) + 1.0 - @log2(@log2(bounded) * 0.5); + const phase = smooth * 0.025; + inline for (0..3) |channel| { + const t = phase + @as(f64, @floatFromInt(channel)) / 3.0; + const wave = 1.0 - @abs(2.0 * (t - @floor(t)) - 1.0); + out[channel] = @intFromFloat(wave * 255.0); + } +} + +export fn render(w: i32, h: i32, max_iter: i32) i64 { + const cx = view_cx; + const cy = view_cy; + const scale = view_scale; + if (!valid(cx, cy, scale, w, h, max_iter)) return -1; + const limit: u32 = @intCast(@min(max_iter, (1 << 26) - 1)); + var total: i64 = 0; + var y: usize = 0; + while (y < @as(usize, @intCast(h))) : (y += 1) { + const ci = cy + (@as(f64, @floatFromInt(y)) - @as(f64, @floatFromInt(h)) / 2.0) * scale; + var x: usize = 0; + while (x < @as(usize, @intCast(w))) : (x += 1) { + const cr = cx + (@as(f64, @floatFromInt(x)) - @as(f64, @floatFromInt(w)) / 2.0) * scale; + var zr: f64 = 0; + var zi: f64 = 0; + var n: u32 = 0; + while (n < limit and zr * zr + zi * zi <= 4.0) : (n += 1) { + const next = zr * zr - zi * zi + cr; + zi = (2.0 * zr) * zi + ci; + zr = next; + } + total += n; + color(y * @as(usize, @intCast(w)) + x, n, limit, zr * zr + zi * zi); + } + } + return total; +} + +export fn render_simd(w: i32, h: i32, max_iter: i32) i64 { + const cx = view_cx; + const cy = view_cy; + const scale = view_scale; + if (!valid(cx, cy, scale, w, h, max_iter)) return -1; + const V = @Vector(2, f64); + const limit: u32 = @intCast(@min(max_iter, (1 << 26) - 1)); + const width: usize = @intCast(w); + var total: i64 = 0; + var y: usize = 0; + while (y < @as(usize, @intCast(h))) : (y += 1) { + const ci: V = @splat(cy + (@as(f64, @floatFromInt(y)) - @as(f64, @floatFromInt(h)) / 2.0) * scale); + var x: usize = 0; + while (x < width) : (x += 2) { + const positions: V = .{ @floatFromInt(x), @floatFromInt(x + 1) }; + const cr = @as(V, @splat(cx)) + (positions - @as(V, @splat(@as(f64, @floatFromInt(w)) / 2.0))) * @as(V, @splat(scale)); + var zr: V = @splat(0); + var zi: V = @splat(0); + var counts = [_]u32{ 0, 0 }; + var step: u32 = 0; + while (step < limit) : (step += 1) { + const mag = zr * zr + zi * zi; + const active: @Vector(2, bool) = .{ counts[0] == step and mag[0] <= 4.0, x + 1 < width and counts[1] == step and mag[1] <= 4.0 }; + if (!@reduce(.Or, active)) break; + const next = zr * zr - zi * zi + cr; + zi = @select(f64, active, (@as(V, @splat(2.0)) * zr) * zi + ci, zi); + zr = @select(f64, active, next, zr); + inline for (0..2) |lane| { + if (active[lane]) counts[lane] += 1; + } + } + const magnitude = zr * zr + zi * zi; + inline for (0..2) |lane| { + if (x + lane < width) { + total += counts[lane]; + color(y * width + x + lane, counts[lane], limit, magnitude[lane]); + } + } + } + } + return total; +} diff --git a/docs/examples/fractal/kernel_simd.wasm b/docs/examples/fractal/kernel_simd.wasm new file mode 100755 index 0000000000000000000000000000000000000000..86a89f80e05a8abe5e501faa203d847d62ef4979 GIT binary patch literal 3319 zcmdT`O>9(E6u$Sq`G0RZKb@I&7|?THG%+!8sli0vyrBgN2|*AO+|U9wVPd5e0yRuJ zH5Ec!7!!;NL3|w-uBuDAsYU!@0SjW{LKoPW7#A!ox_}s7#_ztL>9iEZjnOxmd+&Sq z+;h+Q?mgc*Z20IXXN>VjqC;A<+2oT$j5mk0Mx%j_H+W-6VA#+SaR_mtMIuo?#yO8_ zoV^fB@+P;ZrYiL_{eJqN5=OZJv4fdv6%nHcfR*suq9M<1d0gzEAQ-ttmB8C1yA4cDrqD zPc!1(<5^;}lQm`6OPCTie?HA@_N0a{Q_PIa*V^I3ro-`_wh~rS?|4$tzmYoQNyS!5 zP5IB=HhbCI7~4FG{Z+TEBICs81^_mmMXMzf#JXPHf=p5jZHgjjb(=|}#-tW3U`(d9 zDjCFHl|2-t7^NvO({do1foK+UI;AK(=N2ey^KnzmxhAKidz-tzad(N!oT#cF31ZvK zymD0v*HFfEKW48iV5X>5@1LV0i7iy10yLT>>ZM*a=!0@svcS%4b>xWIKkGiiY|FjI z(OltSwFvT_Nq8;0yJP_-_1C4K9Q9F28N9?Hhbglpdnryuo7c_a)-==C zut~V6)!z{VGak#y* zWN7+YK9#yWuXz5IsRYo{4DpjlrgYV-ziW7*2jyY!?r=ZlvJ52FQC|lMC<&0jRj!)? zNWkAU9ELjz3}u^6S?a0V;+QGy57bqyoBfE?sfni<3!H^frQmL;gQzgGI4jo!mLkQs zc-Qy5w=g)_CCfXAId~!wL<7?|E1Mb1RDP<&v}p4g4#Xa^Rwlc8)(CY8Pz(W1q;r(8GpQZxZ1j>JqurieQuSNp^on! zT1S7lfklyE$e3eBC;|h}{*?hbX|%rK@lk%zL+!$9xHW|?2f7ej2KVABvx^}aELo1i znE#hB{&1DC|2D>1Thz@gmCSOEGEIhx34^%e2ONfeDQ5UtM0lA5xdVN)A&}et zw|S#GQ6O)uNFzy*Txw;e(2)&NDpMF*67J4jMokdcCw3 z4;Am@p-_k7p@-8^>RfDE#veXX4hATI!}&D~P(0M92&N|#vK&+j85JEFVk$aF@0voj zpr%sGffHwbDVJdWx#(|k&YL!BtBrn5gdyAL7f~3Bjha^j*+DV364;=?@~bN1D<_xP zA7zrQwh$`?x>i>tvaF`x(&DK6R85FB(%>h9YKG05ycXz7>`k-K!*(#Z+obRtW5cF^ zIufWwc#ZA$I<-}1T1GlzJ2$uHYjj3(``QU946YNZFvxe=ge&jAtA+L(6S&wMOFT<1 zx3ygzGZ1PTMRum4gnwfH^!ej;GpZ`qBlg8F&bPJtY>iG+IKIH#vN0TM_Z;g zGw(S#HoaiRAV?!yi+7MJ5nB|s3&-o1%!Fem5o-y=xr61g8PaCD)Uc}aG4yKcQ%h}t z#clyd&s2$+qLgcV^v%SL Date: Sat, 7 Nov 2026 18:16:33 -0300 Subject: [PATCH 36/51] test(e2e): gate fractal parity and reproducible wasm artifacts --- docs/examples/fractal/bench.clj | 12 +++++-- test/e2e/phase16_fractal_kernel.sh | 56 ++++++++++++++++++++++++++++++ test/run_all.sh | 2 ++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 test/e2e/phase16_fractal_kernel.sh diff --git a/docs/examples/fractal/bench.clj b/docs/examples/fractal/bench.clj index 3b12d5975..f12257781 100644 --- a/docs/examples/fractal/bench.clj +++ b/docs/examples/fractal/bench.clj @@ -1,11 +1,17 @@ ;; Run from the repository root: zig-out/bin/cljw docs/examples/fractal/bench.clj ;; Render timing excludes framebuffer transfer, checksum and PPM encoding. +;; CLJW_FRACTAL_CI=1 selects small correctness cases for the e2e gate. (ns fractal.bench (:require [clojure.java.io :as io])) (def root "docs/examples/fractal/") -(def views [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 512) :w 512 :h 512 :iter 256} - {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000} - {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}]) +(def views + (if (= "1" (System/getenv "CLJW_FRACTAL_CI")) + [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 32) :w 32 :h 32 :iter 64} + {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 16 :h 16 :iter 160} + {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}] + [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 512) :w 512 :h 512 :iter 256} + {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000} + {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}])) (defn checksum [pixels] (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels)) diff --git a/test/e2e/phase16_fractal_kernel.sh b/test/e2e/phase16_fractal_kernel.sh new file mode 100644 index 000000000..890f0233a --- /dev/null +++ b/test/e2e/phase16_fractal_kernel.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# e2e-process-boundary: rebuilds Zig wasm artifacts and compares compiler output +# Layer 2 correctness gate, not a performance gate: no timing thresholds. +# Committed artifacts must pass bench.clj's small parity cases across engines. +# Both exact header recipes must reproduce the committed files byte-for-byte. +set -euo pipefail +cd "$(dirname "$0")/../.." +BIN="${CLJW_BIN:-zig-out/bin/cljw}" +fail() { echo "FAIL fractal-kernel: $1" >&2; exit 1; } +run_bounded() { + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" + else fail "timeout or gtimeout is required"; fi +} + +# The harness owns building cljw. Never silently rebuild a non-wasm binary. +version="$(run_bounded 10 "$BIN" --version)" || fail "cannot query $BIN" +if ! grep -q wasm <<<"$version"; then + echo "SKIP fractal-kernel: cljw was built without -Dwasm ($version)" + exit 0 +fi +case "$BIN" in /*) ;; *) BIN="$(pwd)/$BIN" ;; esac +DEMO="$(pwd)/docs/examples/fractal" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/cljw_fractal_XXXXXX")" +trap 'rm -rf "$scratch"' EXIT + +# Run in a scratch repo-shaped tree, so CI never overwrites the human PPM. +mkdir -p "$scratch/docs/examples/fractal" +cp "$DEMO/bench.clj" "$DEMO/kernel.zig" "$DEMO/kernel.wasm" "$DEMO/kernel_simd.wasm" \ + "$scratch/docs/examples/fractal/" +out="$(cd "$scratch" && CLJW_FRACTAL_CI=1 run_bounded 60 "$BIN" docs/examples/fractal/bench.clj 2>&1)" \ + || fail "committed artifact parity failed: +$out" +for marker in "PASS scalar artifact: strict engine and vector-source parity" \ + "PASS simd128 JIT strict parity"; do + grep -qF "$marker" <<<"$out" || fail "missing $marker: +$out" +done +# CI selects reduced views only, with exactly 18 frame comparisons. +[ "$(grep -c '^:.* checksum ' <<<"$out")" -eq 18 ] || fail "expected 18 parity frames: +$out" +echo "PASS fractal-kernel-committed -> 18 frames, scalar JIT/interp and simd128 JIT parity" + +# Rebuild from the header recipes, spelled out like phase16_zig_native.sh. +# Scratch is repo-shaped so the source and output paths match the header. +rm "$scratch/docs/examples/fractal/kernel.wasm" "$scratch/docs/examples/fractal/kernel_simd.wasm" +(cd "$scratch" && run_bounded 90 zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel.wasm) \ + || fail "scalar header recipe failed" +(cd "$scratch" && run_bounded 90 zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -mcpu=generic+simd128 -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel_simd.wasm) \ + || fail "SIMD header recipe failed" +for artifact in kernel.wasm kernel_simd.wasm; do + cmp -s "$DEMO/$artifact" "$scratch/docs/examples/fractal/$artifact" \ + || fail "$artifact differs from exact header recipe output" +done +echo "PASS fractal-kernel-rebuilt -> both artifacts byte-identical to committed files" diff --git a/test/run_all.sh b/test/run_all.sh index d368a61e7..2a34fedc5 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -706,6 +706,8 @@ run_step "e2e_phase16_wasm_polyglot" "bash test/e2e/phase16_wasm_polyglot # docs/examples/zig-native: one Zig source as a wasm guest and as a native # library through cljw.ffi, committed wasm AND rebuilt from the header recipe. run_step "e2e_phase16_zig_native" "bash test/e2e/phase16_zig_native.sh" +# Fractal example: committed scalar/SIMD parity and reproducible header recipes. +run_step "e2e_phase16_fractal_kernel" "bash test/e2e/phase16_fractal_kernel.sh" # The reswap coverage moved to suites/wasm-require-component-reswap-test: it # asserts values, not the process boundary, so it runs in-process now. run_step "e2e_phase15_deferred_host_ref" "bash test/e2e/phase15_deferred_host_ref.sh" From da68e7b131c3d9a5d30c4a2b6fcc864e408b28eb Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 18:33:59 -0300 Subject: [PATCH 37/51] feat(http): accept byte-array response bodies cljw.http.server's Ring response now also accepts a primitive byte-array :body and copies it verbatim, beside the existing string body. This is the only route for binary payloads (wasm tiles, wasm modules), since a String's producers normalise to UTF-8 and corrupt bytes >= 0x80. e2e: /bytes fixture route serves byte-array (range 256); the suite asserts 256 verbatim bytes over the wire (14 cases green). Smell-audited: 0: additive body arm beside the string arm, no behaviour change to existing paths --- src/runtime/cljw/http/server.zig | 11 ++++++++--- test/e2e/fixtures/http_server_demo.clj | 6 ++++++ test/e2e/phase16_http_server.sh | 12 +++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/runtime/cljw/http/server.zig b/src/runtime/cljw/http/server.zig index c0cb076f2..37f36bf7c 100644 --- a/src/runtime/cljw/http/server.zig +++ b/src/runtime/cljw/http/server.zig @@ -6,9 +6,11 @@ //! becomes a Ring request map `{:request-method :get :uri "/path" :query-string //! "…" :headers {…} :body "…"}` passed to the handler (invoked via //! `rt.vtable.callFn`), whose response map `{:status N :body "…"}` (or a bare -//! string body) is written back. Header names are lowercased (Ring); `:body` is -//! nil when the client declared none (read only on Content-Length/chunked, -//! capped at `max_body_bytes`). +//! string body) is written back. A response `:body` may also be a primitive +//! byte-array (`byte-array`, e.g. from `wasm/mem-read-array`): its bytes are +//! copied verbatim. Header names are lowercased (Ring); +//! `:body` is nil when the client declared none (read only on +//! Content-Length/chunked, capped at `max_body_bytes`). //! //! Follow-ons (D-257 remainder): per-connection threading + a non-blocking stop //! handle (so keep-alive can be re-enabled), and per-request GC rooting of the @@ -30,6 +32,7 @@ const safepoint = @import("../../concurrency/safepoint.zig"); const string_mod = @import("../../collection/string.zig"); const map_mod = @import("../../collection/map.zig"); const client = @import("client.zig"); +const java_array = @import("../../collection/java_array.zig"); /// `:request-method` keyword for an HTTP method (lowercase, Ring convention). fn methodKeyword(rt: *Runtime, m: std.http.Method) !Value { @@ -206,6 +209,8 @@ fn collectResponse(arena: std.mem.Allocator, resp: Value, kw_status: Value, kw_b var body: []const u8 = ""; const b = map_mod.get(resp, kw_body) catch Value.nil_val; if (b.tag() == .string) body = try arena.dupe(u8, string_mod.asString(b)); + // byte-array body: verbatim copy + if (b.tag() == .array and java_array.isKind(b, .byte)) body = try arena.dupe(u8, java_array.asArray(b).bytes()); var headers: std.ArrayList(std.http.Header) = .empty; const h = map_mod.get(resp, kw_headers) catch Value.nil_val; diff --git a/test/e2e/fixtures/http_server_demo.clj b/test/e2e/fixtures/http_server_demo.clj index ef4603cf9..9202e8728 100644 --- a/test/e2e/fixtures/http_server_demo.clj +++ b/test/e2e/fixtures/http_server_demo.clj @@ -12,6 +12,12 @@ :headers {"content-type" "text/html; charset=utf-8" "x-custom" "yes"} :body "

hi

"} + ;; Binary body: a primitive byte-array :body is copied verbatim. Every + ;; byte value 0..255 once, so a UTF-8-normalising path would corrupt it + ;; (bytes >= 0x80 expand) and the length alone detects the regression. + (= (:uri req) "/bytes") {:status 200 + :headers {"content-type" "application/octet-stream"} + :body (byte-array (map unchecked-byte (range 256)))} ;; Out-of-range :status must NOT panic the server process — it falls back ;; to 500 (FIX-2 / SE-4). 200000 > 1023 would crash a bare @intCast(u10). (= (:uri req) "/badstatus") {:status 200000 :body "should become 500"} diff --git a/test/e2e/phase16_http_server.sh b/test/e2e/phase16_http_server.sh index 3f549bd7e..ec864da71 100755 --- a/test/e2e/phase16_http_server.sh +++ b/test/e2e/phase16_http_server.sh @@ -57,6 +57,16 @@ body=$(curl -s "http://127.0.0.1:$PORT/html" 2>&1) [[ "$body" == "

hi

" ]] || fail "GET /html body: got '$body'" echo "PASS http-html-body -> $body" +# A primitive byte-array :body is served verbatim: 256 bytes, one of each +# value 0..255. A UTF-8 path would expand bytes >= 0x80 and grow the body. +BLEN=$(curl -s "http://127.0.0.1:$PORT/bytes" | wc -c) +[[ "$BLEN" == "256" ]] || fail "GET /bytes length: got $BLEN, want 256" +BOD=$(curl -s "http://127.0.0.1:$PORT/bytes" | od -An -tu1 | tr -s ' ') +EXP="" +for i in $(seq 0 255); do EXP="$EXP $i"; done +[[ "$(echo $BOD)" == "$(echo $EXP)" ]] || fail "GET /bytes content mismatch" +echo "PASS http-bytes-body -> 256 verbatim bytes" + # NINE response headers — a >8-entry :headers map promotes to hash_map # (Discussion #12 bug class) and used to be dropped wholesale. hdrs=$(curl -s -D - -o /dev/null "http://127.0.0.1:$PORT/many-headers" 2>&1) @@ -120,4 +130,4 @@ else echo "SKIP http-slowloris (bash /dev/tcp unavailable)" fi -echo "OK — phase16_http_server (13 cases) green" +echo "OK — phase16_http_server (14 cases) green" From 9978328c8e12dec400c8b491dbbe0ed89e1717f6 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 18:36:26 -0300 Subject: [PATCH 38/51] feat(examples): fractal-zoom demo, server and browser halves Native cljw serves the page and renders tiles server-side with zwasm; the browser runs the identical kernel.wasm locally and the page compares engines live (browser, server-jit, server-jit-simd, server-interp, and a split view). Tiles cross as raw RGBA over the new byte-array response bodies; X-Render-Ms and X-Iters headers carry guest-side numbers. The kernel ABI is the two-call set_view/render shape, which keeps every export within zwasm's JIT dispatch limit (a 6-parameter render traps, measured). Each instance is locked so set_view + render is atomic. kernel.wasm and kernel_simd.wasm come from feat/fractal-kernel and are deliberately not committed here; the coordinator merges both branches. --- docs/examples/fractal/README.md | 99 ++++++++ docs/examples/fractal/index.html | 395 +++++++++++++++++++++++++++++++ docs/examples/fractal/server.clj | 162 +++++++++++++ 3 files changed, 656 insertions(+) create mode 100644 docs/examples/fractal/README.md create mode 100644 docs/examples/fractal/index.html create mode 100644 docs/examples/fractal/server.clj diff --git a/docs/examples/fractal/README.md b/docs/examples/fractal/README.md new file mode 100644 index 000000000..30287c488 --- /dev/null +++ b/docs/examples/fractal/README.md @@ -0,0 +1,99 @@ +# Fractal zoom: one kernel.wasm, three engines + +A Mandelbrot explorer where the **same** WebAssembly module renders on three +engines at once, and the page compares them live: + +- **Browser**: the tab instantiates `/kernel.wasm` itself (V8 or + SpiderMonkey JIT, plus SIMD when the module ships `render_simd`). +- **Server-jit**: native cljw renders the tile with zwasm's JIT and returns + raw RGBA bytes. +- **Server-interp**: the same, on zwasm's tree-walking interpreter. + +The split is the retired cw-playground's shape (docs/works/demos.md section +4): cljw's own HTTP server (`cljw.http.server/run-server`) serves the page and +the compute; no JVM, no node, no CDN. + +## Run + +```sh +zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary +cljw docs/examples/fractal/server.clj [port] # default 8080 +``` + +Open http://localhost:8080. The server loads one zwasm instance per engine at +startup and reuses it across requests. + +`KERNEL=/path/to/other.wasm cljw docs/examples/fractal/server.clj` overrides +the module. When the kernel ships SIMD as a sibling file instead of an export, +drop `kernel_simd.wasm` next to it; the JIT leg picks it up. The interpreter +cannot run SIMD (v128), so `server-interp` always uses the scalar `render`. + +## The kernel ABI + +`kernel.wasm` has no imports and exports: + +| export | signature | meaning | +|---|---|---| +| `memory` | | linear memory holding the framebuffer | +| `fb_ptr` | `() -> i32` | byte address of the RGBA8 framebuffer | +| `fb_cap` | `() -> i32` | framebuffer capacity in pixels (1048576) | +| `set_view` | `(f64 f64 f64) -> i32` | stores cx, cy, scale; 0, or -1 when a coordinate is not finite | +| `render` | `(i32 i32 i32) -> i64` | writes `w*h` RGBA pixels at `fb_ptr` for the stored view, returns total iterations | +| `render_simd` | `(i32 i32 i32) -> i64` | SIMD twin, ships only in `kernel_simd.wasm` | + +Pixel `(px, py)` is the complex point `(cx + (px - w/2) * scale, cy + (py - h/2) * scale)`. + +The two-call shape (view, then render) keeps every export at or below the +5-parameter dispatch limit of zwasm's JIT: a single 6-parameter `render` +traps at invoke (measured, gaps.zig `jit_wide_arity`). + +## Run + +```sh +zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary +cljw docs/examples/fractal/server.clj [port] # default 8080 +``` + +## The page + +Mouse wheel zooms around the cursor, drag pans, double-click zooms in, the +slider sets the iteration cap, Reset returns home. Modes: Browser, +Server-jit, Server-interp, and Split (browser on the left half, server-jit on +the right, same view). All of it is one self-contained file, works at phone +width, dark by default. + +## The numbers + +- **ms/frame**: for the browser leg, wall time of the `render` call. For the + server legs, the guest render time only (the `X-Render-Ms` response header); + network and byte-copy time are excluded so the comparison is engine against + engine. +- **Miter/s**: total Mandelbrot iterations (the `i64` `render` returns) + divided by that time. It scales with both zoom depth and the iteration cap, + so compare engines within one view, not views against each other. +- The rolling chart plots ms/frame per engine over the last 120 frames. +- **Bench** renders a fixed list of views at one size on all three engines and + tabulates ms and Miter/s, handy on a fresh load before you touch anything. + +Measured on the author's x86_64 host (512x512 full set, iter 256; ratios are +the stable part, absolutes are not): + +| engine | ms/frame | Miter/s | +|---|---|---| +| zwasm JIT, scalar | 275 | 33.7 | +| zwasm JIT, simd128 | 589 | 15.8 | +| zwasm interpreter | 3717 | 2.5 | + +Two honest edges: on zwasm's JIT the SIMD build is SLOWER than scalar (the +per-call v128 dispatch costs more than the vectorisation wins at this tile +size), and the interpreter trails the JIT by roughly 13x, in line with cljw's +own FFI measurement of ~15x on compute-bound bodies. The browser JIT (V8 / +SpiderMonkey) is where SIMD pays off normally; compare it against server-jit +in the page itself. + +## Known engine edges + +`render` takes 6 parameters. zwasm's JIT is listed as possibly not dispatching +exports with more than 5; if the JIT leg traps with that diagnosis, the remedy +is structural (the kernel packs params) or the JIT leg falls back to interp. +The trap message names the shape and the option. diff --git a/docs/examples/fractal/index.html b/docs/examples/fractal/index.html new file mode 100644 index 000000000..c70fc4207 --- /dev/null +++ b/docs/examples/fractal/index.html @@ -0,0 +1,395 @@ + + + + + +cljw fractal zoom: one kernel.wasm, three engines + + + +
+

fractal zoom

+
+ + +
+
+ +
+ + +
+
+ +
+
+
+
+ + + +
enginems/frameMiter/sframes
+
▬ browser + ▬ server-jit + ▬ server-interp  (rolling ms/frame)
+ +
+
Bench renders a fixed view list on every engine and reports ms and Miter/s.
+
+ + + diff --git a/docs/examples/fractal/server.clj b/docs/examples/fractal/server.clj new file mode 100644 index 000000000..17e5d4bc7 --- /dev/null +++ b/docs/examples/fractal/server.clj @@ -0,0 +1,162 @@ +;; Fractal zoom: the server half. +;; Usage: cljw docs/examples/fractal/server.clj [port] (default 8080) +;; KERNEL=/path/to/kernel.wasm cljw server.clj (override module) +;; +;; Native cljw serves the page and renders tiles server-side with zwasm; the +;; browser ALSO runs the identical kernel.wasm locally and compares live. +;; +;; kernel.wasm ABI (fixed): no imports; exports `memory`, `fb_ptr() -> i32`, +;; `fb_cap() -> i32`, `set_view(cx f64, cy f64, scale f64) -> i32` (0 finite, +;; -1 nonfinite), and `render(w i32, h i32, max_iter i32) -> i64` writing w*h +;; RGBA8 row-major pixels at fb_ptr and returning total iterations. +;; `render_simd` (same 3 args) ships only in a sibling kernel_simd.wasm; the +;; zwasm interpreter cannot run SIMD, so the interp instance always uses the +;; scalar render. The view lives in the instance, so set_view + render is +;; atomic per instance: every instance is held behind a lock. +;; +;; Engines (the /tile `engine` parameter): +;; jit kernel.wasm on zwasm's JIT +;; jit-simd kernel_simd.wasm on the JIT (only when that file exists) +;; interp kernel.wasm on zwasm's interpreter +;; GET /engines answers which of these exist, e.g. {"engines":["jit","interp"]}. +;; +;; Binary bodies: cljw.http.server accepts a primitive byte-array :body and +;; copies it verbatim, so tiles and the wasm module itself cross as raw bytes +;; (a String cannot carry arbitrary binary: its producers normalise to UTF-8). + +(ns fractal.server + (:require [cljw.http.server :as http] + [clojure.java.io :as io])) + +(def dir (.getParent (io/file *file*))) +(def kernel-path (or (System/getenv "KERNEL") (str dir "/kernel.wasm"))) +(def simd-path (str dir "/kernel_simd.wasm")) + +(def port (if (seq *command-line-args*) + (Long/parseLong (first *command-line-args*)) + 8080)) + +;; --- one instance per engine, each behind its own lock ----------------------- +;; [handle lock]; the lock makes the set_view + render pair atomic per +;; request: the view is instance state and the accept loop may thread later. +(defn engine [path eng simd?] + [(wasm/load path {:engine eng :fuel 0}) + (Object.) + (if simd? "render_simd" "render")]) + +(def jit (engine kernel-path :jit false)) +(def interp (engine kernel-path :interp false)) +(def jit-simd + (when (.exists (io/file simd-path)) + (engine simd-path :jit true))) + +(def engines + (cond-> ["jit" "interp"] + (some? jit-simd) (conj "jit-simd"))) + +(def cap (.fb_cap (first jit))) + +;; --- file bytes --------------------------------------------------------------- +;; Read a whole file as a primitive byte-array. cljw's InputStream surface +;; today is the 1-arg .read (one byte per call, 0..255, -1 at EOF); there is +;; no readAllBytes / .read(buf) arm, so drain into a pre-sized array. +(defn file->bytes [path] + (let [size (.length (io/file path))] + (with-open [in (io/input-stream (io/file path))] + (let [arr (byte-array size)] + (dotimes [i size] + (let [b (.read in)] + (when (neg? b) + (throw (ex-info "file shrank under read" {:path path :at i}))) + (aset arr i (unchecked-byte b)))) + arr)))) + +(def kernel-bytes (file->bytes kernel-path)) + +;; --- query parsing ------------------------------------------------------------ +(defn parse-query [qs] + (if (nil? qs) + {} + (into {} + (keep (fn [pair] + (let [i (.indexOf pair "=")] + (when (pos? i) + [(subs pair 0 i) (subs pair (inc i))])))) + (.split qs "&")))) + +(defn num [m k d] + (let [v (get m k)] + (if (nil? v) d (Double/parseDouble v)))) + +(defn lng [m k d] + (let [v (get m k)] + (if (nil? v) d (Long/parseLong v)))) + +;; --- tile rendering ----------------------------------------------------------- +;; `eng` is an engine triple. set_view validates the view (0) before render; +;; the pair is locked per instance. The framebuffer is read back in one bulk +;; memcpy as a byte-array, which is also the response body verbatim. +(defn render-tile [eng cx cy scale w h iter] + (when (> (* w h) cap) + (throw (ex-info "tile exceeds framebuffer capacity" {:cap cap :got (* w h)}))) + (let [[inst lock f] eng] + (locking lock + (let [rc (.set_view inst cx cy scale)] + (when (neg? rc) + (throw (ex-info "set_view rejected a non-finite coordinate" {:cx cx :cy cy :scale scale})))) + (let [t0 (System/nanoTime) + iters (if (= f "render_simd") + (.render_simd inst w h iter) + (.render inst w h iter)) + ms (/ (double (- (System/nanoTime) t0)) 1.0e6) + arr (wasm/mem-read-array inst :u8 (.fb_ptr inst) (* w h 4))] + {:ms ms :iters iters :bytes arr})))) + +;; --- handler ------------------------------------------------------------------ +(defn handler [req] + (case (:uri req) + "/" {:status 200 + :headers {"content-type" "text/html; charset=utf-8"} + :body (slurp (str dir "/index.html"))} + + "/kernel.wasm" {:status 200 + :headers {"content-type" "application/wasm" + "cache-control" "no-cache"} + :body kernel-bytes} + + "/engines" {:status 200 + :headers {"content-type" "application/json"} + :body (str "{\"engines\":[" (clojure.string/join "," (map #(str "\"" % "\"") engines)) "]}")} + + "/tile" + (try + (let [q (parse-query (:query-string req)) + cx (num q "cx" -0.6) + cy (num q "cy" 0.0) + scale (num q "scale" 0.004) + w (lng q "w" 640) + h (lng q "h" 480) + iter (lng q "iter" 256) + eng (case (get q "engine" "jit") + "jit" jit + "jit-simd" jit-simd + "interp" interp + nil)] + (if (nil? eng) + {:status 400 :body (str "engine must be one of " engines)} + (let [{:keys [ms iters bytes]} (render-tile eng cx cy scale w h iter)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body bytes}))) + (catch Throwable e + {:status 400 :body (str "tile error: " (.getMessage e))})) + + {:status 404 :body "not found\n"})) + +(println (str "fractal server on http://localhost:" port + " kernel=" kernel-path + " engines=" engines)) +(http/run-server handler {:port port}) From 72804785fa2e68ec1a35361d42e9ab5e6b1b0979 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 20:34:50 -0300 Subject: [PATCH 39/51] wip(examples): fractal demo in C, Rust, raster and CUDA; re-frame frontend Checkpoint of the multi-engine fractal demo, before the stratification and test passes land: - kernel.c / kernel.rs: same ABI as kernel.zig, byte-identical output. - mandel.clj: plain Clojure renderer (cljw-clj engine), bench parity. - golden.edn: the golden views every engine must reproduce. - jvm/: Tile domain, IRenderer port, raster (threads, SIMD) and CUDA (NVRTC to CUBIN, fp64 no-FMA and fp32) adapters. - web/: re-frame + reagent frontend (shadow-cljs), hive-cljs manifest. --- docs/examples/fractal/bench.clj | 97 +++-- docs/examples/fractal/golden.edn | 13 + docs/examples/fractal/hive-cljs.edn | 97 +++++ docs/examples/fractal/index.html | 395 ------------------ docs/examples/fractal/jvm/.gitignore | 2 + docs/examples/fractal/jvm/deps.edn | 22 + .../fractal/jvm/resources/fractal/escape.cu | 31 ++ .../fractal/jvm/src/fractal/engines.clj | 21 + .../fractal/jvm/src/fractal/golden.clj | 24 ++ .../fractal/jvm/src/fractal/palette.clj | 43 ++ .../fractal/jvm/src/fractal/renderer.clj | 44 ++ .../fractal/jvm/src/fractal/renderer/cuda.clj | 93 +++++ .../jvm/src/fractal/renderer/oracle.clj | 35 ++ .../jvm/src/fractal/renderer/raster.clj | 55 +++ .../jvm/src/fractal/renderer/raster_simd.clj | 71 ++++ .../examples/fractal/jvm/src/fractal/tile.clj | 57 +++ docs/examples/fractal/kernel.c | 92 ++++ docs/examples/fractal/kernel.rs | 118 ++++++ docs/examples/fractal/kernel_c.wasm | Bin 0 -> 1565 bytes docs/examples/fractal/kernel_rust.wasm | Bin 0 -> 4741 bytes docs/examples/fractal/mandel.clj | 51 +++ docs/examples/fractal/server.clj | 226 +++++----- docs/examples/fractal/web/.gitignore | 4 + docs/examples/fractal/web/package-lock.json | 220 ++++++++++ docs/examples/fractal/web/package.json | 15 + docs/examples/fractal/web/public/index.html | 61 +++ docs/examples/fractal/web/shadow-cljs.edn | 14 + .../fractal/web/src/fractal/core.cljs | 66 +++ .../fractal/web/src/fractal/engines.cljs | 137 ++++++ .../fractal/web/src/fractal/events.cljs | 188 +++++++++ docs/examples/fractal/web/src/fractal/ui.cljs | 217 ++++++++++ .../fractal/web/src/fractal/view.cljs | 70 ++++ 32 files changed, 2051 insertions(+), 528 deletions(-) create mode 100644 docs/examples/fractal/golden.edn create mode 100644 docs/examples/fractal/hive-cljs.edn delete mode 100644 docs/examples/fractal/index.html create mode 100644 docs/examples/fractal/jvm/.gitignore create mode 100644 docs/examples/fractal/jvm/deps.edn create mode 100644 docs/examples/fractal/jvm/resources/fractal/escape.cu create mode 100644 docs/examples/fractal/jvm/src/fractal/engines.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/golden.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/palette.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer/raster.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/tile.clj create mode 100644 docs/examples/fractal/kernel.c create mode 100644 docs/examples/fractal/kernel.rs create mode 100755 docs/examples/fractal/kernel_c.wasm create mode 100755 docs/examples/fractal/kernel_rust.wasm create mode 100644 docs/examples/fractal/mandel.clj create mode 100644 docs/examples/fractal/web/.gitignore create mode 100644 docs/examples/fractal/web/package-lock.json create mode 100644 docs/examples/fractal/web/package.json create mode 100644 docs/examples/fractal/web/public/index.html create mode 100644 docs/examples/fractal/web/shadow-cljs.edn create mode 100644 docs/examples/fractal/web/src/fractal/core.cljs create mode 100644 docs/examples/fractal/web/src/fractal/engines.cljs create mode 100644 docs/examples/fractal/web/src/fractal/events.cljs create mode 100644 docs/examples/fractal/web/src/fractal/ui.cljs create mode 100644 docs/examples/fractal/web/src/fractal/view.cljs diff --git a/docs/examples/fractal/bench.clj b/docs/examples/fractal/bench.clj index f12257781..77f2984a0 100644 --- a/docs/examples/fractal/bench.clj +++ b/docs/examples/fractal/bench.clj @@ -1,9 +1,17 @@ ;; Run from the repository root: zig-out/bin/cljw docs/examples/fractal/bench.clj -;; Render timing excludes framebuffer transfer, checksum and PPM encoding. +;; Every kernel (Zig, Zig simd128, C, Rust) on every zwasm runtime, plus the +;; pure Clojure raster (raster.clj, no wasm). Render timing excludes the +;; framebuffer transfer, checksum and PPM encoding. +;; Parity asserted: iteration totals are equal across ALL engines; checksums are +;; equal across runtimes and exports of one kernel; the Zig scalar and simd128 +;; modules agree byte for byte, and so do C and Rust (they share one log2). ;; CLJW_FRACTAL_CI=1 selects small correctness cases for the e2e gate. +;; RASTER=0 skips the pure Clojure raster. (ns fractal.bench (:require [clojure.java.io :as io])) (def root "docs/examples/fractal/") +(load-file (str root "mandel.clj")) + (def views (if (= "1" (System/getenv "CLJW_FRACTAL_CI")) [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 32) :w 32 :h 32 :iter 64} @@ -13,6 +21,13 @@ {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000} {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}])) +;; Byte-identity groups: kernels in one group must produce identical pixels. +(def kernels + [{:id "zig" :file "kernel.wasm" :group "zig" :runtimes [:jit :interp] :exports ["render" "render_simd"]} + {:id "zig-simd" :file "kernel_simd.wasm" :group "zig" :runtimes [:jit] :exports ["render" "render_simd"]} + {:id "c" :file "kernel_c.wasm" :group "c+rust" :runtimes [:jit :interp] :exports ["render"]} + {:id "rust" :file "kernel_rust.wasm" :group "c+rust" :runtimes [:jit :interp] :exports ["render"]}]) + (defn checksum [pixels] (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels)) @@ -25,41 +40,61 @@ (bit-and 255 (aget pixels (+ (* i 4) 1))) " " (bit-and 255 (aget pixels (+ (* i 4) 2))) "\n"))))) -(def references (atom {})) -(defn run-frame! [module engine export view] - (let [{:keys [name cx cy scale w h iter]} view +(def results (atom [])) +(def iteration-ref (atom {})) +(def checksum-ref (atom {})) + +(defn record! [engine group view iterations ms pixels] + (let [{:keys [name w h]} view + sum (checksum pixels)] + (println (format "%-26s %-9s %10.2f ms %8.2f Miter/s iters %d checksum %d" + engine name ms (/ iterations (* ms 1000.0)) iterations sum)) + (swap! results conj {:engine engine :view name :ms ms :iters iterations}) + (if-let [ref (get @iteration-ref name)] + (assert (= ref iterations) (str "iteration mismatch: " engine " " name)) + (swap! iteration-ref assoc name iterations)) + (when group + (if-let [ref (get @checksum-ref [group name])] + (assert (= ref sum) (str "checksum mismatch in group " group ": " engine " " name)) + (swap! checksum-ref assoc [group name] sum))) + (when (and (= engine "zig-interp/render") (= name "full")) + (ppm! pixels w h)))) + +(defn run-wasm! [module engine group export view] + (let [{:keys [cx cy scale w h iter]} view _ (assert (= 0 (.set_view module cx cy scale))) start (System/nanoTime) iterations (wasm/call module export w h iter) ms (/ (- (System/nanoTime) start) 1.0e6) - pixels (wasm/mem-read-array module :u8 (.fb_ptr module) (* w h 4)) - sum (checksum pixels) - result [iterations sum]] - (println engine export name "ms" ms "Miter/s" (/ iterations (* ms 1000.0)) - "iterations" iterations "checksum" sum) - (if-let [reference (get @references name)] - (assert (= reference result) (str "parity mismatch: " engine " " export " " name)) - (swap! references assoc name result)) - (when (and (= engine :interp) (= export "render") (= name "full")) - (ppm! pixels w h)))) + pixels (wasm/mem-read-array module :u8 (.fb_ptr module) (* w h 4))] + (record! engine group view iterations ms pixels))) -(doseq [engine [:jit :interp]] - (let [module (wasm/load (str root "kernel.wasm") {:engine engine :fuel 0})] - (let [exports (wasm/exports module)] - (println "exports" engine exports) - (doseq [export ["render" "render_simd"]] - (let [signature (first (filter #(= export (:name %)) exports))] - (assert (= [:i32 :i32 :i32] (:params signature))) - (assert (= [:i64] (:results signature)))))) +(doseq [{:keys [id file group runtimes exports]} kernels + runtime runtimes] + (let [module (wasm/load (str root file) {:engine runtime :fuel 0}) + sigs (wasm/exports module)] + (doseq [export exports] + (let [signature (first (filter #(= export (:name %)) sigs))] + (assert (= [:i32 :i32 :i32] (:params signature)) (str id " " export " params")) + (assert (= [:i64] (:results signature)) (str id " " export " results")))) (assert (= 1048576 (.fb_cap module))) - (doseq [export ["render" "render_simd"] view views] - (run-frame! module engine export view)))) -(println "PASS scalar artifact: strict engine and vector-source parity") + (doseq [export exports view views] + (run-wasm! module (str id "-" (name runtime) "/" export) group export view)))) +(println "PASS wasm kernels: iteration parity across kernels, byte parity within groups") + +(when-not (= "0" (System/getenv "RASTER")) + (doseq [view views] + (let [{:keys [cx cy scale w h iter]} view + fb (byte-array (* w h 4)) + start (System/nanoTime) + iterations (fractal.mandel/render fb cx cy scale w h iter) + ms (/ (- (System/nanoTime) start) 1.0e6)] + (record! "cljw-clj" nil view iterations ms fb))) + (println "PASS cljw-raster: iteration parity with the wasm kernels")) -;; SIMD instructions are JIT-only in zwasm. Keep this artifact separate so the -;; scalar module remains usable by the interpreter and browsers without SIMD. -(let [module (wasm/load (str root "kernel_simd.wasm") {:engine :jit :fuel 0})] - (doseq [export ["render" "render_simd"] view views] - (run-frame! module :jit-simd export view)) - (println "PASS simd128 JIT strict parity")) +(println) +(println "| engine | view | ms | Miter/s |") +(println "|---|---|---:|---:|") +(doseq [{:keys [engine view ms iters]} @results] + (println (format "| %s | %s | %.2f | %.2f |" engine view ms (/ iters (* ms 1000.0))))) (println "Wrote docs/examples/fractal/out.ppm") diff --git a/docs/examples/fractal/golden.edn b/docs/examples/fractal/golden.edn new file mode 100644 index 000000000..33393a08e --- /dev/null +++ b/docs/examples/fractal/golden.edn @@ -0,0 +1,13 @@ +;; Golden views of the fractal demo: the one source of truth for every engine. +;; :iters is the total escape count; :checksum folds the RGBA8 framebuffer as +;; (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels). +;; Measured once on cljw (Zig, C, Rust wasm on zwasm jit and interp, plain +;; Clojure); every other engine must reproduce them exactly (fp64, no FMA). +;; :ci marks the small views the e2e gate runs. +{:views + [{:name "full" :cx -0.5 :cy 0.0 :scale 0.0068359375 :w 512 :h 512 :iter 256 + :iters 9284549 :checksum 1665484798} + {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000 + :iters 2152075 :checksum 2115401538} + {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80 + :iters 15979 :checksum 59081420 :ci true}]} diff --git a/docs/examples/fractal/hive-cljs.edn b/docs/examples/fractal/hive-cljs.edn new file mode 100644 index 000000000..cb2bb5b9d --- /dev/null +++ b/docs/examples/fractal/hive-cljs.edn @@ -0,0 +1,97 @@ +;; hive-cljs manifest for the fractal demo page. +;; +;; The page under test is what the cljw server serves: web/public with the +;; shadow-cljs build in web/public/js. Start the servers first; the suite does +;; not start them: +;; +;; (cd web && npx shadow-cljs release app) +;; zig-out/bin/cljw docs/examples/fractal/server.clj 8080 +;; (cd raster && clojure -M:serve 8081) ; only for :raster scenarios +;; code {command: "cljs e2e run", directory: "", tags: "fractal"} +;; +;; Scenarios talk to the page through window.fractal (fractal.core/test-surface) +;; and real DOM input events, so they exercise the re-frame events the UI uses. +{:project-id "ClojureWasm" + :hive.cljs/toolchain :browser + :hive.cljs/builds {:app {:http-port 8080}} + + :hive.cljs/e2e + {:base-url "http://localhost:8080" + :browser :chromium + :headless true + :timeout-ms 60000 + :scenarios + [{:id :boot + :tags [:fractal :smoke] + :doc "The page boots, lists every kernel in the browser and on the cljw + server, and paints a first frame." + :steps [[:goto "/"] + [:wait-for "#view"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "['browser-zig','browser-c','browser-rust','browser-js','server-zig-jit','server-zig-interp','server-c-jit','server-rust-jit','server-cljw-clj'].every(function(e){return window.fractal.engines().indexOf(e)>=0;})"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractal.panes();if(p.L&&p.L.iters>0){clearInterval(t);ok(true);}},100);})"]]} + + {:id :parity + :tags [:fractal :parity] + :doc "One view rendered on every engine the page knows gives the same + iteration total and the same framebuffer checksum: Zig, C and Rust + wasm in the browser and on cljw's zwasm, plain JS, plain Clojure on + cljw, and raster on the JVM when its server is up." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "Promise.all(window.fractal.engines().map(function(e){return window.fractal.render(e,-0.743643887037151,0.13182590420533,1e-6,48,32,400);})).then(function(rs){return rs.length>8&&rs.every(function(r){return r.iters===rs[0].iters&&r.checksum===rs[0].checksum&&r.iters>0;});})"]]} + + {:id :zoom + :tags [:fractal :interaction] + :doc "The + button doubles the zoom around the centre, and a wheel event + over the right half of the canvas zooms in at the cursor." + :steps [[:goto "/#cx=-0.6&cy=0&scale=0.00375&iter=256"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:click "#zin"] + [:wait-ms 200] + [:expect-js "Math.abs(window.fractal.view().scale-0.001875)<1e-12 && Math.abs(window.fractal.view().cx+0.6)<1e-12"] + [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect(),s=window.fractal.view().scale;c.dispatchEvent(new WheelEvent('wheel',{deltaY:-300,clientX:r.left+r.width*0.75,clientY:r.top+r.height/2,bubbles:true,cancelable:true}));return new Promise(function(ok){setTimeout(function(){var v=window.fractal.view();ok(v.scale-0.6);},200);});})()"]]} + + {:id :pan + :tags [:fractal :interaction] + :doc "Arrow keys and WASD slide x and y by a tenth of the canvas; a drag + slides the plane with the pointer." + :steps [[:goto "/#cx=0&cy=0&scale=0.01&iter=128"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "(function(){var w=document.getElementById('view').width;window.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight'}));window.dispatchEvent(new KeyboardEvent('keydown',{key:'s'}));return new Promise(function(ok){setTimeout(function(){var v=window.fractal.view();ok(Math.abs(v.cx-w/10*0.01)<1e-9&&Math.abs(v.cy-w/10*0.01)<1e-9);},200);});})()"] + [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect(),x=r.left+100,y=r.top+100,v0=window.fractal.view();function ev(t,dx){c.dispatchEvent(new PointerEvent(t,{pointerId:7,clientX:x+dx,clientY:y,bubbles:true}));}ev('pointerdown',0);ev('pointermove',50);ev('pointerup',50);return new Promise(function(ok){setTimeout(function(){ok(window.fractal.view().cx0&&p.L.engine!==p.R.engine){clearInterval(t);ok(true);}},100);})"] + [:expect-js "getComputedStyle(document.getElementById('divider')).display==='block'"]]} + + {:id :bench + :tags [:fractal :bench] + :doc "The bench renders the view list on every engine and reports parity." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "window.fractal.bench({w:64,h:48}).then(function(r){return r.parity===true && r.rows.length===4*window.fractal.engines().length && r.rows.every(function(x){return !x.error;});})"] + [:wait-for "#benchtab"]]} + + {:id :raster + :tags [:fractal :raster] + :doc "With the raster server up (raster/, port 8081) the page lists + raster-jvm, and it renders the same pixels as the zig kernel." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "window.fractal.engines().indexOf('raster-jvm')>=0"] + [:expect-js "Promise.all(['raster-jvm','browser-zig'].map(function(e){return window.fractal.render(e,-0.6,0,0.01,64,48,256);})).then(function(rs){return rs[0].iters===rs[1].iters&&rs[0].checksum===rs[1].checksum;})"]]}]}} diff --git a/docs/examples/fractal/index.html b/docs/examples/fractal/index.html deleted file mode 100644 index c70fc4207..000000000 --- a/docs/examples/fractal/index.html +++ /dev/null @@ -1,395 +0,0 @@ - - - - - -cljw fractal zoom: one kernel.wasm, three engines - - - -
-

fractal zoom

-
- - -
-
- -
- - -
-
- -
-
-
-
- - - -
enginems/frameMiter/sframes
-
▬ browser - ▬ server-jit - ▬ server-interp  (rolling ms/frame)
- -
-
Bench renders a fixed view list on every engine and reports ms and Miter/s.
-
- - - diff --git a/docs/examples/fractal/jvm/.gitignore b/docs/examples/fractal/jvm/.gitignore new file mode 100644 index 000000000..30ebdfd63 --- /dev/null +++ b/docs/examples/fractal/jvm/.gitignore @@ -0,0 +1,2 @@ +local.deps.edn +.cpcache/ diff --git a/docs/examples/fractal/jvm/deps.edn b/docs/examples/fractal/jvm/deps.edn new file mode 100644 index 000000000..7f479538c --- /dev/null +++ b/docs/examples/fractal/jvm/deps.edn @@ -0,0 +1,22 @@ +;; The JVM engines of the fractal demo: replikativ/raster (CPU, threads and +;; SIMD) and CUDA through uncomplicate/clojurecuda (GPU). No cljw involved. +;; clojure -M:serve [port] tile server for the page (default 8081) +;; clojure -M:bench golden-view benchmark of every available engine +;; clojure -M:test golden, property and mutation tests +;; --enable-preview lets released raster define its value classes on JDK 27 +;; (fixed upstream in replikativ/raster#1574). A gitignored local.deps.edn may +;; point at local checkouts of raster and clojure-cpp. +{:paths ["src" "resources"] + :deps {org.replikativ/raster {:mvn/version "0.2.1720"} + uncomplicate/clojurecuda {:mvn/version "0.33.0"}} + :aliases + {:jvm {:jvm-opts ["--enable-preview" + "--add-modules=jdk.incubator.vector" + "--enable-native-access=ALL-UNNAMED" + "-Xss8m" "-XX:ReservedCodeCacheSize=512m"]} + :serve {:main-opts ["-m" "fractal.main"]} + :bench {:main-opts ["-m" "fractal.bench"]} + :test {:extra-paths ["test"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} + io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} + :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/docs/examples/fractal/jvm/resources/fractal/escape.cu b/docs/examples/fractal/jvm/resources/fractal/escape.cu new file mode 100644 index 000000000..abc671c5b --- /dev/null +++ b/docs/examples/fractal/jvm/resources/fractal/escape.cu @@ -0,0 +1,31 @@ +// Escape-time kernels for fractal.renderer.cuda, compiled at run time by NVRTC. +// One thread per pixel. Same operation order as fractal.renderer.oracle/escape; +// compiled with --fmad=false so the fp64 kernel rounds exactly like the JVM. +// Writes the EscapeField only (count and |z|^2); the palette runs on the host. + +#define ESCAPE_BODY(REAL) \ + int x = blockIdx.x * blockDim.x + threadIdx.x; \ + int y = blockIdx.y * blockDim.y + threadIdx.y; \ + if (x >= w || y >= h) return; \ + REAL cr = (REAL)cx + ((REAL)x - (REAL)w / (REAL)2.0) * (REAL)scale; \ + REAL ci = (REAL)cy + ((REAL)y - (REAL)h / (REAL)2.0) * (REAL)scale; \ + REAL zr = 0, zi = 0; \ + int n = 0; \ + while (n < limit && zr * zr + zi * zi <= (REAL)4.0) { \ + REAL next = zr * zr - zi * zi + cr; \ + zi = ((REAL)2.0 * zr) * zi + ci; \ + zr = next; \ + n++; \ + } \ + counts[y * w + x] = n; \ + mag2[y * w + x] = (double)(zr * zr + zi * zi); + +extern "C" __global__ void escape_f64(int* counts, double* mag2, int w, int h, + double cx, double cy, double scale, int limit) { + ESCAPE_BODY(double) +} + +extern "C" __global__ void escape_f32(int* counts, double* mag2, int w, int h, + double cx, double cy, double scale, int limit) { + ESCAPE_BODY(float) +} diff --git a/docs/examples/fractal/jvm/src/fractal/engines.clj b/docs/examples/fractal/jvm/src/fractal/engines.clj new file mode 100644 index 000000000..400f0481d --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/engines.clj @@ -0,0 +1,21 @@ +(ns fractal.engines + "Collect: which renderers this JVM can run. Each adapter namespace is + required lazily, so a missing native library (no GPU, no CUDA) costs that + renderer only and is reported as data." + (:require [fractal.renderer :as r])) + +(def constructors + "[id qualified-constructor] for every known renderer, in display order." + [["raster-jvm" 'fractal.renderer.raster/all-cores] + ["raster-jvm-1t" 'fractal.renderer.raster/one-thread] + ["raster-simd" 'fractal.renderer.raster-simd/renderer] + ["cuda-f64" 'fractal.renderer.cuda/f64] + ["cuda-f32" 'fractal.renderer.cuda/f32] + ["clojure-jvm" 'fractal.renderer.oracle/renderer]]) + +(defn discover + "{:renderers [IRenderer ...] :unavailable [{:id :reason} ...]}" + ([] (discover constructors)) + ([ctors] + (r/available (for [[rid qsym] ctors] + [rid #((requiring-resolve qsym))])))) diff --git a/docs/examples/fractal/jvm/src/fractal/golden.clj b/docs/examples/fractal/jvm/src/fractal/golden.clj new file mode 100644 index 000000000..70f7c98a2 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/golden.clj @@ -0,0 +1,24 @@ +(ns fractal.golden + "The golden views (../golden.edn), shared with the cljw bench: Collect reads + them, the rest is pure." + (:require [clojure.edn :as edn] + [clojure.java.io :as io] + [fractal.tile :as tile])) + +(def default-path "../golden.edn") + +(defn load-views + "The golden views; path defaults to ../golden.edn, or FRACTAL_GOLDEN." + ([] (load-views (or (System/getenv "FRACTAL_GOLDEN") default-path))) + ([path] (:views (edn/read-string (slurp (io/file path)))))) + +(defn view->tile [view] (tile/tile view)) + +(defn verdict + "Compare a rendered frame with its golden view: nil when equal, else a map + naming what differs." + [{:keys [iters checksum name]} frame-iters frame-checksum] + (when-not (and (= iters frame-iters) (= checksum frame-checksum)) + {:view name + :expected {:iters iters :checksum checksum} + :actual {:iters frame-iters :checksum frame-checksum}})) diff --git a/docs/examples/fractal/jvm/src/fractal/palette.clj b/docs/examples/fractal/jvm/src/fractal/palette.clj new file mode 100644 index 000000000..92cbd4635 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/palette.clj @@ -0,0 +1,43 @@ +(ns fractal.palette + "The one colouring rule: an escape count and |z|^2 become an RGBA8 pixel. + Interior points (count = limit) are opaque black; the rest follow the smooth + escape count through a periodic triangular wave per channel. Byte for byte + the rule of kernel.zig, kernel.c, kernel.rs and mandel.clj." + (:require [fractal.tile :as tile])) + +(def ^:private ^:const inv-ln2 1.4426950408889634) + +(defn- channel ^long [^double phase ^long c] + (let [t (+ phase (/ (double c) 3.0)) + wave (- 1.0 (Math/abs (- (* 2.0 (- t (Math/floor t))) 1.0)))] + (long (* wave 255.0)))) + +(defn rgba + "[r g b a] of one pixel." + [^long n ^long limit ^double mag2] + (if (== n limit) + [0 0 0 255] + (let [bounded (if (Double/isFinite mag2) (Math/max mag2 4.000001) 1.0e300) + l2 (* (Math/log (* 0.5 (* (Math/log bounded) inv-ln2))) inv-ln2) + phase (* 0.025 (- (+ (double n) 1.0) l2))] + [(channel phase 0) (channel phase 1) (channel phase 2) 255]))) + +(defn paint + "RGBA8 bytes, row-major, for an EscapeField of tile t." + ^bytes [{:keys [^ints counts ^doubles mag2]} t] + (let [n (tile/pixels t) + limit (long (:iter t)) + out (byte-array (* 4 n))] + (dotimes [p n] + (let [[r g b a] (rgba (aget counts p) limit (aget mag2 p)) + o (* 4 p)] + (aset out o (unchecked-byte r)) + (aset out (+ o 1) (unchecked-byte g)) + (aset out (+ o 2) (unchecked-byte b)) + (aset out (+ o 3) (unchecked-byte a)))) + out)) + +(defn checksum + "The golden checksum of an RGBA8 framebuffer." + ^long [^bytes pixels] + (areduce pixels i acc 1 (mod (+ (* acc 31) (bit-and 255 (aget pixels i))) 2147483647))) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer.clj b/docs/examples/fractal/jvm/src/fractal/renderer.clj new file mode 100644 index 000000000..3bd427a63 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer.clj @@ -0,0 +1,44 @@ +(ns fractal.renderer + "The renderer port. Renderers are an open set (CPU threads, SIMD lanes, GPU, + and whatever comes next), so each one is an IRenderer value, discovered at + start-up into a registry. A renderer computes the EscapeField only; the + palette is applied once, above the port, so every renderer that computes the + same escape field paints the same bytes." + (:require [fractal.palette :as palette] + [fractal.tile :as tile])) + +(defprotocol IRenderer + (id [r] "Engine name as the page and the bench show it.") + (describe [r] "{:parity? bool :what str}: :parity? false for renderers whose + arithmetic differs (fp32), excluded from golden checks.") + (escape! [r t field] "Fill the EscapeField `field` for Tile t.")) + +(defn frame + "Render Tile t on r: {:iters :ms :rgba}. :ms covers the escape computation + only; painting is shared work and timed apart as :paint-ms." + [r t] + (let [field (tile/escape-field t) + t0 (System/nanoTime) + _ (escape! r t field) + t1 (System/nanoTime) + rgba (palette/paint field t) + t2 (System/nanoTime)] + {:iters (tile/total-iters field) + :ms (/ (- t1 t0) 1.0e6) + :paint-ms (/ (- t2 t1) 1.0e6) + :rgba rgba})) + +;; Availability is data: a renderer that cannot start (no GPU, missing native +;; library) is an {:id :unavailable reason} row, not an exception at start-up. +(defn available + "Run each [id constructor] and split the results." + [constructors] + (reduce (fn [acc [rid ctor]] + (try (update acc :renderers conj (ctor)) + (catch Throwable e + (update acc :unavailable conj + {:id rid :reason (str (.getSimpleName (class e)) ": " (.getMessage e))})))) + {:renderers [] :unavailable []} + constructors)) + +(defn registry [renderers] (into (array-map) (map (juxt id identity)) renderers)) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj new file mode 100644 index 000000000..edd6fb342 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj @@ -0,0 +1,93 @@ +(ns fractal.renderer.cuda + "CUDA renderers through uncomplicate/clojurecuda: one GPU thread per pixel. + escape.cu is compiled at start-up by NVRTC straight to a CUBIN for this GPU + (SASS, no PTX JIT, so a toolkit newer than the driver still loads). A CUDA + context is current per thread, so the adapter owns ONE worker thread that + creates the context and runs every GPU call; callers never see CUDA thread + affinity. fp64 with --fmad=false rounds like the JVM (golden parity); fp32 + trades exactness for the GPU's single-precision throughput." + (:require [clojure.java.io :as io] + [uncomplicate.clojurecuda.core :as cu] + [uncomplicate.clojure-cpp :as cpp] + [uncomplicate.commons.core :as cc] + [fractal.renderer :as r] + [fractal.tile :as tile]) + (:import (java.util.concurrent Callable ExecutorService Executors ThreadFactory) + (org.bytedeco.cuda.global nvrtc) + (org.bytedeco.cuda.nvrtc _nvrtcProgram) + (org.bytedeco.javacpp BytePointer DoublePointer IntPointer SizeTPointer))) + +;; --- Boundary: the GPU worker ------------------------------------------------- +(defn- on-gpu + "Run f on the worker thread and return its value, rethrowing its exception." + [^ExecutorService worker f] + (.get (.submit worker ^Callable f))) + +(defn- cubin + "The compiled CUBIN of an NVRTC program, as bytes." + ^bytes [prog] + (let [size (SizeTPointer. 1)] + (nvrtc/nvrtcGetCUBINSize ^_nvrtcProgram prog size) + (let [n (.get size 0) + bp (BytePointer. n) + out (byte-array n)] + (nvrtc/nvrtcGetCUBIN ^_nvrtcProgram prog bp) + (.get bp out) + out))) + +(defn- sm-arch + "-arch=sm_XY for device dev, from its compute capability." + [dev] + (let [{:keys [compute-capability-major compute-capability-minor]} + (cc/info dev [:compute-capability-major :compute-capability-minor])] + (str "-arch=sm_" compute-capability-major compute-capability-minor))) + +(defn- open-gpu + "On the worker: context, compiled module and kernel functions." + [] + (cu/init) + (let [dev (cu/device 0) + ctx (cu/context dev)] + (cu/current-context! ctx) + (let [prog (cu/compile! (cu/program (slurp (io/resource "fractal/escape.cu"))) + ["--fmad=false" (sm-arch dev)]) + m (cu/module (cubin prog))] + {:ctx ctx + :device (str (cc/info dev :name)) + :kernels {:f64 (cu/function m "escape_f64") :f32 (cu/function m "escape_f32")}}))) + +(defn- escape-on-gpu! + "On the worker: launch kernel for tile t and copy the field back." + [kernel {:keys [cx cy scale w h iter] :as t} {:keys [^ints counts ^doubles mag2]}] + (let [n (tile/pixels t)] + (cc/with-release [d-counts (cu/mem-alloc-driver (* 4 n)) + d-mag2 (cu/mem-alloc-driver (* 8 n)) + h-counts (cpp/int-pointer n) + h-mag2 (cpp/double-pointer n)] + (cu/launch! kernel (cu/grid-2d w h 16 16) + (cu/parameters d-counts d-mag2 (int w) (int h) + (double cx) (double cy) (double scale) (int iter))) + (cu/synchronize!) + (cu/memcpy-host! d-counts h-counts) + (cu/memcpy-host! d-mag2 h-mag2) + (.get ^IntPointer h-counts counts) + (.get ^DoublePointer h-mag2 mag2)))) + +;; --- the renderers ---------------------------------------------------------------- +(defrecord Cuda [rid precision gpu worker] + r/IRenderer + (id [_] rid) + (describe [_] {:parity? (= precision :f64) + :what (str "CUDA " (name precision) " on " (:device gpu))}) + (escape! [_ t field] + (on-gpu worker #(escape-on-gpu! (get-in gpu [:kernels precision]) t field)))) + +(defonce ^:private shared (delay + (let [worker (Executors/newSingleThreadExecutor + (reify ThreadFactory + (newThread [_ run] + (doto (Thread. run "fractal-cuda") (.setDaemon true)))))] + {:worker worker :gpu (on-gpu worker open-gpu)}))) + +(defn f64 [] (let [{:keys [gpu worker]} @shared] (->Cuda "cuda-f64" :f64 gpu worker))) +(defn f32 [] (let [{:keys [gpu worker]} @shared] (->Cuda "cuda-f32" :f32 gpu worker))) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj new file mode 100644 index 000000000..b844f9b9b --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj @@ -0,0 +1,35 @@ +(ns fractal.renderer.oracle + "The reference renderer: the escape-time definition in plain Clojure, one + pixel at a time, no library. Every other renderer is tested against it." + (:require [fractal.renderer :as r] + [fractal.tile :as tile])) + +(defn escape + "[n mag2] for the point (cr, ci): iterate z <- z^2 + c from 0 while + n < limit and |z|^2 <= 4. The operation order is the kernels' exactly." + [cr ci limit] + (loop [n 0 zr 0.0 zi 0.0] + (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) + (recur (inc n) (+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)) + [n (+ (* zr zr) (* zi zi))]))) + +(defn fill! + "Fill field for tile t with escape; returns field." + [t {:keys [^ints counts ^doubles mag2] :as field}] + (let [{:keys [w h iter]} t] + (dotimes [y h] + (dotimes [x w] + (let [[cr ci] (tile/point t x y) + [n m] (escape cr ci iter) + p (+ (* y w) x)] + (aset counts p (int n)) + (aset mag2 p (double m))))) + field)) + +(defrecord Oracle [] + r/IRenderer + (id [_] "clojure-jvm") + (describe [_] {:parity? true :what "plain Clojure on the JVM, one thread, the reference"}) + (escape! [_ t field] (fill! t field))) + +(defn renderer [] (->Oracle)) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj new file mode 100644 index 000000000..c6d18228d --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj @@ -0,0 +1,55 @@ +(ns fractal.renderer.raster + "replikativ/raster renderers. The escape loop is one deftm, compiled to + primitive JVM bytecode; rows are split into blocks across a ForkJoin pool by + raster's parallel-for!. A per-pixel escape loop has a data-dependent exit, + which raster does not vectorize, so the lanes renderer lives apart + (fractal.renderer.raster-simd)." + (:refer-clojure :exclude [aget aset alength]) + (:require [raster.core :refer [deftm]] + [raster.arrays :refer [aget aset]] + [raster.par.runtime :as rt] + [fractal.renderer :as r])) + +(deftm escape-rows! + [counts :- (Array int), mag2 :- (Array double), y0 :- Long, y1 :- Long, w :- Long, h :- Long, + cx :- Double, cy :- Double, scale :- Double, limit :- Long] :- Long + (let [hw (/ (double w) 2.0) + hh (/ (double h) 2.0)] + (loop [y y0] + (if (< y y1) + (let [ci (+ cy (* (- (double y) hh) scale))] + (loop [x 0] + (when (< x w) + (let [cr (+ cx (* (- (double x) hw) scale)) + p (+ (* y w) x)] + (loop [n 0 zr 0.0 zi 0.0] + (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) + (recur (inc n) (+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)) + (do (aset counts p (unchecked-int n)) + (aset mag2 p (+ (* zr zr) (* zi zi)))))) + (recur (inc x))))) + (recur (inc y))) + y1)))) + +(def ^:private row-block 4) + +(defn- escape-tile! [threads {:keys [cx cy scale w h iter]} {:keys [counts mag2]}] + (let [blocks (long (Math/ceil (/ (double h) row-block))) + work (fn [lo hi] + (doseq [b (range lo hi)] + (let [y0 (* row-block b)] + (escape-rows! counts mag2 y0 (min h (+ y0 row-block)) w h cx cy scale iter))))] + (binding [rt/*par-min-size* 1 + rt/*par-threads* threads] + (rt/parallel-for! blocks work)))) + +(defrecord Raster [rid threads] + r/IRenderer + (id [_] rid) + (describe [_] {:parity? true + :what (str "raster deftm escape loop, " + (if threads (str threads " thread") "all cores"))}) + (escape! [_ t field] (escape-tile! threads t field))) + +(defn all-cores [] (->Raster "raster-jvm" nil)) +(defn one-thread [] (->Raster "raster-jvm-1t" 1)) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj b/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj new file mode 100644 index 000000000..8ab5e62a6 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj @@ -0,0 +1,71 @@ +(ns fractal.renderer.raster-simd + "Data-parallel escape on raster's SIMD path. raster vectorizes par/map + bodies (straight-line arithmetic, if as compare+blend) onto + jdk.incubator.vector lanes, but cannot vectorize a per-pixel exit. So every + pass steps ALL pixels once and freezes an escaped pixel with a blend; + `limit` passes later the counts equal the escape loop's. Exact, vectorized, + and slower than the escape loop: it does w*h*limit steps where the escape + loop does sum(counts)." + (:refer-clojure :exclude [aget aset alength]) + (:require [raster.core :refer [deftm]] + [raster.arrays :refer [aget alength]] + [raster.par :as par] + [raster.compiler.pipeline :as pipeline] + [fractal.renderer :as r] + [fractal.tile :as tile])) + +(deftm step-zr [zr :- (Array double), zi :- (Array double), cr :- (Array double)] :- (Array double) + (par/map [i (alength zr)] + (if (<= (+ (* (aget zr i) (aget zr i)) (* (aget zi i) (aget zi i))) 4.0) + (+ (- (* (aget zr i) (aget zr i)) (* (aget zi i) (aget zi i))) (aget cr i)) + (aget zr i)))) + +(deftm step-zi [zr :- (Array double), zi :- (Array double), ci :- (Array double)] :- (Array double) + (par/map [i (alength zr)] + (if (<= (+ (* (aget zr i) (aget zr i)) (* (aget zi i) (aget zi i))) 4.0) + (+ (* (* 2.0 (aget zr i)) (aget zi i)) (aget ci i)) + (aget zi i)))) + +(deftm step-n [zr :- (Array double), zi :- (Array double), n :- (Array double)] :- (Array double) + (par/map [i (alength zr)] + (if (<= (+ (* (aget zr i) (aget zr i)) (* (aget zi i) (aget zi i))) 4.0) + (+ (aget n i) 1.0) + (aget n i)))) + +(defn- compile-steps [] + {:zr (pipeline/compile-aot #'step-zr :simd? true) + :zi (pipeline/compile-aot #'step-zi :simd? true) + :n (pipeline/compile-aot #'step-n :simd? true)}) + +(defn- grid + "The c plane of tile t as two flat arrays." + [t] + (let [{:keys [w h]} t + cr (double-array (tile/pixels t)) + ci (double-array (tile/pixels t))] + (dotimes [y h] + (dotimes [x w] + (let [[a b] (tile/point t x y) p (+ (* y w) x)] + (clojure.core/aset cr p (double a)) + (clojure.core/aset ci p (double b))))) + [cr ci])) + +(defn- escape-tile! [{fzr :zr fzi :zi fnn :n} t {:keys [^ints counts ^doubles mag2]}] + (let [size (tile/pixels t) + [cr ci] (grid t)] + (loop [s 0 zr (double-array size) zi (double-array size) n (double-array size)] + (if (< s (:iter t)) + (recur (inc s) (fzr zr zi cr) (fzi zr zi ci) (fnn zr zi n)) + (let [^doubles zr zr ^doubles zi zi ^doubles n n] + (dotimes [p size] + (let [a (clojure.core/aget zr p) b (clojure.core/aget zi p)] + (clojure.core/aset counts p (int (clojure.core/aget n p))) + (clojure.core/aset mag2 p (+ (* a a) (* b b)))))))))) + +(defrecord RasterSimd [steps] + r/IRenderer + (id [_] "raster-simd") + (describe [_] {:parity? true :what "raster par/map on Vector API lanes, one pass per iteration"}) + (escape! [_ t field] (escape-tile! steps t field))) + +(defn renderer [] (->RasterSimd (compile-steps))) diff --git a/docs/examples/fractal/jvm/src/fractal/tile.clj b/docs/examples/fractal/jvm/src/fractal/tile.clj new file mode 100644 index 000000000..78612e1dd --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/tile.clj @@ -0,0 +1,57 @@ +(ns fractal.tile + "Domain values. A Tile is the question: {:cx :cy :scale :w :h :iter}, pixel + (x, y) being the point (cx + (x - w/2) scale, cy + (y - h/2) scale). An + EscapeField is the answer every renderer gives: per pixel the escape count + (ints) and |z|^2 at escape (doubles). Pure functions only.") + +(def max-pixels (* 1024 1024)) + +(def max-iter (dec (bit-shift-left 1 26))) + +(defn finite? [x] (and (number? x) (Double/isFinite (double x)))) + +(defn problems + "Why m is not a Tile, or nil when it is one." + [{:keys [cx cy scale w h iter]}] + (not-empty + (cond-> [] + (not (every? finite? [cx cy scale])) (conj "cx, cy and scale must be finite numbers") + (and (finite? scale) (not (pos? scale))) (conj "scale must be positive") + (not (and (int? w) (int? h) (pos? w) (pos? h))) (conj "w and h must be positive integers") + (and (int? w) (int? h) (> (* w h) max-pixels)) (conj (str "w*h must be at most " max-pixels)) + (not (and (int? iter) (pos? iter))) (conj "iter must be a positive integer")))) + +(defn tile + "Validate and normalise m into a Tile; throws ex-info listing the problems." + [m] + (if-let [p (problems m)] + (throw (ex-info (first p) {:tile m :problems p})) + (-> (select-keys m [:cx :cy :scale :w :h :iter]) + (update :cx double) (update :cy double) (update :scale double) + (update :iter #(min % max-iter))))) + +(defn pixels [{:keys [w h]}] (* w h)) + +(defn point + "The complex point of pixel (x, y): [re im]." + [{:keys [cx cy scale w h]} x y] + [(+ cx (* (- (double x) (/ (double w) 2.0)) scale)) + (+ cy (* (- (double y) (/ (double h) 2.0)) scale))]) + +(defn escape-field + "An empty EscapeField sized for tile t." + [t] + {:counts (int-array (pixels t)) :mag2 (double-array (pixels t))}) + +(defn total-iters + "Sum of the escape counts: the number every renderer reports." + ^long [{:keys [^ints counts]}] + (areduce counts i acc 0 (+ acc (aget counts i)))) + +(defn parse-query + "Promote a map of query strings into a Tile, defaults filled in." + [q] + (let [num #(if-let [s (get q %1)] (Double/parseDouble s) %2) + lng #(if-let [s (get q %1)] (Long/parseLong s) %2)] + (tile {:cx (num "cx" -0.6) :cy (num "cy" 0.0) :scale (num "scale" 0.004) + :w (lng "w" 640) :h (lng "h" 480) :iter (lng "iter" 256)}))) diff --git a/docs/examples/fractal/kernel.c b/docs/examples/fractal/kernel.c new file mode 100644 index 000000000..51ac2303e --- /dev/null +++ b/docs/examples/fractal/kernel.c @@ -0,0 +1,92 @@ +/* Mandelbrot RGBA8 kernel in C, freestanding with no imports. + * Same ABI and pixel math as kernel.zig; see README.md "The kernel ABI". + * Build from the repository root (clang 21): + * clang --target=wasm32 -O3 -nostdlib -fno-builtin -Wl,--no-entry -Wl,--strip-all -o docs/examples/fractal/kernel_c.wasm docs/examples/fractal/kernel.c + * log2 is computed locally (no libm in freestanding wasm), so palette bytes may + * differ from kernel.zig by one level; iteration totals are exact. */ + +#include + +#define CAPACITY (1024 * 1024) +#define EXPORT(name) __attribute__((export_name(name))) + +static uint8_t framebuffer[CAPACITY * 4]; +static double view_cx = -0.5; +static double view_cy = 0.0; +static double view_scale = 3.5 / 512.0; + +static int finite(double x) { return x - x == 0.0; } + +/* log2 for x >= 1: split exponent and mantissa, then 2*atanh series. */ +static double log2_pos(double x) { + union { double d; uint64_t u; } v = { x }; + int64_t e = (int64_t)((v.u >> 52) & 0x7ff) - 1023; + v.u = (v.u & 0x000fffffffffffffULL) | 0x3ff0000000000000ULL; + double m = v.d; + if (m > 1.4142135623730951) { m *= 0.5; e += 1; } + double s = (m - 1.0) / (m + 1.0); + double s2 = s * s; + double series = 1.0 + s2 * (1.0 / 3 + s2 * (1.0 / 5 + s2 * (1.0 / 7 + s2 * (1.0 / 9 + + s2 * (1.0 / 11 + s2 * (1.0 / 13 + s2 * (1.0 / 15 + s2 * (1.0 / 17 + + s2 * (1.0 / 19 + s2 * (1.0 / 21)))))))))); + return (double)e + 2.0 * s * series * 1.4426950408889634; +} + +EXPORT("set_view") int32_t set_view(double cx, double cy, double scale) { + view_cx = cx; + view_cy = cy; + view_scale = scale; + return finite(cx) && finite(cy) && finite(scale) ? 0 : -1; +} + +EXPORT("fb_ptr") int32_t fb_ptr(void) { return (int32_t)(uintptr_t)framebuffer; } +EXPORT("fb_cap") int32_t fb_cap(void) { return CAPACITY; } + +static int valid(int32_t w, int32_t h, int32_t max_iter) { + return w > 0 && h > 0 && w <= CAPACITY && h <= CAPACITY && + (int64_t)w * h <= CAPACITY && max_iter > 0 && + finite(view_cx) && finite(view_cy) && finite(view_scale); +} + +static void color(uint32_t pixel, uint32_t n, uint32_t limit, double magnitude) { + uint8_t *out = &framebuffer[pixel * 4]; + out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 255; + if (n == limit) return; + double bounded = finite(magnitude) ? (magnitude > 4.000001 ? magnitude : 4.000001) : 1.0e300; + double smooth = (double)n + 1.0 - log2_pos(log2_pos(bounded) * 0.5); + double phase = smooth * 0.025; + for (int channel = 0; channel < 3; channel++) { + double t = phase + (double)channel / 3.0; + double wave = 1.0 - __builtin_fabs(2.0 * (t - __builtin_floor(t)) - 1.0); + out[channel] = (uint8_t)(wave * 255.0); + } +} + +EXPORT("render") int64_t render(int32_t w, int32_t h, int32_t max_iter) { + if (!valid(w, h, max_iter)) return -1; + const double cx = view_cx, cy = view_cy, scale = view_scale; + const uint32_t limit = (uint32_t)(max_iter < (1 << 26) - 1 ? max_iter : (1 << 26) - 1); + int64_t total = 0; + for (uint32_t y = 0; y < (uint32_t)h; y++) { + const double ci = cy + ((double)y - (double)h / 2.0) * scale; + for (uint32_t x = 0; x < (uint32_t)w; x++) { + const double cr = cx + ((double)x - (double)w / 2.0) * scale; + double zr = 0.0, zi = 0.0; + uint32_t n = 0; + while (n < limit && zr * zr + zi * zi <= 4.0) { + double next = zr * zr - zi * zi + cr; + zi = (2.0 * zr) * zi + ci; + zr = next; + n++; + } + total += n; + color(y * (uint32_t)w + x, n, limit, zr * zr + zi * zi); + } + } + return total; +} + +/* Scalar twin so every kernel answers the same export set. */ +EXPORT("render_simd") int64_t render_simd(int32_t w, int32_t h, int32_t max_iter) { + return render(w, h, max_iter); +} diff --git a/docs/examples/fractal/kernel.rs b/docs/examples/fractal/kernel.rs new file mode 100644 index 000000000..2e5d60689 --- /dev/null +++ b/docs/examples/fractal/kernel.rs @@ -0,0 +1,118 @@ +//! Mandelbrot RGBA8 kernel in Rust, no_std with no imports. +//! Same ABI and pixel math as kernel.zig; see README.md "The kernel ABI". +//! Build from the repository root (rustup target add wasm32-unknown-unknown): +//! rustc --edition 2021 --crate-type=cdylib --target wasm32-unknown-unknown -C opt-level=3 -C panic=abort -C strip=symbols -o docs/examples/fractal/kernel_rust.wasm docs/examples/fractal/kernel.rs +//! log2 is computed locally (core has no libm), with the same series as +//! kernel.c, so kernel_c.wasm and kernel_rust.wasm agree byte for byte. +#![no_std] + +const CAPACITY: usize = 1024 * 1024; +static mut FRAMEBUFFER: [u8; CAPACITY * 4] = [0; CAPACITY * 4]; +static mut VIEW: (f64, f64, f64) = (-0.5, 0.0, 3.5 / 512.0); + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +fn finite(x: f64) -> bool { + x - x == 0.0 +} + +fn floor(t: f64) -> f64 { + let i = t as i64 as f64; + if i > t { i - 1.0 } else { i } +} + +fn abs(x: f64) -> f64 { + if x < 0.0 { -x } else { x } +} + +fn log2_pos(x: f64) -> f64 { + let bits = x.to_bits(); + let mut e = ((bits >> 52) & 0x7ff) as i64 - 1023; + let mut m = f64::from_bits((bits & 0x000f_ffff_ffff_ffff) | 0x3ff0_0000_0000_0000); + if m > 1.4142135623730951 { + m *= 0.5; + e += 1; + } + let s = (m - 1.0) / (m + 1.0); + let s2 = s * s; + let series = 1.0 + s2 * (1.0 / 3.0 + s2 * (1.0 / 5.0 + s2 * (1.0 / 7.0 + s2 * (1.0 / 9.0 + + s2 * (1.0 / 11.0 + s2 * (1.0 / 13.0 + s2 * (1.0 / 15.0 + s2 * (1.0 / 17.0 + + s2 * (1.0 / 19.0 + s2 * (1.0 / 21.0)))))))))); + e as f64 + 2.0 * s * series * 1.4426950408889634 +} + +#[no_mangle] +pub extern "C" fn set_view(cx: f64, cy: f64, scale: f64) -> i32 { + unsafe { VIEW = (cx, cy, scale) }; + if finite(cx) && finite(cy) && finite(scale) { 0 } else { -1 } +} + +#[no_mangle] +pub extern "C" fn fb_ptr() -> i32 { + unsafe { core::ptr::addr_of!(FRAMEBUFFER) as i32 } +} + +#[no_mangle] +pub extern "C" fn fb_cap() -> i32 { + CAPACITY as i32 +} + +fn color(fb: &mut [u8], pixel: usize, n: u32, limit: u32, magnitude: f64) { + let out = &mut fb[pixel * 4..pixel * 4 + 4]; + out.copy_from_slice(&[0, 0, 0, 255]); + if n == limit { + return; + } + let bounded = if finite(magnitude) { + if magnitude > 4.000001 { magnitude } else { 4.000001 } + } else { + 1.0e300 + }; + let smooth = n as f64 + 1.0 - log2_pos(log2_pos(bounded) * 0.5); + let phase = smooth * 0.025; + for channel in 0..3 { + let t = phase + channel as f64 / 3.0; + let wave = 1.0 - abs(2.0 * (t - floor(t)) - 1.0); + out[channel] = (wave * 255.0) as u8; + } +} + +#[no_mangle] +pub extern "C" fn render(w: i32, h: i32, max_iter: i32) -> i64 { + let (cx, cy, scale) = unsafe { VIEW }; + let ok = w > 0 && h > 0 && (w as i64) * (h as i64) <= CAPACITY as i64 && max_iter > 0 + && finite(cx) && finite(cy) && finite(scale); + if !ok { + return -1; + } + let fb = unsafe { &mut *core::ptr::addr_of_mut!(FRAMEBUFFER) }; + let limit = max_iter.min((1 << 26) - 1) as u32; + let (wu, hu) = (w as usize, h as usize); + let mut total: i64 = 0; + for y in 0..hu { + let ci = cy + (y as f64 - h as f64 / 2.0) * scale; + for x in 0..wu { + let cr = cx + (x as f64 - w as f64 / 2.0) * scale; + let (mut zr, mut zi) = (0.0f64, 0.0f64); + let mut n: u32 = 0; + while n < limit && zr * zr + zi * zi <= 4.0 { + let next = zr * zr - zi * zi + cr; + zi = (2.0 * zr) * zi + ci; + zr = next; + n += 1; + } + total += n as i64; + color(fb, y * wu + x, n, limit, zr * zr + zi * zi); + } + } + total +} + +/// Scalar twin so every kernel answers the same export set. +#[no_mangle] +pub extern "C" fn render_simd(w: i32, h: i32, max_iter: i32) -> i64 { + render(w, h, max_iter) +} diff --git a/docs/examples/fractal/kernel_c.wasm b/docs/examples/fractal/kernel_c.wasm new file mode 100755 index 0000000000000000000000000000000000000000..ad1dacef7d9752782d9c67949083e03a0ed7734b GIT binary patch literal 1565 zcmd^9O=uHA6n-<4Y|?D=+dn0uZx;`OS5a!k&8}%xJb9=$sjW?+(P~YrRtVkLh!jua zts=5nFCtVa9;#5Oco7dCJb0BJ^j}*1=q6S1K<-j~v#tslwF7(lrjznZoSI zmC3?Y0BvGyWO}v)OoFqc)4;V-;lg;K1VV3sk(tS3WuWO>fx>?=Xu991kWL} z-68@FCA(g?PQ(GZ&5qH;mJycc9U1MZIMLhNAZLa!)V5(mqarZF82&?}qA)eoe$mK% z7|{@!I+JoNFvykeqwcxEPF{g6EZkRpCta6Btb+H#zIVfHzcSt*e!RZ+<| z>&7}cP^|=%Uine}?SMOR-}_>Xn>@Rv++6oa)iB(&ya%R=mj+*=->>|(o1=6;wEX;@ zC$+VkpC4JCpWmtEv4m+QD-w1OtaJ^mJe6>$@AT}u!Bq)&yuL*DnuK?DAE*13gm;&u zypeEN$$JU6So!%M3wsMkd%jw}voPmepF^I-K4;%z`FXQi6y$%M38$W{QZ{NqDwiy( zUy#dBmS<9H->b>7nJ=pQQqV>l7jyo1aS&vC)_!ZxrVpyeKl@OP-`3*I_5z@$#tiYG z@ozkI6xdrf1SVT8U?edi4_pF49yuNLHl27)V6|So8neLvRwmWv5L~?-YCWoNxvh&+BLlEOK-h{A15r^f55+|7~89P5S z_L{6@d7L;3fzz@l5~0<2SZP;?01+S(X;CaLh{Gar;EDoTBqS~o2jF0>`CfI;k2lG} zUO3U?cKy6puipFKPt^>YU%JQ{V|*@iT68+RbK2@f(7I> zHJUkULOOl1aq)8dHNn#7&zw3t ze}!@CTv={2k#09Goolq2;l`a)OBXJlW2U{-SU$CKq47Iup%y(TTwyC_nqot>ReCC!QHz>@5KBJ@$c7ub@I4xL#R z7>WfMV@75|A=zT!Vl8h58JUu4Deg;Wv)2u>1HA$d>|eg9Q<>+|nd)`F?5;mWDGjXzF*kuiVf9CXVH7vjg2*(6~?y>)GC zo%!EF9}zO(45q-P-XKRc)ecXrdtBG*-5X`-I zv~&K=?mz11LNPrcK7QB*DGnDv|AUagiorzh!=JeWH|O#9PQ5pG_$Qn1-u(Ly>)qbm zC-w7V>h96cJ?ic~dT;XRy`NF{*#56C|Ng~aQup9*TKIdHx^Emhfxll<_s(s4eoNh# z_45bn4z%jceQ^Cq%H0QS7)zXm10`%pDo>*-5K9leb7xfVd($Sc+lNU;JjV$ z!ObzAf2nsvB+7RBK{(mR6`r3&&`+M7-XvNdMMOk6afh-YVPp&(asScfnO-=tjcgyG zo}cayKt4D9ImQsM-+eM*`?|ol|65&jlI~8Pe#kR6(4}uKWdzlX3BrZ4VTf1g-=U0n z1}?tFWk>}s$)JymD-F4CH8A9uWV-HTB&Qbh?iiD(@lyC3Q~=zUQPU-_8M=0(%W83f zM_1GgUAJjgvSGbBgXMf=*hXCvqvHklIOm;DQ-xqkE`-J*-Gx#-5Zu8?RR%p-V9P?;#WL@ z@{X-+G}t(cCM8Yma`#5XIFO8NJ@Qc*x7Hb`ep4F$O_GFiJy;GEv3$5g_-sM%JY5hJ zAU!^Ypg;W=f^JuzQ`iLTeg_t)-?`6kIzkQ_a93h^3~R zEjr!&chf`j}MbUp#7Zxc{~r76RJCMAI?_EX{5^no zo#}rsdMukl0wWVY1ILZMO`h9%t)+g7RT|x_Xa{+$~u^UrH@#oZX|7P-_g6Xh&S)7UCV*U`M*Voq)M$PI|_F zu-X`G`?O6s5E-k$dNv+wrxCzK5^w{EQ3M#GjYMR2>lt{i^xVItx!uum0Ex0 ziQ+|Z%cZdpno6vMDL4X^0O6e`^Y9`Z$dF??`YA?e0_m|3I%*Z=2{U>Y)|0Y!GZcdW zht~%&jf>Vuv_F>ny1cc9 z3cx*S#i2Rn;d6}Z6x5SqFe#Er7d+U(h8Ye{7T_RcN^Nod@5FRyX-qVN7F}db_1J2K zT-#5jgQ|Zs(1k=A0Vgz2iYZ(j)mc*33RF+8wAHoiH*87^`y*)PvuV!~D4KYH4@ZR$ z;fMG*Oi612r-dO+AO#5R6}5A1+iIb!+a^lBxv2aV!razCk|CDb<}DZrBxygM7{G@H z=pab21&{f3Bb)GBT4}7oWQQUVB!+?rG=K*@NVS8yV33BpHH!C5uO5Z=TPh+ z8hKoOIf6P!CGqIM12*E(0UZ561LH_M^3iyN%was*$RdX#($Upd=;}+Nv|o|&eS!29 zj^dKS6V+GfxU}743`${!f()4=`4hEH@o^kB@sR?HaB?7Z!8Tf8+=qf&{cDE55Q;;( z?x+mHQ&#~qNI{0F{%RqI^)zN+HsHl6-^A&++LWmfI;huw$1a5_Y8Dp|%2bop`^XwO-BksiHe)<5{*i4l{@$zrcApq3`;y=a- zcR}|}LnOZtY>RzMixnKjm6Klml$K;V?KU8VQdEP zKD@bHE}tvpin&s*oQramTs2qA=kob{Az#dw^5uM#ujH%wS|L}+7Yc=9p;RarqC%xm zE!2v+V!l`?7K^1~xfm5I#cHuu%9Zk^LaA6PmCB{4R4G+UwQ{bUFBi(ia;aP{N99Vn zTCPR8C?6G~VpNLCQ5038YE-M_D)~yGQmm9Jn+a literal 0 HcmV?d00001 diff --git a/docs/examples/fractal/mandel.clj b/docs/examples/fractal/mandel.clj new file mode 100644 index 000000000..66d247657 --- /dev/null +++ b/docs/examples/fractal/mandel.clj @@ -0,0 +1,51 @@ +;; Plain Clojure Mandelbrot: the same pixel math and palette as the wasm +;; kernels, with no wasm and no library. Portable across cljw, bb and JVM +;; Clojure; the server's cljw-clj engine and bench.clj run it on cljw. +;; Iteration totals match the kernels exactly. +(ns fractal.mandel) + +(def ^:const inv-ln2 1.4426950408889634) + +(defn- log2 [x] (* (Math/log x) inv-ln2)) + +(defn- finite? [x] (== 0.0 (- x x))) + +(defn- color! [^bytes fb pixel n limit magnitude] + (let [o (* 4 pixel)] + (aset fb (+ o 3) (unchecked-byte 255)) + (if (== n limit) + (do (aset fb o (unchecked-byte 0)) + (aset fb (+ o 1) (unchecked-byte 0)) + (aset fb (+ o 2) (unchecked-byte 0))) + (let [bounded (if (finite? magnitude) (max magnitude 4.000001) 1.0e300) + smooth (- (+ (double n) 1.0) (log2 (* 0.5 (log2 bounded)))) + phase (* smooth 0.025)] + (dotimes [ch 3] + (let [t (+ phase (/ (double ch) 3.0)) + wave (- 1.0 (Math/abs (- (* 2.0 (- t (Math/floor t))) 1.0)))] + (aset fb (+ o ch) (unchecked-byte (long (* wave 255.0)))))))))) + +(defn render + "Render w x h RGBA8 pixels of the view (cx, cy, scale) into fb, a byte-array + of at least w*h*4 bytes. Returns total iterations, as the kernels' render." + [^bytes fb cx cy scale w h max-iter] + (let [limit (min max-iter 67108863) + hw (/ (double w) 2.0) + hh (/ (double h) 2.0)] + (loop [y 0 total 0] + (if (< y h) + (let [ci (+ cy (* (- (double y) hh) scale)) + row (loop [x 0 acc 0] + (if (< x w) + (let [cr (+ cx (* (- (double x) hw) scale)) + [n zr zi] (loop [n 0 zr 0.0 zi 0.0] + (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) + (recur (inc n) + (+ (- (* zr zr) (* zi zi)) cr) + (+ (* (* 2.0 zr) zi) ci)) + [n zr zi]))] + (color! fb (+ (* y w) x) n limit (+ (* zr zr) (* zi zi))) + (recur (inc x) (+ acc n))) + acc))] + (recur (inc y) (+ total row))) + total)))) diff --git a/docs/examples/fractal/server.clj b/docs/examples/fractal/server.clj index 17e5d4bc7..7b10ca8dc 100644 --- a/docs/examples/fractal/server.clj +++ b/docs/examples/fractal/server.clj @@ -1,65 +1,48 @@ ;; Fractal zoom: the server half. ;; Usage: cljw docs/examples/fractal/server.clj [port] (default 8080) -;; KERNEL=/path/to/kernel.wasm cljw server.clj (override module) +;; KERNEL=/path/to/kernel.wasm cljw server.clj (override the zig module) ;; -;; Native cljw serves the page and renders tiles server-side with zwasm; the -;; browser ALSO runs the identical kernel.wasm locally and compares live. +;; Native cljw serves the page and renders tiles server-side. Every kernel +;; (Zig, Zig simd128, C, Rust) answers one ABI, see README.md "The kernel ABI", +;; so the same render-tile drives all of them. cljw-raster renders with plain +;; Clojure (raster.clj) and no wasm, the CPU raster baseline. ;; -;; kernel.wasm ABI (fixed): no imports; exports `memory`, `fb_ptr() -> i32`, -;; `fb_cap() -> i32`, `set_view(cx f64, cy f64, scale f64) -> i32` (0 finite, -;; -1 nonfinite), and `render(w i32, h i32, max_iter i32) -> i64` writing w*h -;; RGBA8 row-major pixels at fb_ptr and returning total iterations. -;; `render_simd` (same 3 args) ships only in a sibling kernel_simd.wasm; the -;; zwasm interpreter cannot run SIMD, so the interp instance always uses the -;; scalar render. The view lives in the instance, so set_view + render is -;; atomic per instance: every instance is held behind a lock. +;; Engines (the /tile `engine` parameter) are "-": +;; zig-jit zig-interp c-jit c-interp rust-jit rust-interp zig-simd-jit +;; cljw-raster +;; The old names jit, interp and jit-simd stay as aliases of the zig ones. +;; GET /engines answers {"engines":[...],"kernels":[...]}; every kernel listed +;; is fetchable by the browser at GET /kernel.wasm?k=. ;; -;; Engines (the /tile `engine` parameter): -;; jit kernel.wasm on zwasm's JIT -;; jit-simd kernel_simd.wasm on the JIT (only when that file exists) -;; interp kernel.wasm on zwasm's interpreter -;; GET /engines answers which of these exist, e.g. {"engines":["jit","interp"]}. -;; -;; Binary bodies: cljw.http.server accepts a primitive byte-array :body and -;; copies it verbatim, so tiles and the wasm module itself cross as raw bytes -;; (a String cannot carry arbitrary binary: its producers normalise to UTF-8). +;; The view lives in the wasm instance, so set_view + render is atomic per +;; instance: every instance is held behind a lock. (ns fractal.server (:require [cljw.http.server :as http] - [clojure.java.io :as io])) + [clojure.java.io :as io] + [clojure.string :as str])) (def dir (.getParent (io/file *file*))) -(def kernel-path (or (System/getenv "KERNEL") (str dir "/kernel.wasm"))) -(def simd-path (str dir "/kernel_simd.wasm")) +(load-file (str dir "/mandel.clj")) +(def public (str dir "/web/public")) (def port (if (seq *command-line-args*) (Long/parseLong (first *command-line-args*)) 8080)) -;; --- one instance per engine, each behind its own lock ----------------------- -;; [handle lock]; the lock makes the set_view + render pair atomic per -;; request: the view is instance state and the accept loop may thread later. -(defn engine [path eng simd?] - [(wasm/load path {:engine eng :fuel 0}) - (Object.) - (if simd? "render_simd" "render")]) - -(def jit (engine kernel-path :jit false)) -(def interp (engine kernel-path :interp false)) -(def jit-simd - (when (.exists (io/file simd-path)) - (engine simd-path :jit true))) +;; --- kernels ------------------------------------------------------------------ +;; :jit-only marks simd128 modules: zwasm's interpreter has no v128. +(def kernel-specs + [{:id "zig" :path (or (System/getenv "KERNEL") (str dir "/kernel.wasm")) :export "render"} + {:id "zig-simd" :path (str dir "/kernel_simd.wasm") :export "render_simd" :jit-only true} + {:id "c" :path (str dir "/kernel_c.wasm") :export "render"} + {:id "rust" :path (str dir "/kernel_rust.wasm") :export "render"}]) -(def engines - (cond-> ["jit" "interp"] - (some? jit-simd) (conj "jit-simd"))) - -(def cap (.fb_cap (first jit))) +(def kernels (filterv #(.exists (io/file (:path %))) kernel-specs)) ;; --- file bytes --------------------------------------------------------------- -;; Read a whole file as a primitive byte-array. cljw's InputStream surface -;; today is the 1-arg .read (one byte per call, 0..255, -1 at EOF); there is -;; no readAllBytes / .read(buf) arm, so drain into a pre-sized array. +;; cljw's InputStream surface is the 1-arg .read (one byte per call), so drain +;; into a pre-sized array. (defn file->bytes [path] (let [size (.length (io/file path))] (with-open [in (io/input-stream (io/file path))] @@ -71,7 +54,27 @@ (aset arr i (unchecked-byte b)))) arr)))) -(def kernel-bytes (file->bytes kernel-path)) +(def kernel-bytes (into {} (map (fn [k] [(:id k) (file->bytes (:path k))])) kernels)) + +;; --- engines -------------------------------------------------------------------- +;; An engine is {:inst :lock :export} for wasm, or {:raster true :lock}. +(defn wasm-engine [k runtime] + {:inst (wasm/load (:path k) {:engine runtime :fuel 0}) + :lock (Object.) + :export (:export k)}) + +(def engines + (into (array-map) + (concat + (for [k kernels + runtime [:jit :interp] + :when (not (and (:jit-only k) (= runtime :interp)))] + [(str (:id k) "-" (name runtime)) (wasm-engine k runtime)]) + [["cljw-clj" {:clj true :lock (Object.)}]]))) + +(def aliases {"jit" "zig-jit" "interp" "zig-interp" "jit-simd" "zig-simd-jit"}) + +(def cap (.fb_cap (:inst (get engines "zig-jit")))) ;; --- query parsing ------------------------------------------------------------ (defn parse-query [qs] @@ -93,70 +96,89 @@ (if (nil? v) d (Long/parseLong v)))) ;; --- tile rendering ----------------------------------------------------------- -;; `eng` is an engine triple. set_view validates the view (0) before render; -;; the pair is locked per instance. The framebuffer is read back in one bulk -;; memcpy as a byte-array, which is also the response body verbatim. +;; The wasm framebuffer is read back in one bulk copy as a byte-array, which is +;; also the response body verbatim. (defn render-tile [eng cx cy scale w h iter] (when (> (* w h) cap) (throw (ex-info "tile exceeds framebuffer capacity" {:cap cap :got (* w h)}))) - (let [[inst lock f] eng] - (locking lock - (let [rc (.set_view inst cx cy scale)] + (locking (:lock eng) + (if (:clj eng) + (let [fb (byte-array (* w h 4)) + t0 (System/nanoTime) + iters (fractal.mandel/render fb cx cy scale w h iter)] + {:ms (/ (double (- (System/nanoTime) t0)) 1.0e6) :iters iters :bytes fb}) + (let [inst (:inst eng) + rc (.set_view inst cx cy scale)] (when (neg? rc) - (throw (ex-info "set_view rejected a non-finite coordinate" {:cx cx :cy cy :scale scale})))) - (let [t0 (System/nanoTime) - iters (if (= f "render_simd") - (.render_simd inst w h iter) - (.render inst w h iter)) - ms (/ (double (- (System/nanoTime) t0)) 1.0e6) - arr (wasm/mem-read-array inst :u8 (.fb_ptr inst) (* w h 4))] - {:ms ms :iters iters :bytes arr})))) + (throw (ex-info "set_view rejected a non-finite coordinate" {:cx cx :cy cy :scale scale}))) + (let [t0 (System/nanoTime) + iters (wasm/call inst (:export eng) w h iter) + ms (/ (double (- (System/nanoTime) t0)) 1.0e6)] + {:ms ms :iters iters + :bytes (wasm/mem-read-array inst :u8 (.fb_ptr inst) (* w h 4))}))))) + +;; --- static files --------------------------------------------------------------- +;; The compiled frontend: /js/** from web/public/js. No path may climb out. +(def content-types {"js" "text/javascript" "map" "application/json" "css" "text/css"}) + +(defn static [uri] + (let [f (io/file (str public uri)) + ext (subs uri (inc (.lastIndexOf uri ".")))] + (if (and (.startsWith uri "/js/") (not (.contains uri "..")) (.isFile f)) + {:status 200 + :headers {"content-type" (get content-types ext "application/octet-stream") + "cache-control" "no-cache"} + :body (file->bytes (.getPath f))} + {:status 404 :body "not found\n"}))) ;; --- handler ------------------------------------------------------------------ +(defn json-strings [xs] + (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) + (defn handler [req] - (case (:uri req) - "/" {:status 200 - :headers {"content-type" "text/html; charset=utf-8"} - :body (slurp (str dir "/index.html"))} - - "/kernel.wasm" {:status 200 - :headers {"content-type" "application/wasm" - "cache-control" "no-cache"} - :body kernel-bytes} - - "/engines" {:status 200 - :headers {"content-type" "application/json"} - :body (str "{\"engines\":[" (clojure.string/join "," (map #(str "\"" % "\"") engines)) "]}")} - - "/tile" - (try - (let [q (parse-query (:query-string req)) - cx (num q "cx" -0.6) - cy (num q "cy" 0.0) - scale (num q "scale" 0.004) - w (lng q "w" 640) - h (lng q "h" 480) - iter (lng q "iter" 256) - eng (case (get q "engine" "jit") - "jit" jit - "jit-simd" jit-simd - "interp" interp - nil)] - (if (nil? eng) - {:status 400 :body (str "engine must be one of " engines)} - (let [{:keys [ms iters bytes]} (render-tile eng cx cy scale w h iter)] - {:status 200 - :headers {"content-type" "application/octet-stream" - "cache-control" "no-store" - "x-render-ms" (format "%.3f" ms) - "x-iters" (str iters)} - :body bytes}))) - (catch Throwable e - {:status 400 :body (str "tile error: " (.getMessage e))})) - - {:status 404 :body "not found\n"})) + (let [q (parse-query (:query-string req))] + (case (:uri req) + "/" {:status 200 + :headers {"content-type" "text/html; charset=utf-8"} + :body (slurp (str public "/index.html"))} + + "/kernel.wasm" + (if-let [bytes (get kernel-bytes (get q "k" "zig"))] + {:status 200 + :headers {"content-type" "application/wasm" "cache-control" "no-cache"} + :body bytes} + {:status 404 :body "unknown kernel\n"}) + + "/engines" {:status 200 + :headers {"content-type" "application/json"} + :body (str "{\"engines\":" (json-strings (keys engines)) + ",\"kernels\":" (json-strings (map :id kernels)) "}")} + + "/tile" + (try + (let [cx (num q "cx" -0.6) + cy (num q "cy" 0.0) + scale (num q "scale" 0.004) + w (lng q "w" 640) + h (lng q "h" 480) + iter (lng q "iter" 256) + id (get q "engine" "zig-jit") + eng (get engines (get aliases id id))] + (if (nil? eng) + {:status 400 :body (str "engine must be one of " (vec (keys engines)))} + (let [{:keys [ms iters bytes]} (render-tile eng cx cy scale w h iter)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body bytes}))) + (catch Throwable e + {:status 400 :body (str "tile error: " (.getMessage e))})) + + (static (:uri req))))) (println (str "fractal server on http://localhost:" port - " kernel=" kernel-path - " engines=" engines)) + " kernels=" (mapv :id kernels) + " engines=" (vec (keys engines)))) (http/run-server handler {:port port}) diff --git a/docs/examples/fractal/web/.gitignore b/docs/examples/fractal/web/.gitignore new file mode 100644 index 000000000..e95d4228f --- /dev/null +++ b/docs/examples/fractal/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.shadow-cljs/ +public/js/cljs-runtime/ +public/js/manifest.edn diff --git a/docs/examples/fractal/web/package-lock.json b/docs/examples/fractal/web/package-lock.json new file mode 100644 index 000000000..26c852c3a --- /dev/null +++ b/docs/examples/fractal/web/package-lock.json @@ -0,0 +1,220 @@ +{ + "name": "cljw-fractal-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cljw-fractal-web", + "dependencies": { + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "shadow-cljs": "3.5.7" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==" + }, + "node_modules/shadow-cljs": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-3.5.7.tgz", + "integrity": "sha512-v0qvoSGM36uSBz+GRqdI8FzSFJAXzrTJEctddTsLxo0mqAHpk1yUZteqHL5WIjUDeTPHaiiYIre524B7OMJwPg==", + "dev": true, + "dependencies": { + "buffer": "^6.0.3", + "process": "^0.11.10", + "readline-sync": "^1.4.10", + "shadow-cljs-jar": "1.3.4", + "source-map-support": "^0.5.21", + "which": "^5.0.0", + "ws": "^8.18.1" + }, + "bin": { + "shadow-cljs": "cli/runner.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/shadow-cljs-jar": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz", + "integrity": "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==", + "dev": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/docs/examples/fractal/web/package.json b/docs/examples/fractal/web/package.json new file mode 100644 index 000000000..c4083daf5 --- /dev/null +++ b/docs/examples/fractal/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "cljw-fractal-web", + "private": true, + "scripts": { + "watch": "shadow-cljs watch app", + "release": "shadow-cljs release app" + }, + "dependencies": { + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "shadow-cljs": "3.5.7" + } +} diff --git a/docs/examples/fractal/web/public/index.html b/docs/examples/fractal/web/public/index.html new file mode 100644 index 000000000..d29ad243b --- /dev/null +++ b/docs/examples/fractal/web/public/index.html @@ -0,0 +1,61 @@ + + + + + +cljw fractal zoom + + + +
+ + + diff --git a/docs/examples/fractal/web/shadow-cljs.edn b/docs/examples/fractal/web/shadow-cljs.edn new file mode 100644 index 000000000..9f13e03f6 --- /dev/null +++ b/docs/examples/fractal/web/shadow-cljs.edn @@ -0,0 +1,14 @@ +;; Frontend of the fractal demo: re-frame + reagent, compiled to public/js. +;; The cljw server (../server.clj) serves public/ and the tiles, so `watch` +;; needs no dev-http: open http://localhost:8080 with server.clj running. +{:source-paths ["src"] + :dependencies [[reagent/reagent "2.0.1"] + [re-frame/re-frame "1.4.7"]] + :nrepl {:port 7893} + :http {:port 9632} + :builds + {:app {:target :browser + :output-dir "public/js" + :asset-path "/js" + :modules {:main {:init-fn fractal.core/init}} + :devtools {:after-load fractal.core/reload}}}} diff --git a/docs/examples/fractal/web/src/fractal/core.cljs b/docs/examples/fractal/web/src/fractal/core.cljs new file mode 100644 index 000000000..4b6884483 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/core.cljs @@ -0,0 +1,66 @@ +(ns fractal.core + "Entry point: mounts the reagent root, wires the keyboard and the URL hash, + and exposes window.fractal, the surface the hive-cljs scenarios drive." + (:require [reagent.dom.client :as rdc] + [re-frame.core :as rf] + [re-frame.db :as db] + [fractal.engines :as engines] + [fractal.events :as ev] + [fractal.ui :as ui] + [fractal.view :as view])) + +(defonce ^:private root (atom nil)) + +(defn- on-key [e] + (when-not (= "INPUT" (.. e -target -tagName)) + (let [[w] (:canvas @db/app-db) + step (/ w 10) + handled (case (.-key e) + ("ArrowLeft" "a") [::ev/pan (- step) 0] + ("ArrowRight" "d") [::ev/pan step 0] + ("ArrowUp" "w") [::ev/pan 0 (- step)] + ("ArrowDown" "s") [::ev/pan 0 step] + ("+" "=") [::ev/zoom-center 1.5] + ("-" "_") [::ev/zoom-center (/ 1 1.5)] + nil)] + (when handled + (.preventDefault e) + (rf/dispatch handled))))) + +(defn- apply-hash! [] + (let [{v :view iter :iter} (view/<-hash js/location.hash)] + (rf/dispatch [::ev/set-view (cond-> v iter (assoc :iter iter))]))) + +(defn- test-surface [] + #js {:view (fn [] (clj->js (assoc (:view @db/app-db) :iter (:iter @db/app-db)))) + :engines (fn [] (clj->js (:engines @db/app-db))) + :ready (fn [] (boolean (:ready? @db/app-db))) + :panes (fn [] (clj->js (:panes @db/app-db))) + :setView (fn [v] (rf/dispatch-sync [::ev/set-view (js->clj v :keywordize-keys true)])) + :zoomAt (fn [px py f] (rf/dispatch-sync [::ev/zoom-at px py f])) + :pan (fn [dx dy] (rf/dispatch-sync [::ev/pan dx dy])) + :render (fn [engine cx cy scale w h iter] + (.then (engines/render-on engine {:cx cx :cy cy :scale scale :w w :h h} iter) + (fn [{:keys [ms iters pixels]}] + #js {:ms ms :iters iters :checksum (engines/checksum pixels)}))) + :bench (fn [opts] + (let [o (js->clj (or opts #js {}) :keywordize-keys true)] + (.then (ev/run-bench! (assoc o :engines (or (:engines o) (:engines @db/app-db)))) + clj->js)))}) + +(defn- r-root [] [ui/root]) + +(defn ^:dev/after-load reload [] + (rf/clear-subscription-cache!) + (.render @root (r-root))) + +(defn init [] + (rf/dispatch-sync [::ev/init]) + (apply-hash!) + (set! js/window.fractal (test-surface)) + (js/window.addEventListener "keydown" on-key) + (js/window.addEventListener "hashchange" apply-hash!) + (reset! root (rdc/create-root (js/document.getElementById "app"))) + (.render @root (r-root)) + (-> (engines/load!) + (.then #(rf/dispatch [::ev/engines-loaded %])))) diff --git a/docs/examples/fractal/web/src/fractal/engines.cljs b/docs/examples/fractal/web/src/fractal/engines.cljs new file mode 100644 index 000000000..c0f61b8c9 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/engines.cljs @@ -0,0 +1,137 @@ +(ns fractal.engines + "Render engines. Every engine answers render-on with a promise of + {:pixels Uint8ClampedArray(w*h*4 RGBA) :ms render-ms :iters total}. + + browser- that kernel's .wasm instantiated in this tab + browser-js the same pixel math in plain JavaScript, no wasm + server- native cljw renders (zwasm jit/interp, or cljw-raster)") + +(defonce kernels (atom {})) + +(def raster-url + "The raster engine's tile server (replikativ/raster on the JVM, ../raster). + Default: port 8081 on this host; ?raster= overrides." + (or (.get (js/URLSearchParams. js/location.search) "raster") + (str js/location.protocol "//" js/location.hostname ":8081"))) + +(defn- probe-raster + "Resolves to [\"raster-jvm\"] when the raster server answers, else []." + [] + (let [ctl (js/AbortController.)] + (js/setTimeout #(.abort ctl) 1500) + (-> (js/fetch (str raster-url "/engines") #js {:signal (.-signal ctl)}) + (.then #(if (.-ok %) ["raster-jvm"] [])) + (.catch (constantly []))))) + +(defn- fetch-json [url] + (-> (js/fetch url) (.then #(.json %)))) + +(defn load! + "Fetch the server's engine list and instantiate every kernel this browser + validates. Resolves to {:server [...] :kernels [...]}." + [] + (-> (fetch-json "/engines") + (.catch (fn [_] #js {:engines #js [] :kernels #js ["zig"]})) + (.then + (fn [^js data] + (let [server (vec (.-engines data)) + ids (vec (.-kernels data))] + (-> (js/Promise.all + (clj->js + (for [k ids] + (-> (js/fetch (str "/kernel.wasm?k=" k)) + (.then #(.arrayBuffer %)) + (.then (fn [bytes] + (when (js/WebAssembly.validate bytes) + (.then (js/WebAssembly.instantiate bytes #js {}) + #(swap! kernels assoc k (.. % -instance -exports)))))) + (.catch #(js/console.warn "kernel" k %)))))) + (.then (fn [_] (probe-raster))) + (.then (fn [raster] {:server server :raster raster :kernels (filterv @kernels ids)})))))))) + +(defn names + "Engine names in display order." + [{:keys [server kernels raster]}] + (vec (concat (map #(str "browser-" %) kernels) ["browser-js"] (map #(str "server-" %) server) raster))) + +(defonce ^:private js-fb (atom (js/Uint8ClampedArray. 0))) + +(defn- wave [phase c] + (let [t (+ phase (/ c 3))] + (js/Math.trunc (* 255 (- 1 (js/Math.abs (- (* 2 (- t (js/Math.floor t))) 1))))))) + +(defn- color! [fb o n limit mag] + (aset fb (+ o 3) 255) + (if (== n limit) + (do (aset fb o 0) (aset fb (+ o 1) 0) (aset fb (+ o 2) 0)) + (let [bounded (if (js/Number.isFinite mag) (max mag 4.000001) 1e300) + phase (* 0.025 (- (+ n 1) (js/Math.log2 (* 0.5 (js/Math.log2 bounded)))))] + (dotimes [c 3] (aset fb (+ o c) (wave phase c)))))) + +(defn js-raster + "Plain-JS twin of the kernels' render. Returns {:pixels :iters}." + [cx cy scale w h max-iter] + (when (< (.-length @js-fb) (* w h 4)) + (reset! js-fb (js/Uint8ClampedArray. (* w h 4)))) + (let [fb @js-fb + limit (min max-iter 67108863) + hw (/ w 2) + hh (/ h 2) + total (volatile! 0)] + (dotimes [y h] + (let [ci (+ cy (* (- y hh) scale))] + (dotimes [x w] + (let [cr (+ cx (* (- x hw) scale))] + (loop [n 0 zr 0.0 zi 0.0] + (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4)) + (recur (inc n) (+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2 zr) zi) ci)) + (do (vswap! total + n) + (color! fb (* 4 (+ (* y w) x)) n limit (+ (* zr zr) (* zi zi)))))))))) + {:pixels (.subarray fb 0 (* w h 4)) :iters @total})) + +(defn- now [] (js/performance.now)) + +(defn render-on + "Render one w x h tile of view (cx, cy, scale) on engine. Server ms is the + guest render time from X-Render-Ms, so network time is excluded." + [engine {:keys [cx cy scale w h]} iter] + (cond + (= engine "browser-js") + (let [t0 (now) r (js-raster cx cy scale w h iter)] + (js/Promise.resolve (assoc r :ms (- (now) t0)))) + + (.startsWith engine "browser-") + (let [k (subs engine 8) + ^js ex (get @kernels k) + f (if (= k "zig-simd") (.-render_simd ex) (.-render ex)) + t0 (now) + _ (.set_view ex cx cy scale) + iters (js/Number (f w h iter)) + ms (- (now) t0)] + (js/Promise.resolve + {:pixels (js/Uint8ClampedArray. (.. ex -memory -buffer) (.fb_ptr ex) (* w h 4)) + :ms ms :iters iters})) + + :else + (let [q (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :w w :h h :iter iter + :engine (if (= engine "raster-jvm") engine (subs engine 7))}) + base (if (= engine "raster-jvm") raster-url "") + t0 (now)] + (-> (js/fetch (str base "/tile?" q)) + (.then (fn [^js resp] + (if (.-ok resp) + (.then (.arrayBuffer resp) + (fn [buf] + (let [total (- (now) t0) + rms (js/parseFloat (or (.get (.-headers resp) "X-Render-Ms") "NaN"))] + {:pixels (js/Uint8ClampedArray. buf) + :ms (if (js/Number.isFinite rms) rms total) + :iters (js/Number (or (.get (.-headers resp) "X-Iters") "0"))}))) + (.then (.text resp) #(throw (js/Error. (str "tile: HTTP " (.-status resp) " " %))))))))))) + +(defn checksum + "The bench's framebuffer checksum, identical to bench.clj's." + [px] + (let [n (.-length px)] + (loop [i 0 c 1] + (if (< i n) (recur (inc i) (mod (+ (* c 31) (aget px i)) 2147483647)) c)))) diff --git a/docs/examples/fractal/web/src/fractal/events.cljs b/docs/examples/fractal/web/src/fractal/events.cljs new file mode 100644 index 000000000..25145e962 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/events.cljs @@ -0,0 +1,188 @@ +(ns fractal.events + "re-frame state: events, effects and subscriptions. The canvas paint is an + effect (:fractal/paint), latest-wins with one frame in flight per pane." + (:require [re-frame.core :as rf] + [fractal.engines :as engines] + [fractal.view :as view])) + +(def initial-db + {:view view/home :iter 256 :res 1 :split? false + :engines [] :left nil :right nil :canvas [0 0] + :panes {} :totals {} :samples {} + :bench nil :tour? false :ready? false}) + +;; --- painting ------------------------------------------------------------------- +(defonce canvas-el (atom nil)) +(defonce ^:private pane-state (atom {})) + +(defn- blit! [pixels w h dx dw H] + (when-let [c @canvas-el] + (let [tmp (js/document.createElement "canvas") + ctx (.getContext c "2d")] + (set! (.-width tmp) w) + (set! (.-height tmp) h) + (.putImageData (.getContext tmp "2d") (js/ImageData. (js/Uint8ClampedArray. pixels) w h) 0 0) + (set! (.-imageSmoothingEnabled ctx) false) + (.drawImage ctx tmp 0 0 w h dx 0 dw H)))) + +(defn- paint-pane! [id job] + (if (get-in @pane-state [id :busy?]) + (swap! pane-state assoc-in [id :pending] job) + (let [{:keys [engine view canvas slice res iter]} job + [W H] canvas + [dx dw] slice + pv (view/pane-view view canvas slice res)] + (swap! pane-state assoc id {:busy? true}) + (-> (engines/render-on engine pv iter) + (.then (fn [{:keys [pixels ms iters]}] + (blit! pixels (:w pv) (:h pv) dx dw H) + (rf/dispatch [::frame-done id {:engine engine :ms ms :iters iters :w (:w pv) :h (:h pv)}]))) + (.catch (fn [e] (rf/dispatch [::frame-error id engine (.-message e)]))) + (.finally (fn [] + (let [pending (get-in @pane-state [id :pending])] + (swap! pane-state assoc id {:busy? false}) + (when pending (paint-pane! id pending))))))))) + +(rf/reg-fx + :fractal/paint + (fn [{:keys [view canvas split? left right res iter]}] + (let [[W H] canvas] + (when (and (>= W 8) (>= H 8) left) + (let [lw (if split? (js/Math.floor (/ W 2)) W) + job {:view view :canvas canvas :res res :iter iter}] + (paint-pane! :L (assoc job :engine left :slice [0 lw])) + (when (and split? right) + (paint-pane! :R (assoc job :engine right :slice [lw (- W lw)])))))))) + +(rf/reg-fx + :fractal/hash + (fn [[v iter]] (js/history.replaceState nil "" (view/->hash v iter)))) + +(defn- repaint [db] + {:db db + :fx [[:fractal/paint (select-keys db [:view :canvas :split? :left :right :res :iter])] + [:fractal/hash [(:view db) (:iter db)]]]}) + +;; --- events ---------------------------------------------------------------------- +(rf/reg-event-db ::init (fn [_ _] initial-db)) + +(rf/reg-event-fx + ::engines-loaded + (fn [{:keys [db]} [_ loaded]] + (let [names (engines/names loaded)] + (repaint (assoc db :engines names :ready? true + :left (first names) + :right (or (first (filter #(.startsWith % "server-") names)) (first names))))))) + +(rf/reg-event-fx ::canvas-size (fn [{:keys [db]} [_ wh]] (repaint (assoc db :canvas wh)))) + +(rf/reg-event-fx + ::set-view + (fn [{:keys [db]} [_ v]] + (let [merged (merge (:view db) (select-keys v [:cx :cy :scale]))] + (repaint (cond-> db + (view/valid? merged) (assoc :view merged) + (:iter v) (assoc :iter (max 1 (js/Math.round (:iter v))))))))) + +(rf/reg-event-fx + ::zoom-at + (fn [{:keys [db]} [_ px py factor]] + (repaint (update db :view view/zoom-at (:canvas db) [px py] factor)))) + +(rf/reg-event-fx + ::zoom-center + (fn [{:keys [db]} [_ factor]] + (let [[w h] (:canvas db)] + (repaint (update db :view view/zoom-at (:canvas db) [(/ w 2) (/ h 2)] factor))))) + +(rf/reg-event-fx ::pan (fn [{:keys [db]} [_ dx dy]] (repaint (update db :view view/pan [dx dy])))) + +(rf/reg-event-fx ::reset (fn [{:keys [db]} _] (repaint (assoc db :view view/home :tour? false)))) + +(rf/reg-event-fx ::set-iter (fn [{:keys [db]} [_ n]] (repaint (assoc db :iter n)))) +(rf/reg-event-fx ::set-res (fn [{:keys [db]} [_ r]] (repaint (assoc db :res r)))) +(rf/reg-event-fx ::toggle-split (fn [{:keys [db]} _] (repaint (update db :split? not)))) +(rf/reg-event-fx ::set-engine (fn [{:keys [db]} [_ side e]] (repaint (assoc db side e)))) + +(rf/reg-event-db + ::frame-done + (fn [db [_ id {:keys [engine ms iters] :as frame}]] + (-> db + (assoc-in [:panes id] frame) + (update-in [:totals engine] (fnil (fn [[t i f]] [(+ t ms) (+ i iters) (inc f)]) [0 0 0])) + (update-in [:samples engine] #(vec (take-last 120 (conj (or % []) ms))))))) + +(rf/reg-event-db + ::frame-error + (fn [db [_ id engine msg]] (assoc-in db [:panes id] {:engine engine :error msg}))) + +;; Tour: a timer drives ::tour-tick until the user stops it or scale bottoms out. +(defonce ^:private tour-timer (atom nil)) + +(rf/reg-fx + :fractal/tour + (fn [on?] + (when-let [t @tour-timer] (js/clearInterval t) (reset! tour-timer nil)) + (when on? (reset! tour-timer (js/setInterval #(rf/dispatch [::tour-tick]) 50))))) + +(rf/reg-event-fx + ::toggle-tour + (fn [{:keys [db]} _] + (let [on? (not (:tour? db))] + {:db (assoc db :tour? on?) :fx [[:fractal/tour on?]]}))) + +(rf/reg-event-fx ::stop-tour (fn [{:keys [db]} _] {:db (assoc db :tour? false) :fx [[:fractal/tour false]]})) + +(rf/reg-event-fx + ::tour-tick + (fn [{:keys [db]} _] + (let [v (view/tour-step (:view db)) + db' (assoc db :view v :iter (view/tour-iter v (:iter db)))] + (if (< (:scale v) 1e-13) + (-> (repaint (assoc db' :tour? false)) (update :fx conj [:fractal/tour false])) + (repaint db'))))) + +;; --- bench ------------------------------------------------------------------------- +(def bench-views + [["full set" -0.6 0.0 (/ 3.0 256) 256] + ["seahorse valley" -0.745 0.112 (/ 0.008 256) 512] + ["spiral" -0.10109636384562 0.95628651080914 (/ 0.0005 256) 1024] + ["deep pinch" -1.768778833 0.001738996 (/ 1e-6 256) 1024]]) + +(defn parity? [rows views] + (every? (fn [[name]] + (let [its (keep #(when (and (= name (:view %)) (not (:error %))) (:iters %)) rows)] + (and (seq its) (apply = its)))) + views)) + +(defn run-bench! + "Render every view on every engine, one at a time; dispatch each row as it + lands. Resolves to {:w :h :engines :rows :parity}." + [{:keys [engines w h views] :or {w 256 h 192 views bench-views}}] + (rf/dispatch [::bench-start {:w w :h h :engines engines :views views}]) + (let [jobs (for [[name cx cy sc it] views, e engines] [name e {:cx cx :cy cy :scale sc :w w :h h} it])] + (-> (reduce (fn [p [name e pv it]] + (.then p (fn [rows] + (-> (engines/render-on e pv it) + (.then (fn [{:keys [pixels ms iters]}] + {:view name :engine e :ms ms :iters iters + :mits (/ iters ms 1000) :checksum (engines/checksum pixels)})) + (.catch (fn [err] {:view name :engine e :error (.-message err)})) + (.then (fn [row] (rf/dispatch [::bench-row row]) (conj rows row))))))) + (js/Promise.resolve []) + jobs) + (.then (fn [rows] + (let [result {:w w :h h :engines engines :rows rows :parity (parity? rows views)}] + (rf/dispatch [::bench-done (:parity result)]) + result)))))) + +(rf/reg-fx :fractal/bench (fn [opts] (run-bench! opts))) + +(rf/reg-event-fx ::bench (fn [{:keys [db]} _] {:fx [[:fractal/bench {:engines (:engines db)}]]})) +(rf/reg-event-db ::bench-start (fn [db [_ meta]] (assoc db :bench (assoc meta :rows [] :parity nil)))) +(rf/reg-event-db ::bench-row (fn [db [_ row]] (update-in db [:bench :rows] conj row))) +(rf/reg-event-db ::bench-done (fn [db [_ p]] (assoc-in db [:bench :parity] p))) + +;; --- subscriptions --------------------------------------------------------------- +(doseq [k [:view :iter :res :split? :engines :left :right :canvas :panes :totals :samples :bench :tour? :ready?]] + (rf/reg-sub k (fn [db _] (get db k)))) diff --git a/docs/examples/fractal/web/src/fractal/ui.cljs b/docs/examples/fractal/web/src/fractal/ui.cljs new file mode 100644 index 000000000..b20dafbc8 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/ui.cljs @@ -0,0 +1,217 @@ +(ns fractal.ui + "Reagent views. Pointer, wheel and resize input on the canvas dispatch + re-frame events; nothing here touches the view state directly." + (:require [reagent.core :as r] + [re-frame.core :as rf] + [fractal.events :as ev] + [fractal.view :as view])) + +(def palette ["#58a6ff" "#3fb950" "#d29922" "#f78166" "#bc8cff" "#39c5cf" + "#ff7b72" "#a5d6ff" "#7ee787" "#e3b341" "#ffa198" "#d2a8ff"]) + +(defn color-of [engines e] + (nth palette (mod (max 0 (.indexOf (clj->js engines) e)) (count palette)))) + +(defn- swatch [engines e] [:span {:style {:color (color-of engines e)}} "▬ "]) + +(defn- fmt [n d] (.toFixed n d)) + +;; --- header ----------------------------------------------------------------------- +(defn- engine-select [side] + (let [engines @(rf/subscribe [:engines]) + cur @(rf/subscribe [side])] + [:select {:id (if (= side :left) "engL" "engR") :value (or cur "") + :on-change #(rf/dispatch [::ev/set-engine side (.. % -target -value)])} + (for [e engines] ^{:key e} [:option e])])) + +(defn- coord-inputs [] + (let [draft (r/atom {})] + (fn [] + (let [v @(rf/subscribe [:view]) + shown #(get @draft % (if (= % :scale) (.toExponential (:scale v) 6) (.toPrecision (get v %) 15))) + go! (fn [] + (rf/dispatch [::ev/set-view (into {} (keep (fn [[k s]] (when-let [n (view/finite-num s)] [k n]))) @draft)]) + (reset! draft {}))] + [:div.ctl + (for [[k label] [[:cx "x"] [:cy "y"] [:scale "scale"]]] + ^{:key k} + [:label label " " + [:input {:id (name k) :type "text" :value (shown k) + :on-change #(swap! draft assoc k (.. % -target -value)) + :on-key-down #(when (= "Enter" (.-key %)) (go!))}]]) + [:button {:id "go" :on-click go!} "Go"]])))) + +(defn header [] + (let [iter @(rf/subscribe [:iter]) + split? @(rf/subscribe [:split?]) + tour? @(rf/subscribe [:tour?])] + [:header + [:h1 "fractal zoom"] + [:div.ctl + [:label "left " [engine-select :left]] + [:label "right " [engine-select :right]] + [:button {:id "split" :class (when split? "active") :on-click #(rf/dispatch [::ev/toggle-split])} "Split"]] + [:div.ctl + [:label "iter " + [:input {:id "iter" :type "range" :min 32 :max 4096 :step 32 :value iter + :on-change #(rf/dispatch [::ev/set-iter (js/Number (.. % -target -value))])}] + [:span#iterval iter]] + [:label "res " + [:select {:id "res" :on-change #(rf/dispatch [::ev/set-res (js/Number (.. % -target -value))])} + [:option {:value 1} "1"] [:option {:value 0.5} "1/2"] [:option {:value 0.25} "1/4"]]]] + [:div.ctl + [:button {:id "zin" :title "zoom in (+)" :on-click #(rf/dispatch [::ev/zoom-center 2])} "+"] + [:button {:id "zout" :title "zoom out (-)" :on-click #(rf/dispatch [::ev/zoom-center 0.5])} "−"] + [:button {:id "reset" :on-click #(rf/dispatch [::ev/reset])} "Reset"] + [:button {:id "tour" :class (when tour? "active") :on-click #(rf/dispatch [::ev/toggle-tour])} "Tour"] + [:button {:id "bench" :on-click #(rf/dispatch [::ev/bench])} "Bench"]] + [coord-inputs]])) + +;; --- the canvas ------------------------------------------------------------------- +(defn- canvas-point [^js c e] + (let [rect (.getBoundingClientRect c)] + [(* (- (.-clientX e) (.-left rect)) (/ (.-width c) (.-width rect))) + (* (- (.-clientY e) (.-top rect)) (/ (.-height c) (.-height rect)))])) + +(defn- attach-input! [^js c] + (let [pointers (atom {})] + (.addEventListener c "wheel" + (fn [e] (.preventDefault e) + (let [[x y] (canvas-point c e)] + (rf/dispatch [::ev/zoom-at x y (js/Math.pow 1.0015 (- (.-deltaY e)))]))) + #js {:passive false}) + (.addEventListener c "dblclick" + (fn [e] (.preventDefault e) + (let [[x y] (canvas-point c e)] + (rf/dispatch [::ev/zoom-at x y (if (.-shiftKey e) 0.5 2)])))) + (.addEventListener c "pointerdown" + (fn [e] (.setPointerCapture c (.-pointerId e)) + (swap! pointers assoc (.-pointerId e) (canvas-point c e)) + (rf/dispatch [::ev/stop-tour]))) + (.addEventListener c "pointermove" + (fn [e] + (when-let [[px py] (get @pointers (.-pointerId e))] + (let [p (canvas-point c e)] + (if (= 2 (count @pointers)) + (let [[a b] (vals @pointers) + d0 (js/Math.hypot (- (first a) (first b)) (- (second a) (second b))) + _ (swap! pointers assoc (.-pointerId e) p) + [a' b'] (vals @pointers) + d1 (js/Math.hypot (- (first a') (first b')) (- (second a') (second b')))] + (when (pos? d0) + (rf/dispatch [::ev/zoom-at (/ (+ (first a') (first b')) 2) + (/ (+ (second a') (second b')) 2) (/ d1 d0)]))) + (do (swap! pointers assoc (.-pointerId e) p) + (rf/dispatch [::ev/pan (- px (first p)) (- py (second p))]))))))) + (doseq [t ["pointerup" "pointercancel"]] + (.addEventListener c t #(swap! pointers dissoc (.-pointerId %)))) + (let [fit! (fn [] + (let [dpr (min (or js/window.devicePixelRatio 1) 2) + w (min 1024 (js/Math.floor (* dpr (.-clientWidth c)))) + h (min 1024 (js/Math.floor (* dpr (.-clientHeight c))))] + (when (or (not= w (.-width c)) (not= h (.-height c))) + (set! (.-width c) w) + (set! (.-height c) h)) + (rf/dispatch [::ev/canvas-size [w h]])))] + (.observe (js/ResizeObserver. fit!) c) + (fit!)))) + +(defn- hud-text [{:keys [engine ms iters w h error]} v iter] + (if error + (str engine ": " error) + (str engine " " (fmt ms 1) " ms " (fmt (/ iters ms 1000) 1) " Miter/s\n" + "x " (.toPrecision (:cx v) 15) "\ny " (.toPrecision (:cy v) 15) "\n" + "scale " (.toExponential (:scale v) 3) " zoom " (.toExponential (view/zoom-level v) 2) "x\n" + "iter " iter " " w "x" h))) + +(defn- mount-canvas! [c] + (when (and c (not= c @ev/canvas-el)) + (reset! ev/canvas-el c) + (attach-input! c))) + +(defn stage [] + (r/create-class + {:display-name "stage" + :reagent-render + (fn [] + (let [panes @(rf/subscribe [:panes]) + split? @(rf/subscribe [:split?]) + v @(rf/subscribe [:view]) + iter @(rf/subscribe [:iter])] + [:div#wrap + [:canvas#view {:ref mount-canvas!}] + [:div#divider {:style {:display (if split? "block" "none")}}] + [:div.hud#hudL (when-let [p (:L panes)] (hud-text p v iter))] + [:div.hud#hudR {:style {:display (if split? "block" "none")}} + (when-let [p (:R panes)] (hud-text p v iter))]]))})) + +;; --- stats -------------------------------------------------------------------------- +(defn- draw-chart! [^js c samples engines] + (let [g (.getContext c "2d") + w (.-width c) h (.-height c) + max-ms (reduce max 1 (mapcat val samples))] + (.clearRect g 0 0 w h) + (doseq [[e s] samples :when (> (count s) 1)] + (set! (.-strokeStyle g) (color-of engines e)) + (.beginPath g) + (doseq [[i ms] (map-indexed vector s)] + (let [x (* w (/ i 119)) y (- h 2 (* (- h 6) (/ ms max-ms)))] + (if (zero? i) (.moveTo g x y) (.lineTo g x y)))) + (.stroke g)))) + +(defn chart [] + (let [el (atom nil)] + (r/create-class + {:display-name "chart" + :component-did-update (fn [this] (let [[_ s e] (r/argv this)] (when @el (draw-chart! @el s e)))) + :reagent-render (fn [_samples _engines] + [:canvas#chart {:ref #(reset! el %) :width 600 :height 90}])}))) + +(defn live-stats [] + (let [engines @(rf/subscribe [:engines]) + totals @(rf/subscribe [:totals]) + samples @(rf/subscribe [:samples])] + [:div + [:table#live + [:thead [:tr [:th "engine"] [:th "ms/frame"] [:th "Miter/s"] [:th "frames"]]] + [:tbody + (for [e engines :let [[ms iters frames] (get totals e)] :when frames] + ^{:key e} [:tr [:td [swatch engines e] e] [:td (fmt (/ ms frames) 1)] + [:td (fmt (/ iters ms 1000) 2)] [:td frames]])]] + [chart samples engines] + [:p.note "Wheel or " [:kbd "+"] "/" [:kbd "-"] " zoom at the cursor, drag or arrows/" [:kbd "WASD"] + " slide x and y, double-click zooms in (shift: out), two fingers pinch. The URL hash keeps the view."]])) + +(defn bench-table [] + (let [engines @(rf/subscribe [:engines]) + {:keys [w h views rows parity] :as bench} @(rf/subscribe [:bench])] + [:div.scroll + [:div#benchmeta.note + (if-not bench + "Bench renders a fixed view list on every engine: ms, Miter/s, iteration total (must match) and checksum." + (str w "x" h " per cell. Server ms = guest render only. Green = iteration total and checksum equal the first engine's." + (case parity nil " Running..." true " Parity: all engines agree." " Parity: MISMATCH.")))] + (when bench + [:table#benchtab + [:thead [:tr [:th "engine"] (for [[n] views] ^{:key n} [:th n])]] + [:tbody + (for [e (:engines bench)] + ^{:key e} + [:tr [:td [swatch engines e] e] + (for [[n] views + :let [r (first (filter #(and (= n (:view %)) (= e (:engine %))) rows)) + ref (first (filter #(and (= n (:view %)) (not (:error %))) rows))]] + ^{:key n} + [:td (cond + (nil? r) "…" + (:error r) [:span.bad {:title (:error r)} "error"] + :else [:span {:title (str "iters " (:iters r) " checksum " (:checksum r))} + (fmt (:ms r) 1) " ms " + [:span {:class (if (and (= (:iters ref) (:iters r)) (= (:checksum ref) (:checksum r))) "ok" "bad")} + (fmt (:mits r) 1) " Mi/s"]])])])]])])) + +(defn root [] + [:<> + [header] + [stage] + [:div#stats [live-stats] [bench-table]]]) diff --git a/docs/examples/fractal/web/src/fractal/view.cljs b/docs/examples/fractal/web/src/fractal/view.cljs new file mode 100644 index 000000000..2d4151ecc --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/view.cljs @@ -0,0 +1,70 @@ +(ns fractal.view + "The complex-plane view as a value: {:cx :cy :scale}, where scale is the + plane distance of one canvas pixel. Pure functions only.") + +(def home {:cx -0.6 :cy 0.0 :scale (/ 3.0 800)}) + +(def tour-target {:cx -0.743643887037151 :cy 0.13182590420533}) + +(defn zoom-at + "Zoom by factor (>1 in) keeping the plane point under canvas pixel (px, py) + fixed, on a canvas of w x h pixels." + [{:keys [cx cy scale] :as v} [w h] [px py] factor] + (assoc v + :cx (+ cx (* (- px (/ w 2)) scale (- 1 (/ 1 factor)))) + :cy (+ cy (* (- py (/ h 2)) scale (- 1 (/ 1 factor)))) + :scale (/ scale factor))) + +(defn pan + "Slide the view by (dx, dy) canvas pixels." + [{:keys [scale] :as v} [dx dy]] + (-> v (update :cx + (* dx scale)) (update :cy + (* dy scale)))) + +(defn zoom-level [{:keys [scale]}] (/ (:scale home) scale)) + +(defn pane-view + "The view one pane renders: pane (dx, dw) of a W x H canvas, at resolution + factor res. Panes are slices of one shared view, so a split seam is + continuous." + [{:keys [cx cy scale]} [W H] [dx dw] res] + {:cx (+ cx (* (- (+ dx (/ dw 2)) (/ W 2)) scale)) + :cy cy + :scale (/ scale res) + :w (max 1 (js/Math.round (* dw res))) + :h (max 1 (js/Math.round (* H res)))}) + +(defn tour-step + "One frame of the tour: ease toward the target and zoom 4 percent." + [v] + (-> v + (update :cx #(+ % (* 0.08 (- (:cx tour-target) %)))) + (update :cy #(+ % (* 0.08 (- (:cy tour-target) %)))) + (update :scale * 0.96))) + +(defn tour-iter + "Iteration cap that keeps detail as the tour zooms deeper." + [v current] + (min 4096 (max current (js/Math.round (+ 200 (* 60 (js/Math.log2 (zoom-level v)))))))) + +(defn finite-num [s] + (let [n (js/Number s)] + (when (and (some? s) (not= s "") (js/Number.isFinite n)) n))) + +(defn valid? [{:keys [cx cy scale]}] + (and (js/Number.isFinite cx) (js/Number.isFinite cy) (js/Number.isFinite scale) (pos? scale))) + +(defn ->hash [{:keys [cx cy scale]} iter] + (str \# (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :iter iter}))) + +(defn <-hash + "Parse a location hash into {:view {...} :iter n}; keys absent or invalid + in the hash are absent from the result." + [hash] + (let [p (js/URLSearchParams. (subs (or hash "") 1)) + n #(when (.has p %) (finite-num (.get p %))) + v (cond-> {} + (n "cx") (assoc :cx (n "cx")) + (n "cy") (assoc :cy (n "cy")) + (some-> (n "scale") pos?) (assoc :scale (n "scale")))] + (cond-> {:view v} + (n "iter") (assoc :iter (max 1 (js/Math.round (n "iter"))))))) From 87a5b62a3fab25d68c709cf494047175de2a88bc Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 20:36:12 -0300 Subject: [PATCH 40/51] docs(examples): fractal README matches the shipped ABI, engines and gate Rewrite the README around the final ABI (set_view then 3-arg render), the jit / jit-simd / interp engines, the routes, bench.clj and the e2e gate, and drop the stale 6-param section and a duplicated Run block. server.clj requires clojure.string instead of reaching it unqualified. --- docs/examples/fractal/README.md | 143 ++++++++++++++++--------------- docs/examples/fractal/index.html | 2 +- docs/examples/fractal/server.clj | 8 +- 3 files changed, 80 insertions(+), 73 deletions(-) diff --git a/docs/examples/fractal/README.md b/docs/examples/fractal/README.md index 30287c488..0434485af 100644 --- a/docs/examples/fractal/README.md +++ b/docs/examples/fractal/README.md @@ -1,82 +1,93 @@ -# Fractal zoom: one kernel.wasm, three engines - -A Mandelbrot explorer where the **same** WebAssembly module renders on three -engines at once, and the page compares them live: - -- **Browser**: the tab instantiates `/kernel.wasm` itself (V8 or - SpiderMonkey JIT, plus SIMD when the module ships `render_simd`). -- **Server-jit**: native cljw renders the tile with zwasm's JIT and returns - raw RGBA bytes. -- **Server-interp**: the same, on zwasm's tree-walking interpreter. - -The split is the retired cw-playground's shape (docs/works/demos.md section -4): cljw's own HTTP server (`cljw.http.server/run-server`) serves the page and -the compute; no JVM, no node, no CDN. +# Fractal zoom: one kernel.wasm, server and browser + +A Mandelbrot explorer where the same WebAssembly module renders in the +browser and on native cljw, and the page compares the engines live. It uses +the retired cw-playground's split (`docs/works/demos.md` section 4): cljw's own +HTTP server (`cljw.http.server/run-server`) serves the page and renders tiles +with zwasm; the browser also instantiates the identical `kernel.wasm`. No JVM, +no Node, no CDN. + +| file | role | +|---|---| +| [`kernel.zig`](./kernel.zig) | the kernel; its header holds the exact build recipes | +| `kernel.wasm` | scalar build, no imports, runs on every engine | +| `kernel_simd.wasm` | simd128 build, for JIT engines only | +| [`server.clj`](./server.clj) | page, kernel and tile server | +| [`index.html`](./index.html) | the viewer, one self-contained file | +| [`bench.clj`](./bench.clj) | native benchmark with strict cross-engine parity | +| [`verify.mjs`](./verify.mjs) | Node oracle for the same parity, byte for byte | ## Run ```sh -zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary -cljw docs/examples/fractal/server.clj [port] # default 8080 +zig build -Dwasm -Doptimize=ReleaseSafe +zig-out/bin/cljw docs/examples/fractal/server.clj [port] # default 8080 ``` -Open http://localhost:8080. The server loads one zwasm instance per engine at -startup and reuses it across requests. +Open http://localhost:8080. The server loads one instance per engine at +startup and reuses it; each request runs `set_view` then `render` under that +instance's lock. -`KERNEL=/path/to/other.wasm cljw docs/examples/fractal/server.clj` overrides -the module. When the kernel ships SIMD as a sibling file instead of an export, -drop `kernel_simd.wasm` next to it; the JIT leg picks it up. The interpreter -cannot run SIMD (v128), so `server-interp` always uses the scalar `render`. +| route | answer | +|---|---| +| `GET /` | the page | +| `GET /kernel.wasm` | the scalar kernel, `application/wasm` | +| `GET /engines` | the server engines present, e.g. `{"engines":["jit","interp","jit-simd"]}` | +| `GET /tile?cx=&cy=&scale=&w=&h=&iter=&engine=` | `w*h*4` raw RGBA bytes, with `X-Render-Ms` and `X-Iters` headers | -## The kernel ABI +`engine` is `jit` (scalar kernel on zwasm's JIT), `jit-simd` (`kernel_simd.wasm` +on the JIT, when the file is present) or `interp` (scalar kernel on zwasm's +interpreter, which has no SIMD). `KERNEL=/path/to/other.wasm` overrides the +scalar module. + +The tile body is a `byte-array` from `wasm/mem-read-array`, served verbatim: +`cljw.http.server` copies a primitive byte-array `:body` as is. + +## The page -`kernel.wasm` has no imports and exports: +Mouse wheel zooms around the cursor, drag pans, double-click zooms in, the +slider sets the iteration cap, Reset returns home. Modes: Browser, each server +engine, and Split (browser on the left half, server on the right, same view). +Bench renders a fixed view list on every engine and tabulates the results. + +- **ms/frame**: for the browser, wall time of the `render` call. For a server + engine, the guest render time only (`X-Render-Ms`), so network and copy time + are excluded and the comparison is engine against engine. +- **Miter/s**: total Mandelbrot iterations (`render`'s return value) divided + by that time. It moves with zoom depth and the iteration cap, so compare + engines within one view, not views against each other. + +## The kernel ABI | export | signature | meaning | |---|---|---| | `memory` | | linear memory holding the framebuffer | | `fb_ptr` | `() -> i32` | byte address of the RGBA8 framebuffer | -| `fb_cap` | `() -> i32` | framebuffer capacity in pixels (1048576) | -| `set_view` | `(f64 f64 f64) -> i32` | stores cx, cy, scale; 0, or -1 when a coordinate is not finite | -| `render` | `(i32 i32 i32) -> i64` | writes `w*h` RGBA pixels at `fb_ptr` for the stored view, returns total iterations | -| `render_simd` | `(i32 i32 i32) -> i64` | SIMD twin, ships only in `kernel_simd.wasm` | +| `fb_cap` | `() -> i32` | capacity in pixels (1048576) | +| `set_view` | `(f64 f64 f64) -> i32` | stores cx, cy, scale; 0, or -1 when one is not finite | +| `render` | `(i32 i32 i32) -> i64` | writes `w*h` pixels for the stored view, returns total iterations, -1 on invalid input | +| `render_simd` | `(i32 i32 i32) -> i64` | same output; vectorised in `kernel_simd.wasm`, scalarised in `kernel.wasm` | -Pixel `(px, py)` is the complex point `(cx + (px - w/2) * scale, cy + (py - h/2) * scale)`. +Pixel `(px, py)` is the point `(cx + (px - w/2) * scale, cy + (py - h/2) * scale)`. -The two-call shape (view, then render) keeps every export at or below the -5-parameter dispatch limit of zwasm's JIT: a single 6-parameter `render` -traps at invoke (measured, gaps.zig `jit_wide_arity`). +The shape is set by two gaps in cljw's JIT call boundary (`src/runtime/cljw/wasm/gaps.zig`): +an export with more than 5 parameters does not dispatch (`jit_wide_arity`), and +neither does a zero-result export of this shape (`jit_void_window`). Hence a +separate `set_view` that returns `i32`. Both are tracked by +CLJW-WASM-JIT-CALL-SHAPES. -## Run +## Bench ```sh -zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary -cljw docs/examples/fractal/server.clj [port] # default 8080 +zig-out/bin/cljw docs/examples/fractal/bench.clj ``` -## The page +It renders a view list on the scalar kernel (`:jit`, `:interp`) and the simd128 +kernel (`:jit`), asserts identical iteration totals and framebuffer checksums +across all of them, and writes `out.ppm` (ignored by git). -Mouse wheel zooms around the cursor, drag pans, double-click zooms in, the -slider sets the iteration cap, Reset returns home. Modes: Browser, -Server-jit, Server-interp, and Split (browser on the left half, server-jit on -the right, same view). All of it is one self-contained file, works at phone -width, dark by default. - -## The numbers - -- **ms/frame**: for the browser leg, wall time of the `render` call. For the - server legs, the guest render time only (the `X-Render-Ms` response header); - network and byte-copy time are excluded so the comparison is engine against - engine. -- **Miter/s**: total Mandelbrot iterations (the `i64` `render` returns) - divided by that time. It scales with both zoom depth and the iteration cap, - so compare engines within one view, not views against each other. -- The rolling chart plots ms/frame per engine over the last 120 frames. -- **Bench** renders a fixed list of views at one size on all three engines and - tabulates ms and Miter/s, handy on a fresh load before you touch anything. - -Measured on the author's x86_64 host (512x512 full set, iter 256; ratios are -the stable part, absolutes are not): +Measured on an x86_64 Linux host under load, 512x512 full set, iter 256 +(ratios are the stable part, absolutes are not): | engine | ms/frame | Miter/s | |---|---|---| @@ -84,16 +95,12 @@ the stable part, absolutes are not): | zwasm JIT, simd128 | 589 | 15.8 | | zwasm interpreter | 3717 | 2.5 | -Two honest edges: on zwasm's JIT the SIMD build is SLOWER than scalar (the -per-call v128 dispatch costs more than the vectorisation wins at this tile -size), and the interpreter trails the JIT by roughly 13x, in line with cljw's -own FFI measurement of ~15x on compute-bound bodies. The browser JIT (V8 / -SpiderMonkey) is where SIMD pays off normally; compare it against server-jit -in the page itself. +On this view the simd128 build is slower than scalar on zwasm's JIT, and the +interpreter is about 13x behind the JIT. Compare the browser engine against +these in the page. -## Known engine edges +## Gate -`render` takes 6 parameters. zwasm's JIT is listed as possibly not dispatching -exports with more than 5; if the JIT leg traps with that diagnosis, the remedy -is structural (the kernel packs params) or the JIT leg falls back to interp. -The trap message names the shape and the option. +`test/e2e/phase16_fractal_kernel.sh` runs the parity assertions on the +committed artifacts, rebuilds both from the recipes in `kernel.zig`, and +requires byte equality with the committed files. diff --git a/docs/examples/fractal/index.html b/docs/examples/fractal/index.html index c70fc4207..8cde2b9d7 100644 --- a/docs/examples/fractal/index.html +++ b/docs/examples/fractal/index.html @@ -299,7 +299,7 @@

fractal zoom

]; async function bench() { const out = document.getElementById("benchout"); - out.textContent = "benching..."; + out.textContent = "benching"; const engines = ENGINES(); const w = Math.min(canvas.width, 512), h = Math.min(canvas.height, 384); const rows = [["view".padEnd(16), ...engines.map(e => e.padStart(22))].join("")]; diff --git a/docs/examples/fractal/server.clj b/docs/examples/fractal/server.clj index 17e5d4bc7..96eb64b27 100644 --- a/docs/examples/fractal/server.clj +++ b/docs/examples/fractal/server.clj @@ -21,12 +21,12 @@ ;; GET /engines answers which of these exist, e.g. {"engines":["jit","interp"]}. ;; ;; Binary bodies: cljw.http.server accepts a primitive byte-array :body and -;; copies it verbatim, so tiles and the wasm module itself cross as raw bytes -;; (a String cannot carry arbitrary binary: its producers normalise to UTF-8). +;; copies it verbatim, so tiles and the wasm module itself cross as raw bytes. (ns fractal.server (:require [cljw.http.server :as http] - [clojure.java.io :as io])) + [clojure.java.io :as io] + [clojure.string :as str])) (def dir (.getParent (io/file *file*))) (def kernel-path (or (System/getenv "KERNEL") (str dir "/kernel.wasm"))) @@ -126,7 +126,7 @@ "/engines" {:status 200 :headers {"content-type" "application/json"} - :body (str "{\"engines\":[" (clojure.string/join "," (map #(str "\"" % "\"") engines)) "]}")} + :body (str "{\"engines\":[" (str/join "," (map #(str "\"" % "\"") engines)) "]}")} "/tile" (try From 91c27b266ec524eb220f3b2109f9f06d1d88f593 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 20:36:13 -0300 Subject: [PATCH 41/51] test(e2e): gate the fractal example server over real HTTP Starts docs/examples/fractal/server.clj on a fixed port and checks /engines, that /kernel.wasm equals the committed file, that /tile returns exactly w*h*4 bytes with X-Render-Ms and X-Iters on jit, interp and jit-simd with identical pixels and iteration totals, and that an unknown engine is a 400. Serial step (fixed port). --- test/e2e/phase16_fractal_server.sh | 69 ++++++++++++++++++++++++++++++ test/run_all.sh | 4 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100755 test/e2e/phase16_fractal_server.sh diff --git a/test/e2e/phase16_fractal_server.sh b/test/e2e/phase16_fractal_server.sh new file mode 100755 index 000000000..3a617cd16 --- /dev/null +++ b/test/e2e/phase16_fractal_server.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# e2e-process-boundary: docs/examples/fractal/server.clj over real HTTP. +# Starts the demo server on a fixed port, then checks the routes the page +# depends on: /engines, /kernel.wasm (bytes equal to the committed file) and +# /tile on every engine (exactly w*h*4 bytes, X-Render-Ms and X-Iters set, +# identical pixels and iteration totals across engines). Serial: fixed port. +# Correctness only, no timing thresholds. +set -euo pipefail +cd "$(dirname "$0")/../.." +BIN="${CLJW_BIN:-zig-out/bin/cljw}" +fail() { echo "FAIL fractal-server: $1" >&2; exit 1; } +run_bounded() { + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" + else "$@"; fi +} +command -v curl >/dev/null 2>&1 || { echo "SKIP fractal-server (curl unavailable)"; exit 0; } +version="$(run_bounded 10 "$BIN" --version)" || fail "cannot query $BIN" +if ! grep -q wasm <<<"$version"; then + echo "SKIP fractal-server: cljw was built without -Dwasm ($version)" + exit 0 +fi + +DEMO="docs/examples/fractal" +PORT=8159 +scratch="$(mktemp -d "${TMPDIR:-/tmp}/cljw_fractal_srv_XXXXXX")" +run_bounded 120 "$BIN" "$DEMO/server.clj" "$PORT" >"$scratch/server.log" 2>&1 & +SRV=$! +trap 'kill "$SRV" 2>/dev/null || true; rm -rf "$scratch"' EXIT + +up=0 +for _ in $(seq 1 100); do + if curl -s -o /dev/null "http://127.0.0.1:$PORT/engines" 2>/dev/null; then up=1; break; fi + sleep 0.1 +done +[[ "$up" == 1 ]] || fail "server did not come up on :$PORT +$(cat "$scratch/server.log")" + +engines="$(curl -s "http://127.0.0.1:$PORT/engines")" +[[ "$engines" == '{"engines":["jit","interp","jit-simd"]}' ]] || fail "/engines: got '$engines'" +echo "PASS fractal-server-engines -> $engines" + +curl -s -o "$scratch/kernel.wasm" "http://127.0.0.1:$PORT/kernel.wasm" +cmp -s "$scratch/kernel.wasm" "$DEMO/kernel.wasm" || fail "/kernel.wasm differs from the committed file" +echo "PASS fractal-server-kernel -> $(wc -c <"$DEMO/kernel.wasm" | tr -d ' ') bytes, identical" + +W=64; H=48 +ref_sum=""; ref_iters="" +for e in jit interp jit-simd; do + curl -s -D "$scratch/h.$e" -o "$scratch/t.$e" \ + "http://127.0.0.1:$PORT/tile?cx=-0.5&cy=0&scale=0.05&w=$W&h=$H&iter=128&engine=$e" + len=$(wc -c <"$scratch/t.$e" | tr -d ' ') + [[ "$len" == $((W * H * 4)) ]] || fail "/tile $e: $len bytes, want $((W * H * 4))" + grep -qi '^x-render-ms:' "$scratch/h.$e" || fail "/tile $e: no X-Render-Ms header" + iters=$(grep -i '^x-iters:' "$scratch/h.$e" | tr -d '\r' | awk '{print $2}') + [[ -n "$iters" && "$iters" -gt 0 ]] || fail "/tile $e: bad X-Iters '$iters'" + sum=$(cksum <"$scratch/t.$e" | awk '{print $1}') + if [[ -z "$ref_sum" ]]; then ref_sum="$sum"; ref_iters="$iters" + else + [[ "$sum" == "$ref_sum" ]] || fail "/tile $e pixels differ from jit" + [[ "$iters" == "$ref_iters" ]] || fail "/tile $e X-Iters $iters, jit had $ref_iters" + fi +done +echo "PASS fractal-server-tiles -> ${W}x${H} RGBA on jit, interp, jit-simd, identical (X-Iters $ref_iters)" + +code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/tile?engine=nope") +[[ "$code" == "400" ]] || fail "/tile unknown engine: status $code, want 400" +echo "PASS fractal-server-bad-engine -> 400" diff --git a/test/run_all.sh b/test/run_all.sh index 2a34fedc5..e88ffbecb 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -126,7 +126,7 @@ fi # at their end, so running them serially in the registration pass — # before the flush — leaves the pool a correct, stable binary. # These run serially BEFORE the parallel flush, on a quiet machine. -SERIAL_STEPS="e2e_phase8_exit_smoke,e2e_phase4_cli,e2e_phase4_exit,e2e_phase4_exit_codes,e2e_phase16_http_server,e2e_phase16_http_client" +SERIAL_STEPS="e2e_phase8_exit_smoke,e2e_phase4_cli,e2e_phase4_exit,e2e_phase4_exit_codes,e2e_phase16_http_server,e2e_phase16_http_client,e2e_phase16_fractal_server" declare -a E2E_QUEUE=() # Steps that MUST run on every pass, even under --resume. These are @@ -708,6 +708,8 @@ run_step "e2e_phase16_wasm_polyglot" "bash test/e2e/phase16_wasm_polyglot run_step "e2e_phase16_zig_native" "bash test/e2e/phase16_zig_native.sh" # Fractal example: committed scalar/SIMD parity and reproducible header recipes. run_step "e2e_phase16_fractal_kernel" "bash test/e2e/phase16_fractal_kernel.sh" +# Fractal example server: routes, verbatim wasm bytes, tiles on every engine. +run_step "e2e_phase16_fractal_server" "bash test/e2e/phase16_fractal_server.sh" # The reswap coverage moved to suites/wasm-require-component-reswap-test: it # asserts values, not the process boundary, so it runs in-process now. run_step "e2e_phase15_deferred_host_ref" "bash test/e2e/phase15_deferred_host_ref.sh" From b151293258555f79954cd345ca48e37f9a68cb60 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 20:42:32 -0300 Subject: [PATCH 42/51] feat(examples): JVM fractal engines behind one renderer port, tested by the trifecta jvm/ is CPPB-stratified around fractal.renderer/IRenderer: - domain: fractal.tile (Tile, EscapeField), fractal.palette (the one colouring rule, as data), fractal.golden (golden.edn, pure verdicts). - adapters: raster deftm escape loop (all cores, 1 thread), raster SIMD lanes (par/map + blend), CUDA fp64 (--fmad=false, golden-exact) and fp32 via NVRTC to CUBIN on a dedicated GPU thread, plain-Clojure oracle. - collect: fractal.engines discovers renderers; an unavailable one (no GPU) is data, not a start-up failure. - boundary: fractal.http (tile protocol + CORS), fractal.main, fractal.bench. Renderers compute only the escape field; the palette runs once above the port, so every exact renderer paints identical bytes. Tests: golden (every exact renderer vs golden.edn), test.check properties (renderers vs the oracle bit for bit, palette and tile laws, HTTP frame), mutation (6 escape and 4 palette mutants must be killed). The mutation suite exposed that no golden pixel sat on |z|^2 = 4, so `<` vs `<=` passed; the new "tip" view puts a pixel on c = -2 and kills it. "deep" (1024x768, 4096 iter, 2.39e9 iterations) is the heavy view. --- docs/examples/fractal/golden.edn | 12 ++- .../fractal/jvm/src/fractal/bench.clj | 58 +++++++++++++ .../examples/fractal/jvm/src/fractal/http.clj | 69 +++++++++++++++ .../examples/fractal/jvm/src/fractal/main.clj | 17 ++++ .../fractal/jvm/src/fractal/palette.clj | 78 ++++++++++++----- .../fractal/jvm/src/fractal/renderer.clj | 5 +- .../fractal/jvm/src/fractal/renderer/cuda.clj | 8 +- .../jvm/src/fractal/renderer/oracle.clj | 35 ++++---- .../jvm/src/fractal/renderer/raster.clj | 2 +- .../jvm/src/fractal/renderer/raster_simd.clj | 2 +- .../fractal/jvm/test/fractal/golden_test.clj | 31 +++++++ .../jvm/test/fractal/mutation_test.clj | 65 ++++++++++++++ .../jvm/test/fractal/property_test.clj | 85 +++++++++++++++++++ 13 files changed, 418 insertions(+), 49 deletions(-) create mode 100644 docs/examples/fractal/jvm/src/fractal/bench.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/http.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/main.clj create mode 100644 docs/examples/fractal/jvm/test/fractal/golden_test.clj create mode 100644 docs/examples/fractal/jvm/test/fractal/mutation_test.clj create mode 100644 docs/examples/fractal/jvm/test/fractal/property_test.clj diff --git a/docs/examples/fractal/golden.edn b/docs/examples/fractal/golden.edn index 33393a08e..a8b8e794a 100644 --- a/docs/examples/fractal/golden.edn +++ b/docs/examples/fractal/golden.edn @@ -3,11 +3,19 @@ ;; (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels). ;; Measured once on cljw (Zig, C, Rust wasm on zwasm jit and interp, plain ;; Clojure); every other engine must reproduce them exactly (fp64, no FMA). -;; :ci marks the small views the e2e gate runs. +;; :ci marks the small views the e2e gate runs. :heavy views (billions of +;; iterations) run only where asked (HEAVY=1 on cljw); "deep" was measured on +;; the JVM oracle and confirmed by raster-jvm and cuda-f64. {:views [{:name "full" :cx -0.5 :cy 0.0 :scale 0.0068359375 :w 512 :h 512 :iter 256 :iters 9284549 :checksum 1665484798} {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000 :iters 2152075 :checksum 2115401538} {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80 - :iters 15979 :checksum 59081420 :ci true}]} + :iters 15979 :checksum 59081420 :ci true} + ;; Pixel (4, 4) is exactly c = -2: z = 0, -2, 2, 2, ... sits ON |z|^2 = 4, so + ;; only this view tells the bailout `<= 4` from `< 4` (mutation suite). + {:name "tip" :cx -2.0 :cy 0.0 :scale 0.25 :w 8 :h 8 :iter 50 + :iters 317 :checksum 2086288669 :ci true} + {:name "deep" :cx -0.743643887037151 :cy 0.13182590420533 :scale 2.0e-9 :w 1024 :h 768 :iter 4096 + :iters 2391873304 :checksum 1958721964 :heavy true}]} diff --git a/docs/examples/fractal/jvm/src/fractal/bench.clj b/docs/examples/fractal/jvm/src/fractal/bench.clj new file mode 100644 index 000000000..4d2e07440 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/bench.clj @@ -0,0 +1,58 @@ +(ns fractal.bench + "Every available JVM renderer on every golden view. Pipeline: measure rows, + judge them against golden.edn (pure), render a markdown table (pure). + Boundary: -main prints, and exits 1 on any golden mismatch." + (:require [clojure.string :as str] + [fractal.engines :as engines] + [fractal.golden :as golden] + [fractal.palette :as palette] + [fractal.renderer :as r])) + +(defn measure + "Best of `reps` frames of renderer rr on golden view v, after `warm` frames." + [rr v {:keys [warm reps] :or {warm 2 reps 5}}] + (let [t (golden/view->tile v)] + (dotimes [_ warm] (r/frame rr t)) + (let [frames (repeatedly reps #(r/frame rr t)) + best (apply min-key :ms frames)] + {:engine (r/id rr) + :parity? (:parity? (r/describe rr)) + :view (:name v) + :ms (:ms best) + :paint-ms (:paint-ms best) + :iters (:iters best) + :checksum (palette/checksum (:rgba best)) + :mismatch (golden/verdict v (:iters best) (palette/checksum (:rgba best)))}))) + +(defn runs? + "Whether renderer rr runs view v: heavy views only on :heavy? renderers." + [rr v] + (or (not (:heavy v)) (:heavy? (r/describe rr)))) + +(defn rows [renderers views opts] + (vec (for [rr renderers v views :when (runs? rr v)] + (measure rr v (if (:heavy v) {:warm 1 :reps 3} opts))))) + +(defn failures + "Rows that must match golden and do not." + [rows] + (filterv #(and (:parity? %) (:mismatch %)) rows)) + +(defn table [rows] + (str/join "\n" + (concat ["| engine | view | ms | Miter/s | golden |" "|---|---|---:|---:|---|"] + (for [{:keys [engine view ms iters parity? mismatch]} rows] + (format "| %s | %s | %.2f | %.1f | %s |" engine view ms (/ iters (* ms 1000.0)) + (cond (not parity?) "n/a (fp32)" mismatch "MISMATCH" :else "ok")))))) + +(defn -main [& _] + (let [{:keys [renderers unavailable]} (engines/discover) + rs (rows renderers (golden/load-views) {}) + bad (failures rs)] + (println "java" (System/getProperty "java.version") + " cores" (.availableProcessors (Runtime/getRuntime))) + (doseq [{:keys [id reason]} unavailable] (println "unavailable:" id "-" reason)) + (println (table rs)) + (if (seq bad) + (do (println "FAIL golden:" (pr-str (map (juxt :engine :view :mismatch) bad))) (shutdown-agents) (System/exit 1)) + (do (println "PASS: every exact engine reproduces golden.edn") (shutdown-agents) (System/exit 0))))) diff --git a/docs/examples/fractal/jvm/src/fractal/http.clj b/docs/examples/fractal/jvm/src/fractal/http.clj new file mode 100644 index 000000000..2077a1bec --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/http.clj @@ -0,0 +1,69 @@ +(ns fractal.http + "Boundary: the tile protocol over the JDK's HttpServer. Same routes and + headers as the cljw server (raw RGBA body, X-Render-Ms, X-Iters), plus CORS, + so the page on the cljw origin calls this server directly. + GET /engines {\"engines\":[...],\"unavailable\":[...]} + GET /tile?cx=&cy=&scale=&w=&h=&iter=&engine=" + (:require [clojure.string :as str] + [fractal.renderer :as r] + [fractal.tile :as tile]) + (:import (com.sun.net.httpserver HttpExchange HttpHandler HttpServer) + (java.net InetSocketAddress URLDecoder) + (java.util.concurrent Executors))) + +(defn query-map [^String raw] + (if (str/blank? raw) + {} + (into {} (keep (fn [pair] + (let [i (.indexOf ^String pair "=")] + (when (pos? i) + [(subs pair 0 i) (URLDecoder/decode (subs pair (inc i)) "UTF-8")])))) + (.split raw "&")))) + +(defn- json-strings [xs] (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) + +(defn engines-json [registry unavailable] + (str "{\"engines\":" (json-strings (keys registry)) + ",\"unavailable\":" (json-strings (map :id unavailable)) "}")) + +(defn respond + "Pure: a request {:path :query} to a response {:status :headers :body bytes}." + [registry unavailable {:keys [path query]}] + (try + (case path + "/engines" {:status 200 :headers {"content-type" "application/json"} + :body (.getBytes ^String (engines-json registry unavailable) "UTF-8")} + "/tile" (let [t (tile/parse-query query) + rid (get query "engine" (first (keys registry)))] + (if-let [rr (get registry rid)] + (let [{:keys [ms iters rgba]} (r/frame rr t)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body rgba}) + {:status 400 :headers {} :body (.getBytes (str "unknown engine " rid) "UTF-8")})) + {:status 404 :headers {} :body (.getBytes "not found\n" "UTF-8")}) + (catch Exception e + {:status 400 :headers {} :body (.getBytes (str "tile error: " (.getMessage e)) "UTF-8")}))) + +(defn- send! [^HttpExchange ex {:keys [status headers ^bytes body]}] + (let [h (.getResponseHeaders ex)] + (.add h "access-control-allow-origin" "*") + (.add h "access-control-expose-headers" "x-render-ms, x-iters") + (doseq [[k v] headers] (.add h k v)) + (.sendResponseHeaders ex status (alength body)) + (with-open [out (.getResponseBody ex)] (.write out body)))) + +(defn start! + "Serve registry on port; returns the HttpServer." + ^HttpServer [port registry unavailable] + (doto (HttpServer/create (InetSocketAddress. (int port)) 0) + (.createContext "/" (reify HttpHandler + (handle [_ ex] + (let [uri (.getRequestURI ex)] + (send! ex (respond registry unavailable + {:path (.getPath uri) :query (query-map (.getRawQuery uri))})))))) + (.setExecutor (Executors/newFixedThreadPool 4)) + (.start))) diff --git a/docs/examples/fractal/jvm/src/fractal/main.clj b/docs/examples/fractal/jvm/src/fractal/main.clj new file mode 100644 index 000000000..9a492ede5 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/main.clj @@ -0,0 +1,17 @@ +(ns fractal.main + "Entry point of the JVM tile server: discover renderers, warm them, serve." + (:require [fractal.engines :as engines] + [fractal.http :as http] + [fractal.renderer :as r] + [fractal.tile :as tile])) + +(def warm-tile (tile/tile {:cx -0.6 :cy 0.0 :scale (/ 3.0 256) :w 256 :h 192 :iter 256})) + +(defn -main [& [port]] + (let [port (if port (Long/parseLong port) 8081) + {:keys [renderers unavailable]} (engines/discover) + registry (r/registry renderers)] + (doseq [rr renderers] (dotimes [_ 2] (r/frame rr warm-tile))) + (http/start! port registry unavailable) + (println (str "fractal JVM engines on http://localhost:" port " " (vec (keys registry)))) + (doseq [{:keys [id reason]} unavailable] (println "unavailable:" id "-" reason)))) diff --git a/docs/examples/fractal/jvm/src/fractal/palette.clj b/docs/examples/fractal/jvm/src/fractal/palette.clj index 92cbd4635..73939dfd6 100644 --- a/docs/examples/fractal/jvm/src/fractal/palette.clj +++ b/docs/examples/fractal/jvm/src/fractal/palette.clj @@ -7,35 +7,67 @@ (def ^:private ^:const inv-ln2 1.4426950408889634) -(defn- channel ^long [^double phase ^long c] - (let [t (+ phase (/ (double c) 3.0)) +(def rule + "The palette as data: phase speed per smooth escape count, and the phase + offset of the red, green and blue waves. Every engine uses this rule." + {:speed 0.025 :offsets [0.0 (/ 1.0 3.0) (/ 2.0 3.0)]}) + +(defn phase + "Palette phase of an escaped pixel: speed * smooth escape count." + ^double [^long n ^double mag2 ^double speed] + (let [bounded (if (Double/isFinite mag2) (Math/max mag2 4.000001) 1.0e300) + l2 (* (Math/log (* 0.5 (* (Math/log bounded) inv-ln2))) inv-ln2)] + (* speed (- (+ (double n) 1.0) l2)))) + +(defn channel + "One channel at phase and offset: a triangular wave, 0..255." + ^long [^double phase ^double offset] + (let [t (+ phase offset) wave (- 1.0 (Math/abs (- (* 2.0 (- t (Math/floor t))) 1.0)))] (long (* wave 255.0)))) (defn rgba - "[r g b a] of one pixel." - [^long n ^long limit ^double mag2] - (if (== n limit) - [0 0 0 255] - (let [bounded (if (Double/isFinite mag2) (Math/max mag2 4.000001) 1.0e300) - l2 (* (Math/log (* 0.5 (* (Math/log bounded) inv-ln2))) inv-ln2) - phase (* 0.025 (- (+ (double n) 1.0) l2))] - [(channel phase 0) (channel phase 1) (channel phase 2) 255]))) + "[r g b a] of one pixel under palette rule r (default `rule`)." + ([n limit mag2] (rgba rule n limit mag2)) + ([{:keys [speed offsets]} n limit mag2] + (if (== (long n) (long limit)) + [0 0 0 255] + (let [ph (phase n mag2 speed)] + (conj (mapv #(channel ph %) offsets) 255))))) + +(defn- put-interior! [^bytes out ^long o] + (aset out o (byte 0)) + (aset out (+ o 1) (byte 0)) + (aset out (+ o 2) (byte 0)) + (aset out (+ o 3) (unchecked-byte 255))) + +(defn- put-colour! [^bytes out ^long o ^double ph ^doubles offsets] + (aset out o (unchecked-byte (channel ph (aget offsets 0)))) + (aset out (+ o 1) (unchecked-byte (channel ph (aget offsets 1)))) + (aset out (+ o 2) (unchecked-byte (channel ph (aget offsets 2)))) + (aset out (+ o 3) (unchecked-byte 255))) (defn paint - "RGBA8 bytes, row-major, for an EscapeField of tile t." - ^bytes [{:keys [^ints counts ^doubles mag2]} t] - (let [n (tile/pixels t) - limit (long (:iter t)) - out (byte-array (* 4 n))] - (dotimes [p n] - (let [[r g b a] (rgba (aget counts p) limit (aget mag2 p)) - o (* 4 p)] - (aset out o (unchecked-byte r)) - (aset out (+ o 1) (unchecked-byte g)) - (aset out (+ o 2) (unchecked-byte b)) - (aset out (+ o 3) (unchecked-byte a)))) - out)) + "RGBA8 bytes, row-major, for an EscapeField of tile t: `rgba` per pixel + under palette rule r (default `rule`), rows painted in parallel." + (^bytes [field t] (paint rule field t)) + (^bytes [r {:keys [^ints counts ^doubles mag2]} t] + (let [w (long (:w t)) + limit (long (:iter t)) + speed (double (:speed r)) + offsets (double-array (:offsets r)) + out (byte-array (* 4 (tile/pixels t)))] + (.forEach (.parallel (java.util.stream.IntStream/range 0 (:h t))) + (reify java.util.function.IntConsumer + (accept [_ y] + (let [row (* (long y) w)] + (dotimes [x w] + (let [p (+ row x) + n (aget counts p)] + (if (== n limit) + (put-interior! out (* 4 p)) + (put-colour! out (* 4 p) (phase n (aget mag2 p) speed) offsets)))))))) + out))) (defn checksum "The golden checksum of an RGBA8 framebuffer." diff --git a/docs/examples/fractal/jvm/src/fractal/renderer.clj b/docs/examples/fractal/jvm/src/fractal/renderer.clj index 3bd427a63..f2381fb56 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer.clj @@ -9,8 +9,9 @@ (defprotocol IRenderer (id [r] "Engine name as the page and the bench show it.") - (describe [r] "{:parity? bool :what str}: :parity? false for renderers whose - arithmetic differs (fp32), excluded from golden checks.") + (describe [r] "{:parity? bool :heavy? bool :what str}. :parity? false for + renderers whose arithmetic differs (fp32), excluded from golden + checks; :heavy? true when billion-iteration views are practical.") (escape! [r t field] "Fill the EscapeField `field` for Tile t.")) (defn frame diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj index edd6fb342..585006963 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj @@ -39,7 +39,7 @@ "-arch=sm_XY for device dev, from its compute capability." [dev] (let [{:keys [compute-capability-major compute-capability-minor]} - (cc/info dev [:compute-capability-major :compute-capability-minor])] + (cc/info dev)] (str "-arch=sm_" compute-capability-major compute-capability-minor))) (defn- open-gpu @@ -53,7 +53,7 @@ ["--fmad=false" (sm-arch dev)]) m (cu/module (cubin prog))] {:ctx ctx - :device (str (cc/info dev :name)) + :device (:name (cc/info dev)) :kernels {:f64 (cu/function m "escape_f64") :f32 (cu/function m "escape_f32")}}))) (defn- escape-on-gpu! @@ -77,12 +77,12 @@ (defrecord Cuda [rid precision gpu worker] r/IRenderer (id [_] rid) - (describe [_] {:parity? (= precision :f64) + (describe [_] {:parity? (= precision :f64) :heavy? true :what (str "CUDA " (name precision) " on " (:device gpu))}) (escape! [_ t field] (on-gpu worker #(escape-on-gpu! (get-in gpu [:kernels precision]) t field)))) -(defonce ^:private shared (delay +(def ^:private shared (delay (let [worker (Executors/newSingleThreadExecutor (reify ThreadFactory (newThread [_ run] diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj index b844f9b9b..f07293e8c 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj @@ -14,22 +14,25 @@ [n (+ (* zr zr) (* zi zi))]))) (defn fill! - "Fill field for tile t with escape; returns field." - [t {:keys [^ints counts ^doubles mag2] :as field}] - (let [{:keys [w h iter]} t] - (dotimes [y h] - (dotimes [x w] - (let [[cr ci] (tile/point t x y) - [n m] (escape cr ci iter) - p (+ (* y w) x)] - (aset counts p (int n)) - (aset mag2 p (double m))))) - field)) + "Fill field for tile t with the escape rule esc (default `escape`); returns field." + ([t field] (fill! escape t field)) + ([esc t {:keys [^ints counts ^doubles mag2] :as field}] + (let [{:keys [w h iter]} t] + (dotimes [y h] + (dotimes [x w] + (let [[cr ci] (tile/point t x y) + [n m] (esc cr ci iter) + p (+ (* y w) x)] + (aset counts p (int n)) + (aset mag2 p (double m))))) + field))) -(defrecord Oracle [] +;; esc is the escape rule as a value: the reference passes `escape`, the +;; mutation suite passes deliberately broken rules. +(defrecord Oracle [rid esc] r/IRenderer - (id [_] "clojure-jvm") - (describe [_] {:parity? true :what "plain Clojure on the JVM, one thread, the reference"}) - (escape! [_ t field] (fill! t field))) + (id [_] rid) + (describe [_] {:parity? true :heavy? false :what "plain Clojure on the JVM, one thread, the reference"}) + (escape! [_ t field] (fill! esc t field))) -(defn renderer [] (->Oracle)) +(defn renderer [] (->Oracle "clojure-jvm" escape)) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj index c6d18228d..28af6540a 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj @@ -46,7 +46,7 @@ (defrecord Raster [rid threads] r/IRenderer (id [_] rid) - (describe [_] {:parity? true + (describe [_] {:parity? true :heavy? (nil? threads) :what (str "raster deftm escape loop, " (if threads (str threads " thread") "all cores"))}) (escape! [_ t field] (escape-tile! threads t field))) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj b/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj index 8ab5e62a6..e8ab4d72f 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster_simd.clj @@ -65,7 +65,7 @@ (defrecord RasterSimd [steps] r/IRenderer (id [_] "raster-simd") - (describe [_] {:parity? true :what "raster par/map on Vector API lanes, one pass per iteration"}) + (describe [_] {:parity? true :heavy? false :what "raster par/map on Vector API lanes, one pass per iteration"}) (escape! [_ t field] (escape-tile! steps t field))) (defn renderer [] (->RasterSimd (compile-steps))) diff --git a/docs/examples/fractal/jvm/test/fractal/golden_test.clj b/docs/examples/fractal/jvm/test/fractal/golden_test.clj new file mode 100644 index 000000000..23c5ab5f5 --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/golden_test.clj @@ -0,0 +1,31 @@ +(ns fractal.golden-test + "Golden: every exact renderer this JVM can run reproduces golden.edn, the + numbers cljw's wasm kernels produced. A renderer that is unavailable here + (no GPU) is reported, not failed." + (:require [clojure.test :refer [deftest is testing]] + [fractal.bench :as bench] + [fractal.engines :as engines] + [fractal.golden :as golden] + [fractal.palette :as palette] + [fractal.renderer :as r])) + +(def found (delay (engines/discover))) + +(deftest every-exact-renderer-reproduces-golden + (let [{:keys [renderers unavailable]} @found] + (doseq [{:keys [id reason]} unavailable] + (println "golden-test: renderer unavailable here:" id "-" reason)) + (is (seq renderers) "at least the CPU renderers are available") + (doseq [rr renderers + :when (:parity? (r/describe rr)) + v (golden/load-views) + :when (bench/runs? rr v)] + (testing (str (r/id rr) " on " (:name v)) + (let [f (r/frame rr (golden/view->tile v))] + (is (nil? (golden/verdict v (:iters f) (palette/checksum (:rgba f)))))))))) + +(deftest golden-file-is-well-formed + (doseq [v (golden/load-views)] + (is (golden/view->tile v) (:name v)) + (is (pos-int? (:iters v))) + (is (nat-int? (:checksum v))))) diff --git a/docs/examples/fractal/jvm/test/fractal/mutation_test.clj b/docs/examples/fractal/jvm/test/fractal/mutation_test.clj new file mode 100644 index 000000000..11dcf782e --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/mutation_test.clj @@ -0,0 +1,65 @@ +(ns fractal.mutation-test + "Mutation: the golden views must KILL every plausible mistake in the two + rules everything shares, the escape rule and the palette. Each mutant is a + value fed through the same seams the real rules use (an Oracle's escape fn, + a palette rule map); a mutant the golden views cannot tell apart from the + real rule is a hole in the golden set." + (:require [clojure.test :refer [deftest is testing]] + [fractal.golden :as golden] + [fractal.palette :as palette] + [fractal.renderer :as r] + [fractal.renderer.oracle :as oracle] + [fractal.tile :as tile])) + +(defn- escape-with + "An escape rule with its continue test, step and count start swapped in." + [continue? step n0] + (fn [cr ci limit] + (loop [n n0 zr 0.0 zi 0.0] + (if (continue? n limit (+ (* zr zr) (* zi zi))) + (let [[zr' zi'] (step zr zi cr ci)] (recur (inc n) zr' zi')) + [n (+ (* zr zr) (* zi zi))])))) + +(def ^:private kernel-step + (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)])) + +(def escape-mutants + {:strict-bailout (escape-with (fn [n limit m] (and (< n limit) (< m 4.0))) kernel-step 0) + :bailout-radius-3 (escape-with (fn [n limit m] (and (< n limit) (<= m 9.0))) kernel-step 0) + :limit-off-by-one (escape-with (fn [n limit m] (and (<= n limit) (<= m 4.0))) kernel-step 0) + :count-from-one (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) kernel-step 1) + :conjugate-step (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) + (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (- ci (* (* 2.0 zr) zi))]) 0) + :burning-ship (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) + (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (+ (Math/abs (* 2.0 zr zi)) ci)]) 0)}) + +(def palette-mutants + {:speed-0.026 (assoc palette/rule :speed 0.026) + :speed-halved (assoc palette/rule :speed 0.0125) + :channels-swapped (assoc palette/rule :offsets [(/ 1.0 3.0) 0.0 (/ 2.0 3.0)]) + :offsets-quarter (assoc palette/rule :offsets [0.0 0.25 0.5])}) + +(defn- golden-views [] (remove :heavy (golden/load-views))) + +(defn- killed? + "True when at least one golden view rejects the frames produced by escape + rule esc and palette rule pal." + [esc pal] + (boolean + (some (fn [v] + (let [t (golden/view->tile v) + field (tile/escape-field t)] + (r/escape! (oracle/->Oracle "mutant" esc) t field) + (golden/verdict v (tile/total-iters field) (palette/checksum (palette/paint pal field t))))) + (golden-views)))) + +(deftest the-real-rules-survive + (is (not (killed? oracle/escape palette/rule)))) + +(deftest every-escape-mutant-is-killed + (doseq [[k esc] escape-mutants] + (testing (name k) (is (killed? esc palette/rule))))) + +(deftest every-palette-mutant-is-killed + (doseq [[k pal] palette-mutants] + (testing (name k) (is (killed? oracle/escape pal))))) diff --git a/docs/examples/fractal/jvm/test/fractal/property_test.clj b/docs/examples/fractal/jvm/test/fractal/property_test.clj new file mode 100644 index 000000000..958a0f06e --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/property_test.clj @@ -0,0 +1,85 @@ +(ns fractal.property-test + "Property-based: on generated tiles, every exact renderer computes the + oracle's EscapeField bit for bit; the palette and the tile domain keep + their laws; the HTTP boundary answers the frame it renders." + (:require [clojure.test :refer [deftest is]] + [clojure.test.check :as tc] + [clojure.test.check.generators :as gen] + [clojure.test.check.properties :as prop] + [fractal.engines :as engines] + [fractal.http :as http] + [fractal.palette :as palette] + [fractal.renderer :as r] + [fractal.renderer.oracle :as oracle] + [fractal.tile :as tile])) + +(def gen-tile + "Small tiles around the set, from the full view down to deep zoom." + (gen/let [cx (gen/double* {:min -2.0 :max 0.6 :NaN? false :infinite? false}) + cy (gen/double* {:min -1.2 :max 1.2 :NaN? false :infinite? false}) + zoom (gen/choose 2 40) + w (gen/choose 1 24) + h (gen/choose 1 24) + iter (gen/choose 1 600)] + (tile/tile {:cx cx :cy cy :scale (/ 3.0 (Math/pow 2.0 (/ zoom 2.0))) :w w :h h :iter iter}))) + +(def exact-renderers + (delay (filterv #(and (:parity? (r/describe %)) (not= "clojure-jvm" (r/id %))) + (:renderers (engines/discover))))) + +(defn- field-of [rr t] (let [f (tile/escape-field t)] (r/escape! rr t f) f)) + +(defn- same-field? [a b] + (and (java.util.Arrays/equals ^ints (:counts a) ^ints (:counts b)) + (java.util.Arrays/equals ^doubles (:mag2 a) ^doubles (:mag2 b)))) + +(defn- check [n p] + (let [res (tc/quick-check n p)] + (is (:pass? res) (pr-str (select-keys res [:fail :shrunk :seed]))))) + +(deftest every-exact-renderer-equals-the-oracle + (let [o (oracle/renderer)] + (doseq [rr @exact-renderers] + (check 60 (prop/for-all [t gen-tile] + (same-field? (field-of o t) (field-of rr t))))))) + +(deftest escape-counts-are-bounded + (check 200 (prop/for-all [t gen-tile] + (let [{:keys [^ints counts]} (field-of (oracle/renderer) t)] + (every? #(<= 0 % (:iter t)) counts))))) + +(deftest palette-laws + (check 500 (prop/for-all [n (gen/choose 0 5000) + limit (gen/choose 1 5000) + mag2 (gen/double* {:min 0.0 :NaN? false})] + (let [n (min n limit) + [rr g b a] (palette/rgba n limit mag2)] + (and (= 255 a) + (every? #(<= 0 % 255) [rr g b]) + (= (== n limit) (= [0 0 0] [rr g b]))))))) + +(deftest paint-is-rgba-per-pixel + (check 60 (prop/for-all [t gen-tile] + (let [f (field-of (oracle/renderer) t) + bytes (palette/paint f t) + p (rand-int (tile/pixels t))] + (= (palette/rgba (aget ^ints (:counts f) p) (:iter t) (aget ^doubles (:mag2 f) p)) + (mapv #(bit-and 255 (aget ^bytes bytes (+ (* 4 p) %))) (range 4))))))) + +(deftest tile-validation + (check 300 (prop/for-all [w gen/small-integer h gen/small-integer + iter gen/small-integer scale gen/double] + (let [m {:cx 0.0 :cy 0.0 :scale scale :w w :h h :iter iter} + ok? (and (pos? w) (pos? h) (pos? iter) (Double/isFinite scale) (pos? scale))] + (= ok? (nil? (tile/problems m))))))) + +(deftest http-tile-answers-the-frame + (let [rr (oracle/renderer) + registry (r/registry [rr])] + (check 40 (prop/for-all [t gen-tile] + (let [q (into {"engine" "clojure-jvm"} (map (fn [[k v]] [(name k) (str v)])) t) + {:keys [status headers body]} (http/respond registry [] {:path "/tile" :query q}) + f (r/frame rr t)] + (and (= 200 status) + (= (str (:iters f)) (get headers "x-iters")) + (java.util.Arrays/equals ^bytes body ^bytes (:rgba f)))))))) From e5fddbc70fcc302579fa1245e10e69e06591334b Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 20:46:12 -0300 Subject: [PATCH 43/51] docs(changelog): byte-array HTTP bodies and the fractal example Merges origin/main first so the v1.15.5 stamp is on staging and these entries sit under [Unreleased]. --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfce65dfb..c616fcac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Added + +- `cljw.http.server` accepts a primitive byte-array `:body` (for example the + result of `wasm/mem-read-array`) and writes its bytes verbatim. Binary + responses were impossible before: a String body is UTF-8, so a 640x480 RGBA + frame came back as 1,748,379 bytes instead of 1,228,800, and a byte-array + body was sent empty. Covered by a 256-byte case in `phase16_http_server`. +- `docs/examples/fractal/`: a Mandelbrot zoom where one Zig `kernel.wasm` runs + on cljw's zwasm (JIT, interpreter, and a simd128 build on the JIT) behind + `cljw.http.server`, and in the browser on the same bytes. `bench.clj` checks + iteration and framebuffer parity across engines. Two e2e gates, + `phase16_fractal_kernel` (parity, and both artifacts rebuilt byte-identical + from the header recipes) and `phase16_fractal_server` (routes and tiles over + real HTTP), keep the committed artifacts honest. + ## [1.15.5] - 2026-10-29 ### Changed From 5007cb4c68dd722ba175a1d3c1794d3cc1f54e30 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 21:01:40 -0300 Subject: [PATCH 44/51] feat(examples): one renderer port across cljw, JVM and page; golden-gated e2e cljw: cljw/{tile,renderer,engines}.clj put every wasm kernel (Zig, Zig simd128, C, Rust on zwasm jit and interp) and plain Clojure behind one IRenderer, discovered into a registry. server.clj and bench.clj are thin boundaries over it; bench.clj now judges every frame against golden.edn. Page: engines.cljs is a protocol with WasmKernel, JsRaster and RemoteTiles adapters; discovery fills a registry, so nothing parses an engine name. Servers advertise inexact engines (cuda-f32), which bench parity skips. Found by the hive-cljs suite and fixed: the root rendered nothing (hiccup handed to React instead of reagent.dom.client/render), window.fractal clobbered the fractal.* namespace root (now window.fractalApi), an id/class tag order reagent rejects, and setPointerCapture throwing on an untracked pointer. Gate: phase16_fractal_kernel.sh runs the :ci golden views on 8 engines (16 frames) and rebuilds the Zig, C and Rust kernels byte for byte. kernel.c's recipe is now zig cc (no system wasm-ld); kernel_rust.wasm is rebuilt by its header recipe. Tests: hive-cljs 10/10 scenarios against the live servers, cljs view laws (test.check), JVM golden + property + mutation (11 tests, 58 assertions). --- docs/examples/fractal/README.md | 225 ++-- docs/examples/fractal/bench.clj | 150 +-- docs/examples/fractal/cljw/engines.clj | 62 + docs/examples/fractal/cljw/renderer.clj | 19 + docs/examples/fractal/cljw/tile.clj | 48 + docs/examples/fractal/hive-cljs.edn | 72 +- .../examples/fractal/jvm/src/fractal/http.clj | 6 +- docs/examples/fractal/kernel.c | 8 +- docs/examples/fractal/kernel.rs | 52 +- docs/examples/fractal/kernel_rust.wasm | Bin 4741 -> 4761 bytes docs/examples/fractal/server.clj | 192 +-- docs/examples/fractal/web/.gitignore | 1 + docs/examples/fractal/web/public/js/main.js | 1073 +++++++++++++++++ docs/examples/fractal/web/shadow-cljs.edn | 11 +- .../fractal/web/src/fractal/core.cljs | 9 +- .../fractal/web/src/fractal/engines.cljs | 195 +-- .../fractal/web/src/fractal/events.cljs | 9 +- docs/examples/fractal/web/src/fractal/ui.cljs | 8 +- .../fractal/web/test/fractal/view_test.cljs | 65 + test/e2e/phase16_fractal_kernel.sh | 50 +- 20 files changed, 1787 insertions(+), 468 deletions(-) create mode 100644 docs/examples/fractal/cljw/engines.clj create mode 100644 docs/examples/fractal/cljw/renderer.clj create mode 100644 docs/examples/fractal/cljw/tile.clj create mode 100644 docs/examples/fractal/web/public/js/main.js create mode 100644 docs/examples/fractal/web/test/fractal/view_test.cljs diff --git a/docs/examples/fractal/README.md b/docs/examples/fractal/README.md index 30287c488..7c8392d22 100644 --- a/docs/examples/fractal/README.md +++ b/docs/examples/fractal/README.md @@ -1,99 +1,170 @@ -# Fractal zoom: one kernel.wasm, three engines +# Fractal zoom: one Mandelbrot, nineteen engines -A Mandelbrot explorer where the **same** WebAssembly module renders on three -engines at once, and the page compares them live: +A Mandelbrot explorer where the same picture is rendered by kernels written in +Zig, C and Rust (compiled to WebAssembly), by plain Clojure, by +replikativ/raster on the JVM and by CUDA on the GPU. The page zooms, pans and +compares any two engines side by side. Every exact engine paints the same +bytes, and `golden.edn` proves it. -- **Browser**: the tab instantiates `/kernel.wasm` itself (V8 or - SpiderMonkey JIT, plus SIMD when the module ships `render_simd`). -- **Server-jit**: native cljw renders the tile with zwasm's JIT and returns - raw RGBA bytes. -- **Server-interp**: the same, on zwasm's tree-walking interpreter. - -The split is the retired cw-playground's shape (docs/works/demos.md section -4): cljw's own HTTP server (`cljw.http.server/run-server`) serves the page and -the compute; no JVM, no node, no CDN. +``` +browser (re-frame + reagent, web/) + ├── browser-zig, browser-zig-simd, browser-c, browser-rust wasm in the tab + ├── browser-js plain JavaScript + ├── cljw server (server.clj, port 8080) + │ zig, c, rust on zwasm jit and interp, zig-simd on jit, cljw-clj + └── JVM server (jvm/, port 8081) + raster-jvm, raster-jvm-1t, raster-simd, cuda-f64, cuda-f32, clojure-jvm +``` ## Run ```sh -zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary -cljw docs/examples/fractal/server.clj [port] # default 8080 +zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw +zig-out/bin/cljw docs/examples/fractal/server.clj # page + cljw engines, :8080 +(cd docs/examples/fractal/jvm && clojure -M:jvm:serve) # optional: raster + CUDA, :8081 ``` -Open http://localhost:8080. The server loads one zwasm instance per engine at -startup and reuses it across requests. +Open http://localhost:8080. The page finds the JVM server by itself +(`?jvm=` overrides). Without it, or without a GPU, those engines are absent +from the list and the server prints why (`unavailable: cuda-f64 - ...`). -`KERNEL=/path/to/other.wasm cljw docs/examples/fractal/server.clj` overrides -the module. When the kernel ships SIMD as a sibling file instead of an export, -drop `kernel_simd.wasm` next to it; the JIT leg picks it up. The interpreter -cannot run SIMD (v128), so `server-interp` always uses the scalar `render`. +The page is ClojureScript. `web/public/js/main.js` is committed, so running +needs no Node. To change it: `cd web && npm install && npx shadow-cljs watch app` +with server.clj running, or `npx shadow-cljs release app` for the committed build. + +## The page + +| input | action | +|---|---| +| wheel, `+` / `-`, double-click (shift: out), pinch | zoom at the cursor | +| drag, arrows, `WASD` | slide x and y | +| x, y, scale fields + Go | jump to a point | +| left / right, Split | pick engines, compare them side by side | +| iter, res | iteration cap, render resolution | +| Tour | continuous zoom into seahorse valley | +| Bench | every engine on a fixed view list, with parity | + +The URL hash holds the view, so a deep link reopens it. The live table shows +ms per frame and Miter/s. For a server engine, ms is the guest render time +(`X-Render-Ms`), so network time is excluded. + +## Layout + +The three processes share one shape, Collect, Promote, Pipeline, Boundary, +around one port: an open set of renderers behind a protocol, discovered into a +registry. No code dispatches on an engine name. + +| layer | cljw (`cljw/`, `server.clj`, `bench.clj`) | JVM (`jvm/src/fractal/`) | page (`web/src/fractal/`) | +|---|---|---|---| +| domain | `tile.clj` | `tile.clj`, `palette.clj`, `golden.clj` | `view.cljs` | +| port | `renderer.clj` | `renderer.clj` | `engines.cljs` `IRenderer` | +| adapters | `engines.clj`: `WasmKernel`, `ClojureRaster` | `renderer/raster.clj`, `raster_simd.clj`, `cuda.clj`, `oracle.clj` | `WasmKernel`, `JsRaster`, `RemoteTiles` | +| collect | `engines/discover` | `engines.clj` (an unavailable renderer is data) | `engines/discover` | +| boundary | `server.clj`, `bench.clj` | `http.clj`, `main.clj`, `bench.clj` | `events.cljs` (re-frame fx), `ui.cljs` | + +On the JVM a renderer computes only the escape field (count and |z|^2 per +pixel), and `palette.clj` paints it once, above the port. The wasm kernels +paint inside wasm; golden.edn holds them to the same bytes. ## The kernel ABI -`kernel.wasm` has no imports and exports: +Every `.wasm` kernel has no imports and exports: | export | signature | meaning | |---|---|---| | `memory` | | linear memory holding the framebuffer | | `fb_ptr` | `() -> i32` | byte address of the RGBA8 framebuffer | -| `fb_cap` | `() -> i32` | framebuffer capacity in pixels (1048576) | -| `set_view` | `(f64 f64 f64) -> i32` | stores cx, cy, scale; 0, or -1 when a coordinate is not finite | -| `render` | `(i32 i32 i32) -> i64` | writes `w*h` RGBA pixels at `fb_ptr` for the stored view, returns total iterations | -| `render_simd` | `(i32 i32 i32) -> i64` | SIMD twin, ships only in `kernel_simd.wasm` | - -Pixel `(px, py)` is the complex point `(cx + (px - w/2) * scale, cy + (py - h/2) * scale)`. +| `fb_cap` | `() -> i32` | capacity in pixels (1048576) | +| `set_view` | `(f64 f64 f64) -> i32` | stores cx, cy, scale; 0, or -1 when one is not finite | +| `render` | `(i32 i32 i32) -> i64` | writes `w*h` pixels for the stored view, returns total iterations | +| `render_simd` | `(i32 i32 i32) -> i64` | same output; vectorised in `kernel_simd.wasm` | -The two-call shape (view, then render) keeps every export at or below the -5-parameter dispatch limit of zwasm's JIT: a single 6-parameter `render` -traps at invoke (measured, gaps.zig `jit_wide_arity`). +Pixel `(x, y)` is `(cx + (x - w/2) scale, cy + (y - h/2) scale)`. The split +into `set_view` and `render` keeps every export within zwasm's JIT call shapes +(`src/runtime/cljw/wasm/gaps.zig`). Each source file's header holds its exact +build recipe; the e2e gate rebuilds them and requires identical bytes. -## Run - -```sh -zig build -Dwasm -Doptimize=ReleaseSafe # the wasm-enabled cljw binary -cljw docs/examples/fractal/server.clj [port] # default 8080 -``` +| file | language | build | +|---|---|---| +| `kernel.zig` | Zig | `zig build-exe` (scalar and simd128) | +| `kernel.c` | C | `zig cc -target wasm32-freestanding` | +| `kernel.rs` | Rust | `rustc --target wasm32-unknown-unknown` | +| `mandel.clj` | Clojure | none: cljw, bb and the JVM run it as is | -## The page +## Tests -Mouse wheel zooms around the cursor, drag pans, double-click zooms in, the -slider sets the iteration cap, Reset returns home. Modes: Browser, -Server-jit, Server-interp, and Split (browser on the left half, server-jit on -the right, same view). All of it is one self-contained file, works at phone -width, dark by default. - -## The numbers - -- **ms/frame**: for the browser leg, wall time of the `render` call. For the - server legs, the guest render time only (the `X-Render-Ms` response header); - network and byte-copy time are excluded so the comparison is engine against - engine. -- **Miter/s**: total Mandelbrot iterations (the `i64` `render` returns) - divided by that time. It scales with both zoom depth and the iteration cap, - so compare engines within one view, not views against each other. -- The rolling chart plots ms/frame per engine over the last 120 frames. -- **Bench** renders a fixed list of views at one size on all three engines and - tabulates ms and Miter/s, handy on a fresh load before you touch anything. - -Measured on the author's x86_64 host (512x512 full set, iter 256; ratios are -the stable part, absolutes are not): - -| engine | ms/frame | Miter/s | +| suite | what | run | |---|---|---| -| zwasm JIT, scalar | 275 | 33.7 | -| zwasm JIT, simd128 | 589 | 15.8 | -| zwasm interpreter | 3717 | 2.5 | - -Two honest edges: on zwasm's JIT the SIMD build is SLOWER than scalar (the -per-call v128 dispatch costs more than the vectorisation wins at this tile -size), and the interpreter trails the JIT by roughly 13x, in line with cljw's -own FFI measurement of ~15x on compute-bound bodies. The browser JIT (V8 / -SpiderMonkey) is where SIMD pays off normally; compare it against server-jit -in the page itself. - -## Known engine edges - -`render` takes 6 parameters. zwasm's JIT is listed as possibly not dispatching -exports with more than 5; if the JIT leg traps with that diagnosis, the remedy -is structural (the kernel packs params) or the JIT leg falls back to interp. -The trap message names the shape and the option. +| golden | every exact engine reproduces `golden.edn` (iterations and framebuffer checksum) | `cljw bench.clj`, `clojure -M:jvm:test`, page `:golden` | +| property | test.check: every JVM renderer equals the oracle bit for bit on generated tiles; palette, tile and HTTP laws | `clojure -M:jvm:test` | +| view laws | test.check on the page's view value: zoom keeps the cursor point, pan reverses, hash round-trips, split panes meet | `cd web && npx shadow-cljs compile test && node target/test.js` | +| mutation | 6 escape-rule and 4 palette mutants must each be rejected by the golden views | `clojure -M:jvm:test` | +| page | hive-cljs scenarios: boot, cross-engine parity, zoom, pan, hash, split, bench, JVM and GPU engines | `code cljs e2e run directory=docs/examples/fractal tags=fractal` | +| gate | `test/e2e/phase16_fractal_kernel.sh`: the `:ci` golden views on 8 cljw engines, recipes byte-identical | `bash test/run_all.sh` | + +The mutation suite earned its place at once. No golden pixel sat exactly on +|z|^2 = 4, so a bailout of `< 4` instead of `<= 4` passed every view. The +`tip` view puts pixel (4, 4) on c = -2, whose orbit 0, -2, 2, 2, ... stays on +the circle, and the mutant now dies. + +## Numbers + +RTX 4070 Laptop GPU, Core Ultra 9 185H (22 threads), JDK 27. Best of 5 frames, +render only (CUDA includes device allocation and the copy back). + +**full** (512x512, 256 iter, 9.28e6 iterations): + +| engine | ms | Miter/s | +|---|---:|---:| +| cuda-f32 | 2.1 | 4507 (not exact) | +| cuda-f64 | 3.2 | 2908 | +| raster-jvm, all cores | 5.1 | 1816 | +| raster-jvm, 1 thread | 22.4 | 414 | +| clojure-jvm (plain Clojure on the JVM) | 35.7 | 260 | +| rust-jit on zwasm | 250 | 37 | +| zig-jit simd128 on zwasm | 281 | 33 | +| c-jit on zwasm | 309 | 30 | +| zig-jit on zwasm | 324 | 29 | +| raster-simd (Vector API lanes) | 346 | 27 | +| c / rust interp on zwasm | 3200 | 2.9 | +| cljw-clj (plain Clojure on cljw) | 4640 | 2.0 | +| zig interp on zwasm | 5424 | 1.7 | + +**deep** (1024x768, 4096 iter, 2.39e9 iterations, scale 2e-9): + +| engine | ms | Giter/s | +|---|---:|---:| +| cuda-f64 | 180 | 13.3 | +| raster-jvm, all cores | 540 | 4.4 | +| clojure-jvm | 5669 | 0.42 | +| cuda-f32 | 5.7 | wrong: single precision cannot tell neighbouring pixels apart at this scale | + +What the numbers say: + +- On the GPU, fp64 (with `--fmad=false`) is exact and 3x faster than every CPU + core together on the deep view. fp32 is 30x faster again, and wrong there. +- raster's escape loop is a typed `deftm` compiled to primitive bytecode, 1.6x + faster than the same loop in plain Clojure on one thread, and it scales + across cores through raster's `parallel-for!`. +- raster vectorises `par/map` bodies onto Vector API lanes but cannot vectorise + a loop with a per-lane exit. The lanes renderer steps every pixel `limit` + times (w*h*limit steps where the escape loop does sum(counts)), so it is + exact, vectorised and slow: early exit beats lanes for Mandelbrot on a CPU. + A GPU has both, thousands of lanes with per-thread exit. +- On cljw, zwasm's JIT runs the wasm kernels 10x to 17x faster than its + interpreter, and plain Clojure on cljw sits near the interpreter. + +## Upstream fixes found by this demo + +- replikativ/raster#1574: on a JDK 27 without `--enable-preview`, + `valhalla-available?` was true, value classes failed to load, and the error + surfaced as `ClassNotFoundException: raster.types.promote.TLong`. The fix + probes by defining a value class. Until it is released, `jvm/deps.edn` + passes `--enable-preview`. +- uncomplicate/clojure-cpp#3: a `keywrod-pointer*` typo made the namespace + unloadable on current Clojure, and with it clojurecuda. Until it is released, + `cuda-*` needs a `jvm/local.deps.edn` that points at a patched clojure-cpp, + otherwise the JVM server lists them as unavailable. +- CUDA 13.5 NVRTC emits PTX a 13.4 driver rejects. `cuda.clj` compiles to a + CUBIN for the device's own `sm_XY` instead, which minor-version + compatibility allows. diff --git a/docs/examples/fractal/bench.clj b/docs/examples/fractal/bench.clj index 77f2984a0..1c93bf75a 100644 --- a/docs/examples/fractal/bench.clj +++ b/docs/examples/fractal/bench.clj @@ -1,100 +1,56 @@ ;; Run from the repository root: zig-out/bin/cljw docs/examples/fractal/bench.clj -;; Every kernel (Zig, Zig simd128, C, Rust) on every zwasm runtime, plus the -;; pure Clojure raster (raster.clj, no wasm). Render timing excludes the -;; framebuffer transfer, checksum and PPM encoding. -;; Parity asserted: iteration totals are equal across ALL engines; checksums are -;; equal across runtimes and exports of one kernel; the Zig scalar and simd128 -;; modules agree byte for byte, and so do C and Rust (they share one log2). -;; CLJW_FRACTAL_CI=1 selects small correctness cases for the e2e gate. -;; RASTER=0 skips the pure Clojure raster. -(ns fractal.bench (:require [clojure.java.io :as io])) - -(def root "docs/examples/fractal/") -(load-file (str root "mandel.clj")) - -(def views - (if (= "1" (System/getenv "CLJW_FRACTAL_CI")) - [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 32) :w 32 :h 32 :iter 64} - {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 16 :h 16 :iter 160} - {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}] - [{:name "full" :cx -0.5 :cy 0.0 :scale (/ 3.5 512) :w 512 :h 512 :iter 256} - {:name "seahorse" :cx -0.743643887037151 :cy 0.13182590420533 :scale 1.0e-6 :w 128 :h 128 :iter 2000} - {:name "odd-tail" :cx -0.5 :cy 0.0 :scale 0.1 :w 31 :h 29 :iter 80}])) - -;; Byte-identity groups: kernels in one group must produce identical pixels. -(def kernels - [{:id "zig" :file "kernel.wasm" :group "zig" :runtimes [:jit :interp] :exports ["render" "render_simd"]} - {:id "zig-simd" :file "kernel_simd.wasm" :group "zig" :runtimes [:jit] :exports ["render" "render_simd"]} - {:id "c" :file "kernel_c.wasm" :group "c+rust" :runtimes [:jit :interp] :exports ["render"]} - {:id "rust" :file "kernel_rust.wasm" :group "c+rust" :runtimes [:jit :interp] :exports ["render"]}]) - -(defn checksum [pixels] - (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels)) - -(defn ppm! [pixels w h] - ;; P3 is portable text PPM, avoiding host-specific binary stream methods. - (with-open [out (io/writer (str root "out.ppm"))] - (.write out (str "P3\n" w " " h "\n255\n")) - (dotimes [i (* w h)] - (.write out (str (bit-and 255 (aget pixels (* i 4))) " " - (bit-and 255 (aget pixels (+ (* i 4) 1))) " " - (bit-and 255 (aget pixels (+ (* i 4) 2))) "\n"))))) - -(def results (atom [])) -(def iteration-ref (atom {})) -(def checksum-ref (atom {})) - -(defn record! [engine group view iterations ms pixels] - (let [{:keys [name w h]} view - sum (checksum pixels)] - (println (format "%-26s %-9s %10.2f ms %8.2f Miter/s iters %d checksum %d" - engine name ms (/ iterations (* ms 1000.0)) iterations sum)) - (swap! results conj {:engine engine :view name :ms ms :iters iterations}) - (if-let [ref (get @iteration-ref name)] - (assert (= ref iterations) (str "iteration mismatch: " engine " " name)) - (swap! iteration-ref assoc name iterations)) - (when group - (if-let [ref (get @checksum-ref [group name])] - (assert (= ref sum) (str "checksum mismatch in group " group ": " engine " " name)) - (swap! checksum-ref assoc [group name] sum))) - (when (and (= engine "zig-interp/render") (= name "full")) - (ppm! pixels w h)))) - -(defn run-wasm! [module engine group export view] - (let [{:keys [cx cy scale w h iter]} view - _ (assert (= 0 (.set_view module cx cy scale))) - start (System/nanoTime) - iterations (wasm/call module export w h iter) - ms (/ (- (System/nanoTime) start) 1.0e6) - pixels (wasm/mem-read-array module :u8 (.fb_ptr module) (* w h 4))] - (record! engine group view iterations ms pixels))) - -(doseq [{:keys [id file group runtimes exports]} kernels - runtime runtimes] - (let [module (wasm/load (str root file) {:engine runtime :fuel 0}) - sigs (wasm/exports module)] - (doseq [export exports] - (let [signature (first (filter #(= export (:name %)) sigs))] - (assert (= [:i32 :i32 :i32] (:params signature)) (str id " " export " params")) - (assert (= [:i64] (:results signature)) (str id " " export " results")))) - (assert (= 1048576 (.fb_cap module))) - (doseq [export exports view views] - (run-wasm! module (str id "-" (name runtime) "/" export) group export view)))) -(println "PASS wasm kernels: iteration parity across kernels, byte parity within groups") - -(when-not (= "0" (System/getenv "RASTER")) - (doseq [view views] - (let [{:keys [cx cy scale w h iter]} view - fb (byte-array (* w h 4)) - start (System/nanoTime) - iterations (fractal.mandel/render fb cx cy scale w h iter) - ms (/ (- (System/nanoTime) start) 1.0e6)] - (record! "cljw-clj" nil view iterations ms fb))) - (println "PASS cljw-raster: iteration parity with the wasm kernels")) +;; Every cljw renderer (each wasm kernel on each zwasm runtime, plus plain +;; Clojure) on every golden view; each frame must reproduce golden.edn exactly. +;; Timing covers the render call only. +;; CLJW_FRACTAL_CI=1 only the :ci views (the e2e gate) +;; HEAVY=1 also :heavy views, on JIT engines only +;; Pipeline: frames -> verdicts -> table (pure). Boundary: printing, exit code. +(ns fractal.bench) + +(def root "docs/examples/fractal") +(load-file (str root "/mandel.clj")) +(load-file (str root "/cljw/tile.clj")) +(load-file (str root "/cljw/renderer.clj")) +(load-file (str root "/cljw/engines.clj")) + +(def ci? (= "1" (System/getenv "CLJW_FRACTAL_CI"))) +(def heavy? (= "1" (System/getenv "HEAVY"))) + +(defn golden-views [] + (:views (clojure.edn/read-string (slurp (str root "/golden.edn"))))) + +(defn selected? [v] (if ci? (:ci v) (or (not (:heavy v)) heavy?))) + +(defn runs? [rr v] + (or (not (:heavy v)) (not (:interp? (fractal.cljw.renderer/describe rr))))) + +(defn measure [rr v] + (let [t (fractal.cljw.tile/tile v) + {:keys [iters rgba ms]} (fractal.cljw.renderer/frame rr t) + sum (fractal.cljw.tile/checksum rgba)] + {:engine (fractal.cljw.renderer/id rr) :view (:name v) :ms ms :iters iters :checksum sum + :golden? (and (= iters (:iters v)) (= sum (:checksum v)))})) + +(defn line [{:keys [engine view ms iters checksum golden?]}] + (format "frame %-14s %-9s %10.2f ms %9.2f Miter/s iters %d checksum %d %s" + engine view ms (/ iters (* ms 1000.0)) iters checksum (if golden? "golden" "MISMATCH"))) + +(defn table [rows] + (clojure.string/join + "\n" (concat ["| engine | view | ms | Miter/s |" "|---|---|---:|---:|"] + (for [{:keys [engine view ms iters]} rows] + (format "| %s | %s | %.2f | %.2f |" engine view ms (/ iters (* ms 1000.0))))))) + +(def renderers (fractal.cljw.engines/discover root fractal.mandel/render)) +(def rows + (vec (for [v (golden-views) :when (selected? v) + rr renderers :when (runs? rr v)] + (let [row (measure rr v)] (println (line row)) row)))) (println) -(println "| engine | view | ms | Miter/s |") -(println "|---|---|---:|---:|") -(doseq [{:keys [engine view ms iters]} @results] - (println (format "| %s | %s | %.2f | %.2f |" engine view ms (/ iters (* ms 1000.0))))) -(println "Wrote docs/examples/fractal/out.ppm") +(println (table rows)) +(if (every? :golden? rows) + (println (str "PASS golden: " (count rows) " frames on " (count renderers) + " engines reproduce golden.edn")) + (do (println "FAIL golden:" (pr-str (map (juxt :engine :view) (remove :golden? rows)))) + (System/exit 1))) diff --git a/docs/examples/fractal/cljw/engines.clj b/docs/examples/fractal/cljw/engines.clj new file mode 100644 index 000000000..6a9e54e5a --- /dev/null +++ b/docs/examples/fractal/cljw/engines.clj @@ -0,0 +1,62 @@ +;; Adapters and discovery on cljw. A wasm kernel (Zig, Zig simd128, C, Rust) +;; answers one ABI (README "The kernel ABI"), so one adapter serves every +;; kernel on every zwasm runtime. cljw-clj renders with plain Clojure +;; (../mandel.clj), no wasm. Kernels are data; a missing .wasm file is skipped. +(ns fractal.cljw.engines + (:require [clojure.java.io :as io] + [fractal.cljw.renderer :as r])) + +(def kernel-specs + "id, file under the fractal dir, render export, and :jit-only for simd128 + (zwasm's interpreter has no v128)." + [{:id "zig" :file "kernel.wasm" :export "render"} + {:id "zig-simd" :file "kernel_simd.wasm" :export "render_simd" :jit-only true} + {:id "c" :file "kernel_c.wasm" :export "render"} + {:id "rust" :file "kernel_rust.wasm" :export "render"}]) + +;; The view lives in the wasm instance, so set_view + render is one critical +;; section per instance. +(defrecord WasmKernel [rid runtime inst lock export] + r/IRenderer + (id [_] rid) + (describe [_] {:interp? (= runtime :interp) :what (str export " on zwasm " (name runtime))}) + (render! [_ {:keys [cx cy scale w h iter]}] + (locking lock + (when (neg? (.set_view inst cx cy scale)) + (throw (ex-info "set_view rejected a non-finite coordinate" {:cx cx :cy cy :scale scale}))) + (let [iters (wasm/call inst export w h iter)] + {:iters iters + :rgba (wasm/mem-read-array inst :u8 (.fb_ptr inst) (* w h 4))})))) + +(defrecord ClojureRaster [rid render-fn] + r/IRenderer + (id [_] rid) + (describe [_] {:interp? true :what "plain Clojure on cljw, no wasm"}) + (render! [_ {:keys [cx cy scale w h iter]}] + (let [fb (byte-array (* w h 4))] + {:iters (render-fn fb cx cy scale w h iter) :rgba fb}))) + +(defn wasm-kernels + "Every kernel file present under dir, on every runtime it supports." + [dir specs] + (for [{:keys [id file export jit-only]} specs + :let [path (str dir "/" file)] + :when (.exists (io/file path)) + runtime [:jit :interp] + :when (not (and jit-only (= runtime :interp)))] + (->WasmKernel (str id "-" (name runtime)) runtime + (wasm/load path {:engine runtime :fuel 0}) (Object.) export))) + +(defn discover + "All renderers available under dir: the wasm kernels, then cljw-clj." + [dir render-fn] + (concat (wasm-kernels dir kernel-specs) [(->ClojureRaster "cljw-clj" render-fn)])) + +(defn kernel-files + "{id path} of the kernels the browser can fetch." + [dir] + (into (array-map) + (for [{:keys [id file]} kernel-specs + :let [path (str dir "/" file)] + :when (.exists (io/file path))] + [id path]))) diff --git a/docs/examples/fractal/cljw/renderer.clj b/docs/examples/fractal/cljw/renderer.clj new file mode 100644 index 000000000..fc0caea7a --- /dev/null +++ b/docs/examples/fractal/cljw/renderer.clj @@ -0,0 +1,19 @@ +;; The renderer port on cljw. Renderers are an open set (a wasm kernel per +;; language and zwasm runtime, plain Clojure, more later): each is an IRenderer +;; value in a registry. Every renderer paints the RGBA8 framebuffer itself; the +;; golden views prove they all paint the same bytes. +(ns fractal.cljw.renderer) + +(defprotocol IRenderer + (id [r] "Engine name as the page and the bench show it.") + (describe [r] "{:interp? bool :what str}; interpreted engines skip heavy views.") + (render! [r t] "Render Tile t: {:iters total :rgba byte-array}.")) + +(defn frame + "Render Tile t on r, timed: {:iters :rgba :ms}." + [r t] + (let [t0 (System/nanoTime) + out (render! r t)] + (assoc out :ms (/ (double (- (System/nanoTime) t0)) 1.0e6)))) + +(defn registry [renderers] (into (array-map) (map (fn [r] [(id r) r])) renderers)) diff --git a/docs/examples/fractal/cljw/tile.clj b/docs/examples/fractal/cljw/tile.clj new file mode 100644 index 000000000..fef62521f --- /dev/null +++ b/docs/examples/fractal/cljw/tile.clj @@ -0,0 +1,48 @@ +;; Domain values on cljw, pure. A Tile is {:cx :cy :scale :w :h :iter}; +;; the same shape and limits as the JVM's fractal.tile. +(ns fractal.cljw.tile) + +(def max-pixels (* 1024 1024)) + +(defn finite? [x] (and (number? x) (== 0.0 (- (double x) (double x))))) + +(defn problems + "Why m is not a Tile, or nil when it is one." + [{:keys [cx cy scale w h iter]}] + (let [ps (cond-> [] + (not (and (finite? cx) (finite? cy) (finite? scale))) (conj "cx, cy and scale must be finite numbers") + (and (finite? scale) (not (pos? scale))) (conj "scale must be positive") + (not (and (int? w) (int? h) (pos? w) (pos? h))) (conj "w and h must be positive integers") + (and (int? w) (int? h) (> (* w h) max-pixels)) (conj (str "w*h must be at most " max-pixels)) + (not (and (int? iter) (pos? iter))) (conj "iter must be a positive integer"))] + (when (seq ps) ps))) + +(defn tile + "Validate m into a Tile; throws ex-info naming the first problem." + [m] + (if-let [p (problems m)] + (throw (ex-info (first p) {:problems p})) + (select-keys m [:cx :cy :scale :w :h :iter]))) + +(defn parse-query + "Promote a query-string map into a Tile, defaults filled in." + [q] + (let [num (fn [k d] (if-let [s (get q k)] (Double/parseDouble s) d)) + lng (fn [k d] (if-let [s (get q k)] (Long/parseLong s) d))] + (tile {:cx (num "cx" -0.6) :cy (num "cy" 0.0) :scale (num "scale" 0.004) + :w (lng "w" 640) :h (lng "h" 480) :iter (lng "iter" 256)}))) + +(defn query-map + "Collect: a raw query string into a map of strings." + [qs] + (if (nil? qs) + {} + (into {} (keep (fn [pair] + (let [i (.indexOf pair "=")] + (when (pos? i) [(subs pair 0 i) (subs pair (inc i))])))) + (.split qs "&")))) + +(defn checksum + "The golden checksum of an RGBA8 framebuffer." + [pixels] + (reduce (fn [acc b] (mod (+ (* acc 31) (bit-and 255 b)) 2147483647)) 1 pixels)) diff --git a/docs/examples/fractal/hive-cljs.edn b/docs/examples/fractal/hive-cljs.edn index cb2bb5b9d..5c938d0e8 100644 --- a/docs/examples/fractal/hive-cljs.edn +++ b/docs/examples/fractal/hive-cljs.edn @@ -9,7 +9,7 @@ ;; (cd raster && clojure -M:serve 8081) ; only for :raster scenarios ;; code {command: "cljs e2e run", directory: "", tags: "fractal"} ;; -;; Scenarios talk to the page through window.fractal (fractal.core/test-surface) +;; Scenarios talk to the page through window.fractalApi (fractal.core/test-surface) ;; and real DOM input events, so they exercise the re-frame events the UI uses. {:project-id "ClojureWasm" :hive.cljs/toolchain :browser @@ -27,9 +27,9 @@ server, and paints a first frame." :steps [[:goto "/"] [:wait-for "#view"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "['browser-zig','browser-c','browser-rust','browser-js','server-zig-jit','server-zig-interp','server-c-jit','server-rust-jit','server-cljw-clj'].every(function(e){return window.fractal.engines().indexOf(e)>=0;})"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractal.panes();if(p.L&&p.L.iters>0){clearInterval(t);ok(true);}},100);})"]]} + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "['browser-zig','browser-c','browser-rust','browser-js','server-zig-jit','server-zig-interp','server-c-jit','server-rust-jit','server-cljw-clj'].every(function(e){return window.fractalApi.engines().indexOf(e)>=0;})"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(p.L&&p.L.iters>0){clearInterval(t);ok(true);}},100);})"]]} {:id :parity :tags [:fractal :parity] @@ -38,60 +38,80 @@ wasm in the browser and on cljw's zwasm, plain JS, plain Clojure on cljw, and raster on the JVM when its server is up." :steps [[:goto "/"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "Promise.all(window.fractal.engines().map(function(e){return window.fractal.render(e,-0.743643887037151,0.13182590420533,1e-6,48,32,400);})).then(function(rs){return rs.length>8&&rs.every(function(r){return r.iters===rs[0].iters&&r.checksum===rs[0].checksum&&r.iters>0;});})"]]} + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "Promise.all(window.fractalApi.engines().filter(function(e){return e!=='cuda-f32';}).map(function(e){return window.fractalApi.render(e,-0.743643887037151,0.13182590420533,1e-6,48,32,400);})).then(function(rs){return rs.length>8&&rs.every(function(r){return r.iters===rs[0].iters&&r.checksum===rs[0].checksum&&r.iters>0;});})"]]} + + {:id :golden + :tags [:fractal :parity :golden] + :doc "The page reproduces golden.edn's tip view (the pixel on c = -2) on + every exact engine it can reach: the same numbers cljw and the JVM + suites assert." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "Promise.all(window.fractalApi.engines().filter(function(e){return e!=='cuda-f32';}).map(function(e){return window.fractalApi.render(e,-2.0,0.0,0.25,8,8,50);})).then(function(rs){return rs.every(function(r){return r.iters===317&&r.checksum===2086288669;});})"]]} {:id :zoom :tags [:fractal :interaction] :doc "The + button doubles the zoom around the centre, and a wheel event over the right half of the canvas zooms in at the cursor." :steps [[:goto "/#cx=-0.6&cy=0&scale=0.00375&iter=256"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] [:click "#zin"] [:wait-ms 200] - [:expect-js "Math.abs(window.fractal.view().scale-0.001875)<1e-12 && Math.abs(window.fractal.view().cx+0.6)<1e-12"] - [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect(),s=window.fractal.view().scale;c.dispatchEvent(new WheelEvent('wheel',{deltaY:-300,clientX:r.left+r.width*0.75,clientY:r.top+r.height/2,bubbles:true,cancelable:true}));return new Promise(function(ok){setTimeout(function(){var v=window.fractal.view();ok(v.scale-0.6);},200);});})()"]]} + [:expect-js "Math.abs(window.fractalApi.view().scale-0.001875)<1e-12 && Math.abs(window.fractalApi.view().cx+0.6)<1e-12"] + [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect(),s=window.fractalApi.view().scale;c.dispatchEvent(new WheelEvent('wheel',{deltaY:-300,clientX:r.left+r.width*0.75,clientY:r.top+r.height/2,bubbles:true,cancelable:true}));return new Promise(function(ok){setTimeout(function(){var v=window.fractalApi.view();ok(v.scale-0.6);},200);});})()"]]} {:id :pan :tags [:fractal :interaction] :doc "Arrow keys and WASD slide x and y by a tenth of the canvas; a drag slides the plane with the pointer." :steps [[:goto "/#cx=0&cy=0&scale=0.01&iter=128"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "(function(){var w=document.getElementById('view').width;window.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight'}));window.dispatchEvent(new KeyboardEvent('keydown',{key:'s'}));return new Promise(function(ok){setTimeout(function(){var v=window.fractal.view();ok(Math.abs(v.cx-w/10*0.01)<1e-9&&Math.abs(v.cy-w/10*0.01)<1e-9);},200);});})()"] - [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect(),x=r.left+100,y=r.top+100,v0=window.fractal.view();function ev(t,dx){c.dispatchEvent(new PointerEvent(t,{pointerId:7,clientX:x+dx,clientY:y,bubbles:true}));}ev('pointerdown',0);ev('pointermove',50);ev('pointerup',50);return new Promise(function(ok){setTimeout(function(){ok(window.fractal.view().cx0&&p.L.engine!==p.R.engine){clearInterval(t);ok(true);}},100);})"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(p.R&&p.R.iters>0&&p.L.engine!==p.R.engine){clearInterval(t);ok(true);}},100);})"] [:expect-js "getComputedStyle(document.getElementById('divider')).display==='block'"]]} {:id :bench :tags [:fractal :bench] :doc "The bench renders the view list on every engine and reports parity." :steps [[:goto "/"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "window.fractal.bench({w:64,h:48}).then(function(r){return r.parity===true && r.rows.length===4*window.fractal.engines().length && r.rows.every(function(x){return !x.error;});})"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "window.fractalApi.bench({w:64,h:48}).then(function(r){return r.parity===true && r.rows.length===4*window.fractalApi.engines().length && r.rows.every(function(x){return !x.error;});})"] [:wait-for "#benchtab"]]} - {:id :raster - :tags [:fractal :raster] - :doc "With the raster server up (raster/, port 8081) the page lists - raster-jvm, and it renders the same pixels as the zig kernel." + {:id :jvm-engines + :tags [:fractal :jvm] + :doc "With the JVM server up (jvm/, port 8081) the page lists raster and + CUDA engines; raster-jvm and cuda-f64 paint the zig kernel's bytes, + cuda-f32 renders but is not held to them." :steps [[:goto "/"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractal&&window.fractal.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "window.fractal.engines().indexOf('raster-jvm')>=0"] - [:expect-js "Promise.all(['raster-jvm','browser-zig'].map(function(e){return window.fractal.render(e,-0.6,0,0.01,64,48,256);})).then(function(rs){return rs[0].iters===rs[1].iters&&rs[0].checksum===rs[1].checksum;})"]]}]}} + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "['raster-jvm','raster-simd','cuda-f64','cuda-f32'].every(function(e){return window.fractalApi.engines().indexOf(e)>=0;})"] + [:expect-js "Promise.all(['raster-jvm','cuda-f64','browser-zig','cuda-f32'].map(function(e){return window.fractalApi.render(e,-0.6,0,0.01,64,48,256);})).then(function(rs){return rs[0].iters===rs[2].iters&&rs[0].checksum===rs[2].checksum&&rs[1].iters===rs[2].iters&&rs[1].checksum===rs[2].checksum&&rs[3].iters>0;})"]]} + + {:id :split-gpu + :tags [:fractal :jvm :interaction] + :doc "Split view with the browser kernel on the left and the GPU on the + right, deep in seahorse valley." + :steps [[:goto "/#cx=-0.743643887037151&cy=0.13182590420533&scale=2e-9&iter=2048"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "(function(){var s=document.getElementById('engR');s.value='cuda-f64';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"] + [:click "#split"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(p.R&&p.R.engine==='cuda-f64'&&p.R.iters>0){clearInterval(t);ok(true);}},100);})"]]}]}} diff --git a/docs/examples/fractal/jvm/src/fractal/http.clj b/docs/examples/fractal/jvm/src/fractal/http.clj index 2077a1bec..af6f7d592 100644 --- a/docs/examples/fractal/jvm/src/fractal/http.clj +++ b/docs/examples/fractal/jvm/src/fractal/http.clj @@ -22,8 +22,12 @@ (defn- json-strings [xs] (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) -(defn engines-json [registry unavailable] +(defn engines-json + "Engines, the inexact ones among them (not held to golden.edn, e.g. fp32), + and the unavailable ones." + [registry unavailable] (str "{\"engines\":" (json-strings (keys registry)) + ",\"inexact\":" (json-strings (for [[k rr] registry :when (not (:parity? (r/describe rr)))] k)) ",\"unavailable\":" (json-strings (map :id unavailable)) "}")) (defn respond diff --git a/docs/examples/fractal/kernel.c b/docs/examples/fractal/kernel.c index 51ac2303e..d6766db30 100644 --- a/docs/examples/fractal/kernel.c +++ b/docs/examples/fractal/kernel.c @@ -1,9 +1,9 @@ /* Mandelbrot RGBA8 kernel in C, freestanding with no imports. * Same ABI and pixel math as kernel.zig; see README.md "The kernel ABI". - * Build from the repository root (clang 21): - * clang --target=wasm32 -O3 -nostdlib -fno-builtin -Wl,--no-entry -Wl,--strip-all -o docs/examples/fractal/kernel_c.wasm docs/examples/fractal/kernel.c - * log2 is computed locally (no libm in freestanding wasm), so palette bytes may - * differ from kernel.zig by one level; iteration totals are exact. */ + * Build from the repository root (zig cc bundles clang and wasm-ld): + * zig cc -target wasm32-freestanding -O3 -nostdlib -fno-builtin -Wl,--no-entry -Wl,--strip-all -o docs/examples/fractal/kernel_c.wasm docs/examples/fractal/kernel.c + * log2 is computed locally (no libm in freestanding wasm); the golden views + * show it paints the same bytes as kernel.zig. */ #include diff --git a/docs/examples/fractal/kernel.rs b/docs/examples/fractal/kernel.rs index 2e5d60689..84ec18adb 100644 --- a/docs/examples/fractal/kernel.rs +++ b/docs/examples/fractal/kernel.rs @@ -2,8 +2,8 @@ //! Same ABI and pixel math as kernel.zig; see README.md "The kernel ABI". //! Build from the repository root (rustup target add wasm32-unknown-unknown): //! rustc --edition 2021 --crate-type=cdylib --target wasm32-unknown-unknown -C opt-level=3 -C panic=abort -C strip=symbols -o docs/examples/fractal/kernel_rust.wasm docs/examples/fractal/kernel.rs -//! log2 is computed locally (core has no libm), with the same series as -//! kernel.c, so kernel_c.wasm and kernel_rust.wasm agree byte for byte. +//! log2 is computed locally (core has no libm); the golden views show it +//! paints the same bytes as kernel.zig. #![no_std] const CAPACITY: usize = 1024 * 1024; @@ -21,11 +21,19 @@ fn finite(x: f64) -> bool { fn floor(t: f64) -> f64 { let i = t as i64 as f64; - if i > t { i - 1.0 } else { i } + if i > t { + i - 1.0 + } else { + i + } } fn abs(x: f64) -> f64 { - if x < 0.0 { -x } else { x } + if x < 0.0 { + -x + } else { + x + } } fn log2_pos(x: f64) -> f64 { @@ -38,21 +46,32 @@ fn log2_pos(x: f64) -> f64 { } let s = (m - 1.0) / (m + 1.0); let s2 = s * s; - let series = 1.0 + s2 * (1.0 / 3.0 + s2 * (1.0 / 5.0 + s2 * (1.0 / 7.0 + s2 * (1.0 / 9.0 - + s2 * (1.0 / 11.0 + s2 * (1.0 / 13.0 + s2 * (1.0 / 15.0 + s2 * (1.0 / 17.0 - + s2 * (1.0 / 19.0 + s2 * (1.0 / 21.0)))))))))); + let series = 1.0 + + s2 * (1.0 / 3.0 + + s2 * (1.0 / 5.0 + + s2 * (1.0 / 7.0 + + s2 * (1.0 / 9.0 + + s2 * (1.0 / 11.0 + + s2 * (1.0 / 13.0 + + s2 * (1.0 / 15.0 + + s2 * (1.0 / 17.0 + + s2 * (1.0 / 19.0 + s2 * (1.0 / 21.0)))))))))); e as f64 + 2.0 * s * series * 1.4426950408889634 } #[no_mangle] pub extern "C" fn set_view(cx: f64, cy: f64, scale: f64) -> i32 { unsafe { VIEW = (cx, cy, scale) }; - if finite(cx) && finite(cy) && finite(scale) { 0 } else { -1 } + if finite(cx) && finite(cy) && finite(scale) { + 0 + } else { + -1 + } } #[no_mangle] pub extern "C" fn fb_ptr() -> i32 { - unsafe { core::ptr::addr_of!(FRAMEBUFFER) as i32 } + core::ptr::addr_of!(FRAMEBUFFER) as i32 } #[no_mangle] @@ -67,7 +86,11 @@ fn color(fb: &mut [u8], pixel: usize, n: u32, limit: u32, magnitude: f64) { return; } let bounded = if finite(magnitude) { - if magnitude > 4.000001 { magnitude } else { 4.000001 } + if magnitude > 4.000001 { + magnitude + } else { + 4.000001 + } } else { 1.0e300 }; @@ -83,8 +106,13 @@ fn color(fb: &mut [u8], pixel: usize, n: u32, limit: u32, magnitude: f64) { #[no_mangle] pub extern "C" fn render(w: i32, h: i32, max_iter: i32) -> i64 { let (cx, cy, scale) = unsafe { VIEW }; - let ok = w > 0 && h > 0 && (w as i64) * (h as i64) <= CAPACITY as i64 && max_iter > 0 - && finite(cx) && finite(cy) && finite(scale); + let ok = w > 0 + && h > 0 + && (w as i64) * (h as i64) <= CAPACITY as i64 + && max_iter > 0 + && finite(cx) + && finite(cy) + && finite(scale); if !ok { return -1; } diff --git a/docs/examples/fractal/kernel_rust.wasm b/docs/examples/fractal/kernel_rust.wasm index 27de423102a17d077b77094c7a1fa9b560e7d920..a11a7c8260783f638bfe94d4ace6ae42bb6a7ff0 100755 GIT binary patch delta 223 zcmZowovAwE0^@>-7d=cTG#_YSP-0PFS?Iz51xdUNjttt&3z`o!F(|Pruwux}*zCvX zz{t2@azB$dQDBBVh8BZC5iB{SF(Ad|_GxnnaM zI~ynCmdU&V@<38oARI`x3TQKKoxD-Ngl8Tz6N6(z!vO~FKTMN93d|7APA$qy&Cx3= h20CsAg8&03kan27N${c=&`<_$0f?I)?1#`b_5jT%J1YPH diff --git a/docs/examples/fractal/server.clj b/docs/examples/fractal/server.clj index 7b10ca8dc..97be03f6c 100644 --- a/docs/examples/fractal/server.clj +++ b/docs/examples/fractal/server.clj @@ -1,21 +1,16 @@ -;; Fractal zoom: the server half. -;; Usage: cljw docs/examples/fractal/server.clj [port] (default 8080) -;; KERNEL=/path/to/kernel.wasm cljw server.clj (override the zig module) +;; Fractal zoom: the cljw server, a thin boundary over the renderer registry. +;; Usage: cljw docs/examples/fractal/server.clj [port] (default 8080) ;; -;; Native cljw serves the page and renders tiles server-side. Every kernel -;; (Zig, Zig simd128, C, Rust) answers one ABI, see README.md "The kernel ABI", -;; so the same render-tile drives all of them. cljw-raster renders with plain -;; Clojure (raster.clj) and no wasm, the CPU raster baseline. -;; -;; Engines (the /tile `engine` parameter) are "-": -;; zig-jit zig-interp c-jit c-interp rust-jit rust-interp zig-simd-jit -;; cljw-raster -;; The old names jit, interp and jit-simd stay as aliases of the zig ones. -;; GET /engines answers {"engines":[...],"kernels":[...]}; every kernel listed -;; is fetchable by the browser at GET /kernel.wasm?k=. -;; -;; The view lives in the wasm instance, so set_view + render is atomic per -;; instance: every instance is held behind a lock. +;; Serves the re-frame page (web/public, built by shadow-cljs), every wasm +;; kernel for the browser, and tiles rendered natively on cljw: each kernel on +;; zwasm jit and interp, plus cljw-clj (plain Clojure). The JVM engines (raster, +;; CUDA) are a separate server (jvm/); the page talks to it directly. +;; GET / the page +;; GET /js/** the compiled frontend +;; GET /kernel.wasm?k= a kernel module, application/wasm +;; GET /engines {"engines":[...],"kernels":[...]} +;; GET /tile?cx=&cy=&scale=&w=&h=&iter=&engine= +;; w*h*4 raw RGBA bytes, X-Render-Ms and X-Iters (ns fractal.server (:require [cljw.http.server :as http] @@ -24,101 +19,33 @@ (def dir (.getParent (io/file *file*))) (load-file (str dir "/mandel.clj")) -(def public (str dir "/web/public")) - -(def port (if (seq *command-line-args*) - (Long/parseLong (first *command-line-args*)) - 8080)) - -;; --- kernels ------------------------------------------------------------------ -;; :jit-only marks simd128 modules: zwasm's interpreter has no v128. -(def kernel-specs - [{:id "zig" :path (or (System/getenv "KERNEL") (str dir "/kernel.wasm")) :export "render"} - {:id "zig-simd" :path (str dir "/kernel_simd.wasm") :export "render_simd" :jit-only true} - {:id "c" :path (str dir "/kernel_c.wasm") :export "render"} - {:id "rust" :path (str dir "/kernel_rust.wasm") :export "render"}]) +(load-file (str dir "/cljw/tile.clj")) +(load-file (str dir "/cljw/renderer.clj")) +(load-file (str dir "/cljw/engines.clj")) -(def kernels (filterv #(.exists (io/file (:path %))) kernel-specs)) +(def public (str dir "/web/public")) +(def port (if (seq *command-line-args*) (Long/parseLong (first *command-line-args*)) 8080)) -;; --- file bytes --------------------------------------------------------------- -;; cljw's InputStream surface is the 1-arg .read (one byte per call), so drain -;; into a pre-sized array. +;; --- Collect --------------------------------------------------------------------- +;; cljw's InputStream surface is the 1-arg .read, so drain into a sized array. (defn file->bytes [path] (let [size (.length (io/file path))] (with-open [in (io/input-stream (io/file path))] (let [arr (byte-array size)] (dotimes [i size] (let [b (.read in)] - (when (neg? b) - (throw (ex-info "file shrank under read" {:path path :at i}))) + (when (neg? b) (throw (ex-info "file shrank under read" {:path path :at i}))) (aset arr i (unchecked-byte b)))) arr)))) -(def kernel-bytes (into {} (map (fn [k] [(:id k) (file->bytes (:path k))])) kernels)) - -;; --- engines -------------------------------------------------------------------- -;; An engine is {:inst :lock :export} for wasm, or {:raster true :lock}. -(defn wasm-engine [k runtime] - {:inst (wasm/load (:path k) {:engine runtime :fuel 0}) - :lock (Object.) - :export (:export k)}) - -(def engines - (into (array-map) - (concat - (for [k kernels - runtime [:jit :interp] - :when (not (and (:jit-only k) (= runtime :interp)))] - [(str (:id k) "-" (name runtime)) (wasm-engine k runtime)]) - [["cljw-clj" {:clj true :lock (Object.)}]]))) - -(def aliases {"jit" "zig-jit" "interp" "zig-interp" "jit-simd" "zig-simd-jit"}) +(def registry (fractal.cljw.renderer/registry + (fractal.cljw.engines/discover dir fractal.mandel/render))) +(def kernel-bytes (into {} (map (fn [[k path]] [k (file->bytes path)])) + (fractal.cljw.engines/kernel-files dir))) -(def cap (.fb_cap (:inst (get engines "zig-jit")))) +;; --- responses (pure, given the registry) ------------------------------------------ +(defn json-strings [xs] (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) -;; --- query parsing ------------------------------------------------------------ -(defn parse-query [qs] - (if (nil? qs) - {} - (into {} - (keep (fn [pair] - (let [i (.indexOf pair "=")] - (when (pos? i) - [(subs pair 0 i) (subs pair (inc i))])))) - (.split qs "&")))) - -(defn num [m k d] - (let [v (get m k)] - (if (nil? v) d (Double/parseDouble v)))) - -(defn lng [m k d] - (let [v (get m k)] - (if (nil? v) d (Long/parseLong v)))) - -;; --- tile rendering ----------------------------------------------------------- -;; The wasm framebuffer is read back in one bulk copy as a byte-array, which is -;; also the response body verbatim. -(defn render-tile [eng cx cy scale w h iter] - (when (> (* w h) cap) - (throw (ex-info "tile exceeds framebuffer capacity" {:cap cap :got (* w h)}))) - (locking (:lock eng) - (if (:clj eng) - (let [fb (byte-array (* w h 4)) - t0 (System/nanoTime) - iters (fractal.mandel/render fb cx cy scale w h iter)] - {:ms (/ (double (- (System/nanoTime) t0)) 1.0e6) :iters iters :bytes fb}) - (let [inst (:inst eng) - rc (.set_view inst cx cy scale)] - (when (neg? rc) - (throw (ex-info "set_view rejected a non-finite coordinate" {:cx cx :cy cy :scale scale}))) - (let [t0 (System/nanoTime) - iters (wasm/call inst (:export eng) w h iter) - ms (/ (double (- (System/nanoTime) t0)) 1.0e6)] - {:ms ms :iters iters - :bytes (wasm/mem-read-array inst :u8 (.fb_ptr inst) (* w h 4))}))))) - -;; --- static files --------------------------------------------------------------- -;; The compiled frontend: /js/** from web/public/js. No path may climb out. (def content-types {"js" "text/javascript" "map" "application/json" "css" "text/css"}) (defn static [uri] @@ -131,54 +58,41 @@ :body (file->bytes (.getPath f))} {:status 404 :body "not found\n"}))) -;; --- handler ------------------------------------------------------------------ -(defn json-strings [xs] - (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) - +(defn tile-response [q] + (try + (let [t (fractal.cljw.tile/parse-query q) + engine (get q "engine" "zig-jit")] + (if-let [rr (get registry engine)] + (let [{:keys [ms iters rgba]} (fractal.cljw.renderer/frame rr t)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body rgba}) + {:status 400 :body (str "engine must be one of " (vec (keys registry)))})) + (catch Throwable e + {:status 400 :body (str "tile error: " (.getMessage e))}))) + +;; --- Boundary ------------------------------------------------------------------------ (defn handler [req] - (let [q (parse-query (:query-string req))] + (let [q (fractal.cljw.tile/query-map (:query-string req))] (case (:uri req) "/" {:status 200 :headers {"content-type" "text/html; charset=utf-8"} :body (slurp (str public "/index.html"))} - - "/kernel.wasm" - (if-let [bytes (get kernel-bytes (get q "k" "zig"))] - {:status 200 - :headers {"content-type" "application/wasm" "cache-control" "no-cache"} - :body bytes} - {:status 404 :body "unknown kernel\n"}) - + "/kernel.wasm" (if-let [bytes (get kernel-bytes (get q "k" "zig"))] + {:status 200 + :headers {"content-type" "application/wasm" "cache-control" "no-cache"} + :body bytes} + {:status 404 :body "unknown kernel\n"}) "/engines" {:status 200 :headers {"content-type" "application/json"} - :body (str "{\"engines\":" (json-strings (keys engines)) - ",\"kernels\":" (json-strings (map :id kernels)) "}")} - - "/tile" - (try - (let [cx (num q "cx" -0.6) - cy (num q "cy" 0.0) - scale (num q "scale" 0.004) - w (lng q "w" 640) - h (lng q "h" 480) - iter (lng q "iter" 256) - id (get q "engine" "zig-jit") - eng (get engines (get aliases id id))] - (if (nil? eng) - {:status 400 :body (str "engine must be one of " (vec (keys engines)))} - (let [{:keys [ms iters bytes]} (render-tile eng cx cy scale w h iter)] - {:status 200 - :headers {"content-type" "application/octet-stream" - "cache-control" "no-store" - "x-render-ms" (format "%.3f" ms) - "x-iters" (str iters)} - :body bytes}))) - (catch Throwable e - {:status 400 :body (str "tile error: " (.getMessage e))})) - + :body (str "{\"engines\":" (json-strings (keys registry)) + ",\"kernels\":" (json-strings (keys kernel-bytes)) "}")} + "/tile" (tile-response q) (static (:uri req))))) (println (str "fractal server on http://localhost:" port - " kernels=" (mapv :id kernels) - " engines=" (vec (keys engines)))) + " engines=" (vec (keys registry)))) (http/run-server handler {:port port}) diff --git a/docs/examples/fractal/web/.gitignore b/docs/examples/fractal/web/.gitignore index e95d4228f..760fc4df0 100644 --- a/docs/examples/fractal/web/.gitignore +++ b/docs/examples/fractal/web/.gitignore @@ -2,3 +2,4 @@ node_modules/ .shadow-cljs/ public/js/cljs-runtime/ public/js/manifest.edn +target/ diff --git a/docs/examples/fractal/web/public/js/main.js b/docs/examples/fractal/web/public/js/main.js new file mode 100644 index 000000000..a5c2f1087 --- /dev/null +++ b/docs/examples/fractal/web/public/js/main.js @@ -0,0 +1,1073 @@ +var shadow$provide = {}; +(function(){ +shadow$provide[0]=function(I,Ka,A){function v(){throw Error("setTimeout has not been defined");}function U(){throw Error("clearTimeout has not been defined");}function Ma(da){if(Z===setTimeout)return setTimeout(da,0);if((Z===v||!Z)&&setTimeout)return Z=setTimeout,setTimeout(da,0);try{return Z(da,0)}catch(ra){try{return Z.call(null,da,0)}catch(la){return Z.call(this,da,0)}}}function Na(da){if(F===clearTimeout)return clearTimeout(da);if((F===U||!F)&&clearTimeout)return F=clearTimeout,clearTimeout(da); +try{return F(da)}catch(ra){try{return F.call(null,da)}catch(la){return F.call(this,da)}}}function na(){R&&aa&&(R=!1,aa.length?y=aa.concat(y):T=-1,y.length&&ab())}function ab(){if(!R){var da=Ma(na);R=!0;for(var ra=y.length;ra;){aa=y;for(y=[];++T1)for(var la=1;la>>1,L=C[ea];if(0>>1;a:for(;eaNa(zc,P))mNa(z,zc)?(C[ea]=z,C[m]=P,ea=m):(C[ea]=zc,C[Vb]=P,ea=Vb);else if(mNa(z,P))C[ea]=z,C[m]=P,ea=m;else break a}}return N} +function Na(C,N){var P=C.sortIndex-N.sortIndex;return 0!==P?P:C.id-N.id}function na(C){for(var N=U(T);null!==N;){if(null===N.callback)Ma(T);else if(N.startTime<=C)Ma(T),N.sortIndex=N.expirationTime,v(aa,N);else break;N=U(T)}}function ab(C){Ob=!1;na(C);if(!nb)if(null!==U(aa))nb=!0,bb||(bb=!0,Bb());else{var N=U(T);null!==N&&Z(ab,N.startTime-C)}}function Mb(){return Za?!0:A.unstable_now()-obC&&Mb());){var ea=ra.callback;if("function"===typeof ea){ra.callback=null;la=ra.priorityLevel;var L=ea(ra.expirationTime<=C);C=A.unstable_now();if("function"===typeof L){ra.callback=L;na(C);N=!0;break b}ra===U(aa)&&Ma(aa);na(C)}else Ma(aa);ra=U(aa)}if(null!==ra)N=!0;else{var pb=U(T);null!==pb&&Z(ab,pb.startTime-C);N=!1}}break a}finally{ra=null,la=P,Nb=!1}N=void 0}}finally{N?Bb():bb=!1}}}function Z(C,N){Cb=Ba(function(){C(A.unstable_now())}, +N)}A.unstable_now=void 0;if("object"===typeof performance&&"function"===typeof performance.now){var F=performance;A.unstable_now=function(){return F.now()}}else{var y=Date,R=y.now();A.unstable_now=function(){return y.now()-R}}var aa=[],T=[],da=1,ra=null,la=3,Nb=!1,nb=!1,Ob=!1,Za=!1,Ba="function"===typeof setTimeout?setTimeout:null,ia="function"===typeof clearTimeout?clearTimeout:null,jc="undefined"!==typeof setImmediate?setImmediate:null,bb=!1,Cb=-1,kc=5,ob=-1;if("function"===typeof jc)var Bb=function(){jc(za)}; +else if("undefined"!==typeof MessageChannel){I=new MessageChannel;var xd=I.port2;I.port1.onmessage=za;Bb=function(){xd.postMessage(null)}}else Bb=function(){Ba(za,0)};A.unstable_IdlePriority=5;A.unstable_ImmediatePriority=1;A.unstable_LowPriority=4;A.unstable_NormalPriority=3;A.unstable_Profiling=null;A.unstable_UserBlockingPriority=2;A.unstable_cancelCallback=function(C){C.callback=null};A.unstable_forceFrameRate=function(C){0>C||125ea?(C.sortIndex=P,v(T,C),null===U(aa)&&C===U(T)&&(Ob?(ia(Cb),Cb=-1):Ob=!0,Z(ab,P-ea))):(C.sortIndex=L,v(aa,C),nb||Nb||(nb=!0,bb||(bb=!0,Bb())));return C};A.unstable_shouldYield=Mb;A.unstable_wrapCallback=function(C){var N=la;return function(){var P= +la;la=N;try{return C.apply(this,arguments)}finally{la=P}}}}; +shadow$provide[6]=function(I,Ka,A){Ka.exports=I(4)}; +shadow$provide[7]=function(I,Ka,A){function v(F){var y="https://react.dev/errors/"+F;if(1Ad||(a.current=Zg[Ad],Zg[Ad]=null,Ad--)}function ia(a,b){Ad++;Zg[Ad]=a.current;a.current=b}function jc(a,b){ia(Bc,b);ia(se,a);ia(Xb,null);switch(b.nodeType){case 9:case 11:a=(a=b.documentElement)?(a=a.namespaceURI)?Hj(a):0:0;break;default:if(a=b.tagName,b=b.namespaceURI)b=Hj(b),a=Ij(b,a);else switch(a){case "svg":a=1;break;case "math":a=2;break;default:a=0}}Ba(Xb);ia(Xb,a)}function bb(){Ba(Xb); +Ba(se);Ba(Bc)}function Cb(a){var b=a.memoizedState;null!==b&&(Bd._currentValue=b.memoizedState,ia(of,a));b=Xb.current;var c=Ij(b,a.type);b!==c&&(ia(se,a),ia(Xb,c))}function kc(a){se.current===a&&(Ba(Xb),Ba(se));of.current===a&&(Ba(of),Bd._currentValue=bd)}function ob(a){if(void 0===$g)try{throw Error();}catch(c){var b=c.stack.trim().match(/\n( *(at )?)/);$g=b&&b[1]||"";Jj=-1e||k[d]!==q[e]){var u="\n"+k[d].replace(" at new "," at ");a.displayName&&u.includes("\x3canonymous\x3e")&&(u=u.replace("\x3canonymous\x3e",a.displayName));return u}while(1<=d&&0<=e)}break}}}finally{ah=!1,Error.prepareStackTrace=c}return(c=a?a.displayName||a.name:"")?ob(c):""}function xd(a,b){switch(a.tag){case 26:case 27:case 5:return ob(a.type);case 16:return ob("Lazy");case 13:return a.child!==b&&null!==b?ob("Suspense Fallback"):ob("Suspense");case 19:return ob("SuspenseList");case 0:case 15:return Bb(a.type, +!1);case 11:return Bb(a.type.render,!1);case 1:return Bb(a.type,!0);case 31:return ob("Activity");case 30:return ob("ViewTransition");default:return""}}function C(a){try{var b="",c=null;do b+=xd(a,c),c=a,a=a.return;while(a);return b}catch(d){return"\nError generating stack: "+d.message+"\n"+d.stack}}function N(a){"function"===typeof ho&&io(a);if(qb&&"function"===typeof qb.setStrictMode)try{qb.setStrictMode(te,a)}catch(b){}}function P(a){a>>>=0;return 0===a?32:31-(jo(a)/ko|0)|0}function ea(a){var b= +a&42;if(0!==b)return b;switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return a&-a;case 262144:case 524288:case 1048576:case 2097152:return a&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return a&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456; +case 536870912:return 536870912;case 1073741824:return 0;default:return a}}function L(a,b,c){var d=a.pendingLanes;if(0===d)return 0;var e=0,f=a.suspendedLanes,g=a.pingedLanes;a=a.warmLanes;var h=d&134217727;0!==h?(d=h&~f,0!==d?e=ea(d):(g&=h,0!==g?e=ea(g):c||(c=h&~a,0!==c&&(e=ea(c))))):(h=d&~f,0!==h?e=ea(h):0!==g?e=ea(g):c||(c=d&~a,0!==c&&(e=ea(c))));return 0===e?0:0!==b&&b!==e&&0===(b&f)&&(f=e&-e,c=b&-b,f>=c||32===f&&0!==(c&4194048))?b:e}function pb(a,b){return 0===(a.pendingLanes&~(a.suspendedLanes& +~a.pingedLanes)&b)}function Vb(a,b){0!==(b&8)&&(b|=b&32);var c=a.entangledLanes;if(0!==c)for(a=a.entanglements,c&=b;0c;c++)b.push(a);return b}function G(a,b){a.pendingLanes|=b;268435456!==b&&(a.suspendedLanes=0,a.pingedLanes=0,a.warmLanes=0)}function J(a,b,c,d,e,f){var g=a.pendingLanes;a.pendingLanes=c;a.suspendedLanes=0;a.pingedLanes=0;a.warmLanes=0;a.expiredLanes&=c;a.entangledLanes&=c;a.errorRecoveryDisabledLanes&=c;a.shellSuspendCounter=0;var h=a.entanglements,k=a.expirationTimes,q=a.hiddenUpdates; +for(c=g&~c;0=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=qk(c)}}function sk(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?sk(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function tk(a){a=null!=a&&null!=a.ownerDocument&&null!=a.ownerDocument.defaultView?a.ownerDocument.defaultView:window;for(var b=lh(a.document);b instanceof a.HTMLIFrameElement;){try{var c= +"string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;b=lh(a.document)}return b}function mh(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function uk(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;nh||null==Ld||Ld!==lh(d)||(d=Ld,"selectionStart"in d&&mh(d)?d={start:d.selectionStart, +end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d={anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),Be&&Ae(Be,d)||(Be=d,d=zf(oh,"onSelect"),0>=g;e-=g;Zb=1<<32-rb(b)+e|c<M?(Ga=H,H=null):Ga=H.sibling;var ca=n(p,H,r[M],w);if(null===ca){null===H&&(H=Ga);break}a&&H&&null===ca.alternate&&b(p,H);l=f(ca,l,M);null===X?D=ca:X.sibling= +ca;X=ca;H=Ga}if(M===r.length)return c(p,H),S&&pc(p,M),D;if(null===H){for(;MM?(Ga=H,H=null):Ga=H.sibling;var Jc=n(p,H,ca.value,w);if(null===Jc){null===H&&(H=Ga);break}a&&H&&null===Jc.alternate&&b(p,H);l=f(Jc,l,M);null===X?D=Jc:X.sibling=Jc;X=Jc;H=Ga}if(ca.done)return c(p,H),S&&pc(p,M),D;if(null===H){for(;!ca.done;M++,ca=r.next())ca=x(p,ca.value,w),null!==ca&&(l=f(ca,l,M),null===X?D=ca:X.sibling=ca,X=ca);S&&pc(p,M);return D}for(H=d(H);!ca.done;M++,ca=r.next())ca=t(H,p,M,ca.value,w),null!==ca&&(a&&(Ga=ca.alternate,null!==Ga&&H.delete(null=== +Ga.key?M:Ga.key)),l=f(ca,l,M),null===X?D=ca:X.sibling=ca,X=ca);a&&H.forEach(function(Qo){return b(p,Qo)});S&&pc(p,M);return D}function Q(p,l,r,w){"object"===typeof r&&null!==r&&r.type===zd&&null===r.key&&void 0===r.props.ref&&(r=r.props.children);if("object"===typeof r&&null!==r){switch(r.$$typeof){case Rf:a:{for(var D=r.key;null!==l;){if(l.key===D){D=r.type;if(D===zd){if(7===l.tag){c(p,l.sibling);w=e(l,r.props.children);Ic(w,r);w.return=p;p=w;break a}}else if(l.elementType===D||"object"===typeof D&& +null!==D&&D.$$typeof===Ac&&od(D)===l.type){c(p,l.sibling);w=e(l,r.props);Ic(w,r);w.return=p;p=w;break a}c(p,l);break}else b(p,l);l=l.sibling}r.type===zd?(w=hd(r.props.children,p.mode,w,r.key),Ic(w,r),w.return=p,p=w):(w=Gf(r.type,r.key,r.props,null,p.mode,w),Ic(w,r),w.return=p,p=w)}return g(p);case re:a:{for(D=r.key;null!==l;){if(l.key===D)if(4===l.tag&&l.stateNode.containerInfo===r.containerInfo&&l.stateNode.implementation===r.implementation){c(p,l.sibling);w=e(l,r.children||[]);w.return=p;p=w;break a}else{c(p, +l);break}else b(p,l);l=l.sibling}w=uh(r,p.mode,w);w.return=p;p=w}return g(p);case Ac:return r=od(r),Q(p,l,r,w)}if(we(r))return B(p,l,r,w);if(nb(r)){D=nb(r);if("function"!==typeof D)throw Error(v(150));r=D.call(r);return E(p,l,r,w)}if("function"===typeof r.then)return Q(p,l,Pf(r),w);if(r.$$typeof===Wb)return Q(p,l,Mf(p,r),w);Qf(p,r)}return"string"===typeof r&&""!==r||"number"===typeof r||"bigint"===typeof r?(r=""+r,null!==l&&6===l.tag?(c(p,l.sibling),w=e(l,r),w.return=p,p=w):(c(p,l),w=th(r,p.mode, +w),w.return=p,p=w),g(p)):c(p,l)}return function(p,l,r,w){try{Je=0;var D=Q(p,l,r,w);Ud=null;return D}catch(H){if(H===Td||H===Of)throw H;var X=ib(29,H,null,p.mode);X.lanes=w;X.return=p;return X}finally{}}}function Gh(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Hh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue={baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate, +shared:a.shared,callbacks:null})}function Kc(a){return{lane:a,tag:0,payload:null,callback:null,next:null}}function Lc(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(ka&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;b=Ef(a);yk(a,null,c);return b}Df(a,d,b,c);return Ef(a)}function Ke(a,b,c){b=b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194048))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;ha(a,c)}}function Ih(a,b){var c=a.updateQueue,d=a.alternate; +if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={lane:c.lane,tag:c.tag,payload:c.payload,callback:null,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,shared:d.shared,callbacks:d.callbacks};a.updateQueue=c}else a=c.lastBaseUpdate,null===a?c.firstBaseUpdate=b:a.next=b,c.lastBaseUpdate=b}function Le(){if(Jh){var a=Sd;if(null!==a)throw a;}}function Me(a, +b,c,d){Jh=!1;var e=a.updateQueue;Mc=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,q=k.next;k.next=null;null===g?f=q:g.next=q;var u=a.alternate;null!==u&&(u=u.updateQueue,h=u.lastBaseUpdate,h!==k&&(null===h?u.firstBaseUpdate=q:h.next=q,u.lastBaseUpdate=k))}if(null!==f){var x=e.baseState;g=0;u=q=k=null;h=f;do{var n=h.lane&-536870913,t=n!==h.lane;if(t?(Y&n)===n:(d&n)===n){0!==n&&n===ld&&(Jh=!0);null!==u&&(u=u.next={lane:0,tag:h.tag,payload:h.payload, +callback:null,next:null});a:{var B=a,E=h;n=b;var Q=c;switch(E.tag){case 1:B=E.payload;if("function"===typeof B){x=B.call(Q,x,n);break a}x=B;break a;case 3:B.flags=B.flags&-65537|128;case 0:B=E.payload;n="function"===typeof B?B.call(Q,x,n):B;if(null===n||void 0===n)break a;x=ua({},x,n);break a;case 2:Mc=!0}}n=h.callback;null!==n&&(a.flags|=64,t&&(a.flags|=8192),t=e.callbacks,null===t?e.callbacks=[n]:t.push(n))}else t={lane:n,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===u?(q=u= +t,k=x):u=u.next=t,g|=n;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else t=h,h=t.next,t.next=null,e.lastBaseUpdate=t,e.shared.pending=null}while(1);null===u&&(k=x);e.baseState=k;e.firstBaseUpdate=q;e.lastBaseUpdate=u;null===f&&(e.shared.lanes=0);Nc|=g;a.lanes=g;a.memoizedState=x}}function Qk(a,b){if("function"!==typeof a)throw Error(v(191,a));a.call(b)}function Rk(a,b){var c=a.callbacks;if(null!==c)for(a.callbacks=null,a=0;af?f:8;var g=O.T,h={};h.types=null!==g?g.types:null;O.T=h;ai(a,!1,b,c);try{var k=e(),q=O.S;null!==q&&q(h,k);if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var u=Oo(k,d);Qe(a,b,u,vb(a))}else Qe(a,b,d,vb(a))}catch(x){Qe(a,b,{then:function(){},status:"rejected",reason:x},vb())}finally{ja.p=f,null!==g&&null!==h.types&&(g.types=h.types), +O.T=g}}function Yo(){}function di(a,b,c,d){if(5!==a.tag)throw Error(v(476));var e=Al(a).queue;zl(a,e,b,bd,null===c?Yo:function(){Bl(a);return c(d)})}function Al(a){var b=a.memoizedState;if(null!==b)return b;b={memoizedState:bd,baseState:bd,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:bd},next:null};var c={};b.next={memoizedState:c,baseState:c,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:c},next:null}; +a.memoizedState=b;a=a.alternate;null!==a&&(a.memoizedState=b);return b}function Bl(a){var b=Al(a);null===b.next&&(b=a.alternate.memoizedState);Qe(a,b.next.queue,{},vb())}function ei(){return Ua(Bd)}function Cl(){return Ea().memoizedState}function Dl(){return Ea().memoizedState}function Zo(a){for(var b=a.return;null!==b;){switch(b.tag){case 24:case 3:var c=vb();a=Kc(c);var d=Lc(b,a,c);null!==d&&(kb(d,b,c),Ke(d,b,c));b={cache:Ch()};a.payload=b;return}b=b.return}}function $o(a,b,c){var d=vb();c={lane:d, +revertLane:0,gesture:null,action:c,hasEagerState:!1,eagerState:null,next:null};$f(a)?El(b,c):(c=rh(a,b,c,d),null!==c&&(kb(c,a,d),Fl(c,b,d)))}function kl(a,b,c){var d=vb();Qe(a,b,c,d)}function Qe(a,b,c,d){var e={lane:d,revertLane:0,gesture:null,action:c,hasEagerState:!1,eagerState:null,next:null};if($f(a))El(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(tb(h,g))return Df(a, +b,e,0),null===va&&Cf(),!1}catch(k){}finally{}c=rh(a,b,e,d);if(null!==c)return kb(c,a,d),Fl(c,b,d),!0}return!1}function ai(a,b,c,d){d={lane:2,revertLane:Eh(),gesture:null,action:d,hasEagerState:!1,eagerState:null,next:null};if($f(a)){if(b)throw Error(v(479));}else b=rh(a,c,d,2),null!==b&&kb(b,a,2)}function $f(a){var b=a.alternate;return a===K||null!==b&&b===K}function El(a,b){Vd=Vf=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function Fl(a,b,c){if(0!==(c&4194048)){var d= +b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;ha(a,c)}}function fi(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:ua({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function Gl(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!Ae(c,d)||!Ae(e,f):!0}function Hl(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"=== +typeof b.UNSAFE_componentWillReceiveProps&&b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&gi.enqueueReplaceState(b,b.state,null)}function qd(a,b){var c=b;if("ref"in b){c={};for(var d in b)"ref"!==d&&(c[d]=b[d])}if(a=a.defaultProps){c===b&&(c=ua({},c));for(var e in a)void 0===c[e]&&(c[e]=a[e])}return c}function Il(a){cg(a)}function Jl(a){console.error(a)}function Kl(a){cg(a)}function dg(a,b){try{var c=a.onUncaughtError;c(b.value,{componentStack:b.stack})}catch(d){setTimeout(function(){throw d; +})}}function Ll(a,b,c){try{var d=a.onCaughtError;d(c.value,{componentStack:c.stack,errorBoundary:1===b.tag?b.stateNode:null})}catch(e){setTimeout(function(){throw e;})}}function hi(a,b,c){c=Kc(c);c.tag=3;c.payload={element:null};c.callback=function(){dg(a,b)};return c}function Ml(a){a=Kc(a);a.tag=3;return a}function Nl(a,b,c,d){var e=c.type.getDerivedStateFromError;if("function"===typeof e){var f=d.value;a.payload=function(){return e(f)};a.callback=function(){Ll(b,c,d)}}var g=c.stateNode;null!==g&& +"function"===typeof g.componentDidCatch&&(a.callback=function(){Ll(b,c,d);"function"!==typeof e&&(null===Rc?Rc=new Set([this]):Rc.add(this));var h=d.stack;this.componentDidCatch(d.value,{componentStack:null!==h?h:""})})}function ap(a,b,c,d,e){c.flags|=32768;if(null!==d&&"object"===typeof d&&"function"===typeof d.then){b=c.alternate;null!==b&&id(b,c,e,!0);c=Wa.current;if(null!==c){switch(c.tag){case 31:case 13:case 19:return null===$a?eg():null===c.alternate&&0===Da&&(Da=3),c.flags&=-257,c.flags|= +65536,c.lanes=e,d===fg?c.flags|=16384:(b=c.updateQueue,null===b?c.updateQueue=new Set([d]):b.add(d),ii(a,d,e)),!1;case 22:return c.flags|=65536,d===fg?c.flags|=16384:(b=c.updateQueue,null===b?(b={transitions:null,markerInstances:null,retryQueue:new Set([d])},c.updateQueue=b):(c=b.retryQueue,null===c?b.retryQueue=new Set([d]):c.add(d)),ii(a,d,e)),!1}throw Error(v(435,c.tag));}ii(a,d,e);eg();return!1}if(S)return b=Wa.current,null!==b?(0===(b.flags&65536)&&(b.flags|=256),b.flags|=65536,b.lanes=e,d!== +wh&&(a=Error(v(422),{cause:d}),Ee(Fb(a,c)))):(d!==wh&&(b=Error(v(423),{cause:d}),Ee(Fb(b,c))),a=a.current.alternate,a.flags|=65536,e&=-e,a.lanes|=e,d=Fb(d,c),e=hi(a.stateNode,d,e),Ih(a,e),4!==Da&&(Da=2)),!1;var f=Error(v(520),{cause:d});f=Fb(f,c);null===Re?Re=[f]:Re.push(f);4!==Da&&(Da=2);if(null===b)return!0;d=Fb(d,c);c=b;do{switch(c.tag){case 3:return c.flags|=65536,a=e&-e,c.lanes|=a,a=hi(c.stateNode,d,a),Ih(c,a),!1;case 1:b=c.type;f=c.stateNode;if(0===(c.flags&128)&&("function"===typeof b.getDerivedStateFromError|| +null!==f&&"function"===typeof f.componentDidCatch&&(null===Rc||!Rc.has(f))))return c.flags|=65536,e&=-e,c.lanes|=e,e=Ml(e),Nl(e,a,c,d),Ih(c,e),!1;break;case 22:if(null!==c.memoizedState)return c.flags|=65536,!1}c=c.return}while(null!==c);return!1}function Ja(a,b,c,d){b.child=null===a?Ol(b,null,c,d):rd(b,a.child,c,d)}function Pl(a,b,c,d,e){c=c.render;var f=b.ref;if("ref"in d){var g={};for(h in d)"ref"!==h&&(g[h]=d[h])}else g=d;jd(b);d=Rh(a,b,c,g,f,e);var h=Sh();if(null!==a&&!Ia)return Th(a,b,e),vc(a, +b,e);S&&h&&If(b);b.flags|=1;Ja(a,b,d,e);return b.child}function Ql(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!sh(f)&&void 0===f.defaultProps&&null===c.compare)return b.tag=15,b.type=f,Rl(a,b,f,d,e);a=Gf(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(!ji(a,e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:Ae;if(c(g,d)&&a.ref===b.ref)return vc(a,b,e)}b.flags|=1;a=oc(f,d);a.ref=b.ref;a.return=b;return b.child=a}function Rl(a,b,c,d,e){if(null!==a){var f= +a.memoizedProps;if(Ae(f,d)&&a.ref===b.ref)if(Ia=!1,b.pendingProps=d=f,ji(a,e))0!==(a.flags&131072)&&(Ia=!0);else return b.lanes=a.lanes,vc(a,b,e)}return ki(a,b,c,d,e)}function Sl(a,b,c,d){var e=d.children,f=null!==a?a.memoizedState:null;null===a&&null===b.stateNode&&(b.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null});if("hidden"===d.mode){if(0!==(b.flags&128)){f=null!==f?f.baseLanes|c:c;if(null!==a){d=b.child=a.child;for(e=0;null!==d;)e|=d.lanes|d.childLanes,d=d.sibling; +d=e&~f}else d=0,b.child=null;return Tl(a,b,f,c,d)}if(0!==(c&536870912))b.memoizedState={baseLanes:0,cachePool:null},null!==a&&Nf(b,null!==f?f.cachePool:null),null!==f?Sk(b,f):Kh(),Tk(b);else return d=b.lanes=536870912,Tl(a,b,null!==f?f.baseLanes|c:c,c,d)}else null!==f?(Nf(b,f.cachePool),Sk(b,f),Qc(),b.memoizedState=null):(null!==a&&Nf(b,null),Kh(),Qc());Ja(a,b,e,c);return b.child}function Se(a,b){null!==a&&22===a.tag||null!==b.stateNode||(b.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null, +_transitions:null});return b.sibling}function Tl(a,b,c,d,e){var f=Fh();f=null===f?null:{parent:Fa._currentValue,pool:f};b.memoizedState={baseLanes:c,cachePool:f};null!==a&&Nf(b,null);Kh();Tk(b);null!==a&&id(a,b,d,!0);b.childLanes=e;return null}function gg(a,b){b=hg({mode:b.mode,children:b.children},a.mode);b.ref=a.ref;a.child=b;b.return=a;return b}function Ul(a,b,c){rd(b,a.child,null,c);a=gg(b,b.pendingProps);a.flags|=2;ub(b);b.memoizedState=null;return a}function bp(a,b,c){var d=b.pendingProps,e= +0!==(b.flags&128);b.flags&=-129;if(null===a){if(S){if("hidden"===d.mode)return a=gg(b,d),b.lanes=536870912,a.memoizedState={baseLanes:0,cachePool:null},Se(null,a);Mh(b);(a=wa)?(a=Vl(a,Ib),a=null!==a&&"\x26"===a.data?a:null,null!==a&&(b.memoizedState={dehydrated:a,treeContext:null!==Dc?{id:Zb,overflow:$b}:null,retryLane:536870912,hydrationErrors:null},c=Ak(a),c.return=b,b.child=c,Qa=b,wa=null)):a=null;if(null===a)throw Ec(b);b.lanes=536870912;return null}return gg(b,d)}var f=a.memoizedState;if(null!== +f){var g=f.dehydrated;Mh(b);if(e)if(b.flags&256)b.flags&=-257,b=Ul(a,b,c);else if(null!==b.memoizedState)b.child=a.child,b.flags|=128,b=null;else throw Error(v(558));else if(Ia||id(a,b,c,!1),e=0!==(c&a.childLanes),Ia||e){if(null===Oc.current){d=va;if(null!==d&&(g=sa(d,c),0!==g&&g!==f.retryLane))throw f.retryLane=g,gd(a,g),kb(d,a,g),li;eg()}b=Ul(a,b,c)}else a=f.treeContext,wa=Jb(g.nextSibling),Qa=b,S=!0,Gc=null,Ib=!1,null!==a&&Dk(b,a),b=gg(b,d),b.flags|=134221824;return b}a=oc(a.child,{mode:d.mode, +children:d.children});a.ref=b.ref;b.child=a;a.return=b;return a}function Yd(a,b){var c=b.ref;if(null===c)null!==a&&null!==a.ref&&(b.flags|=4194816);else{if("function"!==typeof c&&"object"!==typeof c)throw Error(v(284));if(null===a||a.ref!==c)b.flags|=4194816}}function ki(a,b,c,d,e){jd(b);c=Rh(a,b,c,d,void 0,e);d=Sh();if(null!==a&&!Ia)return Th(a,b,e),vc(a,b,e);S&&d&&If(b);b.flags|=1;Ja(a,b,c,e);return b.child}function Wl(a,b,c,d,e,f){jd(b);b.updateQueue=null;c=Wk(b,d,c,e);Xk(a);d=Sh();if(null!==a&& +!Ia)return Th(a,b,f),vc(a,b,f);S&&d&&If(b);b.flags|=1;Ja(a,b,c,f);return b.child}function Xl(a,b,c,d,e){jd(b);if(null===b.stateNode){var f=Zd,g=c.contextType;"object"===typeof g&&null!==g&&(f=Ua(g));f=new c(d,f);b.memoizedState=null!==f.state&&void 0!==f.state?f.state:null;f.updater=gi;b.stateNode=f;f._reactInternals=b;f=b.stateNode;f.props=d;f.state=b.memoizedState;f.refs={};Gh(b);g=c.contextType;f.context="object"===typeof g&&null!==g?Ua(g):Zd;f.state=b.memoizedState;g=c.getDerivedStateFromProps; +"function"===typeof g&&(fi(b,c,g,d),f.state=b.memoizedState);"function"===typeof c.getDerivedStateFromProps||"function"===typeof f.getSnapshotBeforeUpdate||"function"!==typeof f.UNSAFE_componentWillMount&&"function"!==typeof f.componentWillMount||(g=f.state,"function"===typeof f.componentWillMount&&f.componentWillMount(),"function"===typeof f.UNSAFE_componentWillMount&&f.UNSAFE_componentWillMount(),g!==f.state&&gi.enqueueReplaceState(f,f.state,null),Me(b,d,f,e),Le(),f.state=b.memoizedState);"function"=== +typeof f.componentDidMount&&(b.flags|=4194308);d=!0}else if(null===a){f=b.stateNode;var h=b.memoizedProps,k=qd(c,h);f.props=k;var q=f.context,u=c.contextType;g=Zd;"object"===typeof u&&null!==u&&(g=Ua(u));var x=c.getDerivedStateFromProps;u="function"===typeof x||"function"===typeof f.getSnapshotBeforeUpdate;h=b.pendingProps!==h;u||"function"!==typeof f.UNSAFE_componentWillReceiveProps&&"function"!==typeof f.componentWillReceiveProps||(h||q!==g)&&Hl(b,f,d,g);Mc=!1;var n=b.memoizedState;f.state=n;Me(b, +d,f,e);Le();q=b.memoizedState;h||n!==q||Mc?("function"===typeof x&&(fi(b,c,x,d),q=b.memoizedState),(k=Mc||Gl(b,c,k,d,n,q,g))?(u||"function"!==typeof f.UNSAFE_componentWillMount&&"function"!==typeof f.componentWillMount||("function"===typeof f.componentWillMount&&f.componentWillMount(),"function"===typeof f.UNSAFE_componentWillMount&&f.UNSAFE_componentWillMount()),"function"===typeof f.componentDidMount&&(b.flags|=4194308)):("function"===typeof f.componentDidMount&&(b.flags|=4194308),b.memoizedProps= +d,b.memoizedState=q),f.props=d,f.state=q,f.context=g,d=k):("function"===typeof f.componentDidMount&&(b.flags|=4194308),d=!1)}else{f=b.stateNode;Hh(a,b);g=b.memoizedProps;u=qd(c,g);f.props=u;x=b.pendingProps;n=f.context;q=c.contextType;k=Zd;"object"===typeof q&&null!==q&&(k=Ua(q));h=c.getDerivedStateFromProps;(q="function"===typeof h||"function"===typeof f.getSnapshotBeforeUpdate)||"function"!==typeof f.UNSAFE_componentWillReceiveProps&&"function"!==typeof f.componentWillReceiveProps||(g!==x||n!== +k)&&Hl(b,f,d,k);Mc=!1;n=b.memoizedState;f.state=n;Me(b,d,f,e);Le();var t=b.memoizedState;g!==x||n!==t||Mc||null!==a&&null!==a.dependencies&&Lf(a.dependencies)?("function"===typeof h&&(fi(b,c,h,d),t=b.memoizedState),(u=Mc||Gl(b,c,u,d,n,t,k)||null!==a&&null!==a.dependencies&&Lf(a.dependencies))?(q||"function"!==typeof f.UNSAFE_componentWillUpdate&&"function"!==typeof f.componentWillUpdate||("function"===typeof f.componentWillUpdate&&f.componentWillUpdate(d,t,k),"function"===typeof f.UNSAFE_componentWillUpdate&& +f.UNSAFE_componentWillUpdate(d,t,k)),"function"===typeof f.componentDidUpdate&&(b.flags|=4),"function"===typeof f.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof f.componentDidUpdate||g===a.memoizedProps&&n===a.memoizedState||(b.flags|=4),"function"!==typeof f.getSnapshotBeforeUpdate||g===a.memoizedProps&&n===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=t),f.props=d,f.state=t,f.context=k,d=u):("function"!==typeof f.componentDidUpdate||g===a.memoizedProps&&n=== +a.memoizedState||(b.flags|=4),"function"!==typeof f.getSnapshotBeforeUpdate||g===a.memoizedProps&&n===a.memoizedState||(b.flags|=1024),d=!1)}f=d;Yd(a,b);d=0!==(b.flags&128);f||d?(f=b.stateNode,c=d&&"function"!==typeof c.getDerivedStateFromError?null:f.render(),b.flags|=1,null!==a&&d?(b.child=rd(b,a.child,null,e),b.child=rd(b,null,c,e)):Ja(a,b,c,e),b.memoizedState=f.state,a=b.child):a=vc(a,b,e);return a}function mi(a){return{baseLanes:a,cachePool:Kk()}}function ni(a,b,c){a=null!==a?a.childLanes&~c: +0;b&&(a|=wb);return a}function Yl(a,b,c){var d=b.pendingProps,e=!1,f=0!==(b.flags&128),g;(g=f)||(g=null!==a&&null===a.memoizedState?!1:0!==(Va.current&2));g&&(e=!0,b.flags&=-129);g=0!==(b.flags&32);b.flags&=-33;if(null===a){if(S){e?Pc(b):Qc();(a=wa)?(a=Vl(a,Ib),a=null!==a&&"\x26"!==a.data?a:null,null!==a&&(b.memoizedState={dehydrated:a,treeContext:null!==Dc?{id:Zb,overflow:$b}:null,retryLane:536870912,hydrationErrors:null},c=Ak(a),c.return=b,b.child=c,Qa=b,wa=null)):a=null;if(null===a)throw Ec(b); +Ph(a)?b.lanes=32:b.lanes=536870912;return null}f=d.children;d=d.fallback;if(e)return Qc(),e=b.mode,f=hg({mode:"hidden",children:f},e),d=hd(d,e,c,null),f.return=b,d.return=b,f.sibling=d,b.child=f,d=b.child,d.memoizedState=mi(c),d.childLanes=ni(a,g,c),b.memoizedState=oi,Se(null,d);Pc(b);return pi(b,f)}var h=a.memoizedState;if(null!==h){var k=h.dehydrated;if(null!==k)return cp(a,b,f,g,d,k,h,c)}if(e)return Qc(),e=d.fallback,f=b.mode,h=a.child,k=h.sibling,d=oc(h,{mode:"hidden",children:d.children}),d.subtreeFlags= +h.subtreeFlags&1206910976,null!==k?e=oc(k,e):(e=hd(e,f,c,null),e.flags|=2),e.return=b,d.return=b,d.sibling=e,b.child=d,Se(null,d),d=b.child,e=a.child.memoizedState,null===e?e=mi(c):(f=e.cachePool,null!==f?(h=Fa._currentValue,f=f.parent!==h?{parent:h,pool:h}:f):f=Kk(),e={baseLanes:e.baseLanes|c,cachePool:f}),d.memoizedState=e,d.childLanes=ni(a,g,c),b.memoizedState=oi,Se(a.child,d);Pc(b);c=a.child;a=c.sibling;c=oc(c,{mode:"visible",children:d.children});c.return=b;c.sibling=null;null!==a&&(g=b.deletions, +null===g?(b.deletions=[a],b.flags|=16):g.push(a));b.child=c;b.memoizedState=null;return c}function pi(a,b){b=hg({mode:"visible",children:b},a.mode);b.return=a;return a.child=b}function hg(a,b){a=ib(22,a,null,b);a.lanes=0;return a}function ig(a,b,c){rd(b,a.child,null,c);a=pi(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function cp(a,b,c,d,e,f,g,h){if(c){if(b.flags&256)return Pc(b),b.flags&=-257,ig(a,b,h);if(null!==b.memoizedState)return Qc(),b.child=a.child,b.flags|=128,null; +Qc();f=e.fallback;g=b.mode;e=hg({mode:"visible",children:e.children},g);f=hd(f,g,h,null);f.flags|=2;e.return=b;f.return=b;e.sibling=f;b.child=e;rd(b,a.child,null,h);e=b.child;e.memoizedState=mi(h);e.childLanes=ni(a,d,h);b.memoizedState=oi;return Se(null,e)}Pc(b);if(Ph(f)){if(d=f.nextSibling&&f.nextSibling.dataset)var k=d.dgst;d=k;""!==d&&(e=Error(v(419)),e.stack="",e.digest=d,Ee({value:e,source:null,stack:null}));return ig(a,b,h)}Ia||id(a,b,h,!1);d=0!==(h&a.childLanes);if(Ia||d){if(null!==Oc.current)return ig(a, +b,h);d=va;if(null!==d&&(e=sa(d,h),0!==e&&e!==g.retryLane))throw g.retryLane=e,gd(a,e),kb(d,a,e),li;Oh(f)||eg();return ig(a,b,h)}if(Oh(f))return b.flags|=192,b.child=a.child,null;a=g.treeContext;wa=Jb(f.nextSibling);Qa=b;S=!0;Gc=null;Ib=!1;null!==a&&Dk(b,a);b=pi(b,e.children);b.flags|=134221824;return b}function Zl(a,b,c){a.lanes|=b;var d=a.alternate;null!==d&&(d.lanes|=b);Kf(a.return,b,c)}function $l(a){for(var b=null;null!==a;){var c=a.alternate;null!==c&&null===Tf(c)&&(b=a);a=a.sibling}return b} +function jg(a,b,c,d,e,f){var g=a.memoizedState;null===g?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e,treeForkCount:f}:(g.isBackwards=b,g.rendering=null,g.renderingStartTime=0,g.last=d,g.tail=c,g.tailMode=e,g.treeForkCount=f)}function qi(a){var b=a.child;for(a.child=null;null!==b;){var c=b.sibling;b.sibling=a.child;a.child=b;b=c}}function ri(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;d=d.children;var g=Va.current;if(b.flags&128)return Ne(b,g), +null;var h=0!==(g&2);h?(g=g&1|2,b.flags|=128):g&=1;Ne(b,g);"backwards"===e&&null!==a?(qi(a),Ja(a,b,d,c),qi(a)):Ja(a,b,d,c);d=S?De:0;if(!h&&null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&Zl(a,c,b);else if(19===a.tag)Zl(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}switch(e){case "backwards":c=$l(b.child); +null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null,qi(b));jg(b,!0,e,null,f,d);break;case "unstable_legacy-backwards":c=null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===Tf(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}jg(b,!0,c,null,f,d);break;case "together":jg(b,!1,null,null,void 0,d);break;case "independent":b.memoizedState=null;break;default:c=$l(b.child),null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null),jg(b,!1,e,c,f,d)}return b.child} +function am(a,b,c){var d=b.pendingProps;Hc(b,b.type,d.value);Ja(a,b,d.children,c);return b.child}function vc(a,b,c){null!==a&&(b.dependencies=a.dependencies);Nc|=b.lanes;if(0===(c&b.childLanes))if(null!==a){if(id(a,b,c,!1),0===(c&b.childLanes))return null}else return null;if(null!==a&&b.child!==a.child)throw Error(v(153));if(null!==b.child){a=b.child;c=oc(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=oc(a,a.pendingProps),c.return=b;c.sibling=null}return b.child} +function ji(a,b){if(0!==(a.lanes&b))return!0;a=a.dependencies;return null!==a&&Lf(a)?!0:!1}function dp(a,b,c){switch(b.tag){case 3:jc(b,b.stateNode.containerInfo);Hc(b,Fa,a.memoizedState.cache);Fc();break;case 27:case 5:Cb(b);break;case 4:jc(b,b.stateNode.containerInfo);break;case 10:Hc(b,b.type,b.memoizedProps.value);break;case 31:if(null!==b.memoizedState)return b.flags|=128,Mh(b),null;break;case 13:var d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return Pc(b),b.flags|=128,null;d=id(a, +b,c,!1);var e=b.child.childLanes;if(d||0!==(c&e))return Yl(a,b,c);Pc(b);a=vc(a,b,c);return null!==a?a.sibling:null}Pc(b);break;case 19:if(b.flags&128)return ri(a,b,c);e=0!==(a.flags&128);(d=0!==(c&b.childLanes))||(id(a,b,c,!1),d=0!==(c&b.childLanes));if(e){if(d)return ri(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);Ne(b,Va.current);if(d)break;else return null;case 22:return b.lanes=0,Sl(a,b,c,b.pendingProps);case 24:Hc(b,Fa,a.memoizedState.cache)}return vc(a, +b,c)}function bm(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps)Ia=!0;else{if(!ji(a,c)&&0===(b.flags&128))return Ia=!1,dp(a,b,c);Ia=0!==(a.flags&131072)?!0:!1}else Ia=!1,S&&0!==(b.flags&1048576)&&Ck(b,De,b.index);b.lanes=0;switch(b.tag){case 16:a:{var d=b.pendingProps;a=od(b.elementType);b.type=a;if("function"===typeof a)sh(a)?(d=qd(a,d),b.tag=1,b=Xl(null,b,a,d,c)):(b.tag=0,b=ki(null,b,a,d,c));else{if(void 0!==a&&null!==a){var e=a.$$typeof;if(e===Xg){b.tag=11;b=Pl(null,b,a,d,c);break a}else if(e=== +Yg){b.tag=14;b=Ql(null,b,a,d,c);break a}else if(e===Wb){b.tag=10;b.type=a;b=am(null,b,c);break a}}b=Ob(a)||a;throw Error(v(306,b,""));}}return b;case 0:return ki(a,b,b.type,b.pendingProps,c);case 1:return d=b.type,e=qd(d,b.pendingProps),Xl(a,b,d,e,c);case 3:a:{jc(b,b.stateNode.containerInfo);if(null===a)throw Error(v(387));d=b.pendingProps;var f=b.memoizedState;e=f.element;Hh(a,b);Me(b,d,null,c);var g=b.memoizedState;d=g.cache;Hc(b,Fa,d);d!==f.cache&&Bh(b,[Fa],c,!0);Le();d=g.element;if(f.isDehydrated)if(f= +{element:d,isDehydrated:!1,cache:g.cache},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){Fc();b.flags|=256;Ja(a,b,d,c);b=b.child;break a}else if(d!==e){e=Fb(Error(v(424)),b);Ee(e);Fc();b.flags|=256;Ja(a,b,d,c);b=b.child;break a}else{a=b.stateNode.containerInfo;switch(a.nodeType){case 9:a=a.body;break;default:a="HTML"===a.nodeName?a.ownerDocument.body:a}wa=Jb(a.firstChild);Qa=b;S=!0;Gc=null;Ib=!0;c=Ol(b,null,d,c);for(b.child=c;c;)c.flags=c.flags&-3|134221824,c=c.sibling}else{Fc();if(d=== +e){b=vc(a,b,c);break a}Ja(a,b,d,c)}b=b.child}return b;case 26:return Yd(a,b),null===a?(c=cm(b.type,null,b.pendingProps,null))?b.memoizedState=c:S||(b.stateNode=dm(b.type,b.pendingProps,Bc.current,b)):b.memoizedState=cm(b.type,a.memoizedProps,b.pendingProps,a.memoizedState),null;case 27:return Cb(b),null===a&&S&&(d=b.stateNode=em(b.type,b.pendingProps,Bc.current),Qa=b,Ib=!0,e=wa,"head"===b.type?(yh=e,wa=Jb(d.firstChild)):wa=e),Ja(a,b,b.pendingProps.children,c),Yd(a,b),null===a&&(b.flags|=4194304), +b.child;case 5:if(null===a&&S){if(e=d=wa)d=ep(d,b.type,b.pendingProps,Ib),null!==d?(b.stateNode=d,Qa=b,wa=Jb(d.firstChild),Ib=!1,e=!0):e=!1;e||Ec(b)}Cb(b);e=b.type;f=b.pendingProps;g=null!==a?a.memoizedProps:null;d=f.children;xh(e,f)?d=null:null!==g&&xh(e,g)&&(b.flags|=32);null!==b.memoizedState&&(e=Rh(a,b,So,null,null,c),Bd._currentValue=e);Yd(a,b);Ja(a,b,d,c);return b.child;case 6:if(null===a&&S){if(a=c=wa)c=fp(c,b.pendingProps,Ib),null!==c?(b.stateNode=c,Qa=b,wa=null,a=!0):a=!1;a||Ec(b)}return null; +case 13:return Yl(a,b,c);case 4:return jc(b,b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=rd(b,null,d,c):Ja(a,b,d,c),b.child;case 11:return Pl(a,b,b.type,b.pendingProps,c);case 7:return d=b.pendingProps,Yd(a,b),Ja(a,b,d,c),b.child;case 8:return Ja(a,b,b.pendingProps.children,c),b.child;case 12:return Ja(a,b,b.pendingProps.children,c),b.child;case 10:return am(a,b,c);case 9:return e=b.type._context,d=b.pendingProps.children,jd(b),e=Ua(e),d=d(e),b.flags|=1,Ja(a,b,d,c),b.child;case 14:return Ql(a, +b,b.type,b.pendingProps,c);case 15:return Rl(a,b,b.type,b.pendingProps,c);case 19:return ri(a,b,c);case 31:return bp(a,b,c);case 22:return Sl(a,b,c,b.pendingProps);case 24:return jd(b),d=Ua(Fa),null===a?(e=Fh(),null===e&&(e=va,f=Ch(),e.pooledCache=f,f.refCount++,null!==f&&(e.pooledCacheLanes|=c),e=f),b.memoizedState={parent:d,cache:e},Gh(b),Hc(b,Fa,e)):(0!==(a.lanes&c)&&(Hh(a,b),Me(b,null,null,c),Le()),e=a.memoizedState,f=b.memoizedState,e.parent!==d?(e={parent:d,cache:d},b.memoizedState=e,0===b.lanes&& +(b.memoizedState=b.updateQueue.baseState=e),Hc(b,Fa,d)):(d=f.cache,Hc(b,Fa,d),d!==e.cache&&Bh(b,[Fa],c,!0))),Ja(a,b,b.pendingProps.children,c),b.child;case 30:return null===b.stateNode&&(b.stateNode={autoName:null,paired:null,clones:null,ref:null}),d=b.pendingProps,null!=d.name&&"auto"!==d.name?b.flags|=null===a?18882560:18874368:S&&If(b),null!==a&&a.memoizedProps.name!==d.name?b.flags|=4194816:Yd(a,b),Ja(a,b,d.children,c),b.child;case 29:throw b.pendingProps;}throw Error(v(156,b.tag));}function si(a, +b,c,d,e){var f;if(f=0!==(a.mode&32))f=null===c?fm(b,d):fm(b,d)&&(d.src!==c.src||d.srcSet!==c.srcSet);if(f){if(a.flags|=16777216,(e&335544128)===e)if(a.stateNode.complete)a.flags|=8192;else if(gm())a.flags|=8192;else throw nd=fg,ti;}else a.flags&=-16777217}function hm(a,b){if("stylesheet"!==b.type||0!==(b.state.loading&4))a.flags&=-16777217;else if(a.flags|=16777216,!im(b))if(gm())a.flags|=8192;else throw nd=fg,ti;}function kg(a,b){null!==b&&(a.flags|=4);a.flags&16384&&(b=22!==a.tag?m():536870912, +a.lanes|=b,$d|=b)}function Te(a,b){if(!S)switch(a.tailMode){case "visible":break;case "collapsed":for(var c=a.tail,d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null;break;default:b=a.tail;for(c=null;null!==b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null}}function xa(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags& +1206910976,d|=e.flags&1206910976,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function gp(a,b,c){var d=b.pendingProps;vh(b);switch(b.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return xa(b),null;case 1:return xa(b),null;case 3:c=b.stateNode;d=null;null!==a&&(d=a.memoizedState.cache);b.memoizedState.cache!==d&&(b.flags|=2048);qc(Fa);bb();c.pendingContext&& +(c.context=c.pendingContext,c.pendingContext=null);if(null===a||null===a.child)Rd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&256)||(b.flags|=1024,zh());xa(b);return null;case 26:var e=b.type,f=b.memoizedState;null===a?(b.flags|=4,null!==f?(xa(b),hm(b,f)):(xa(b),si(b,e,null,d,c))):f?f!==a.memoizedState?(b.flags|=4,xa(b),hm(b,f)):(xa(b),b.flags&=-16777217):(a=a.memoizedProps,a!==d&&(b.flags|=4),xa(b),si(b,e,a,d,c));return null;case 27:kc(b);c=Bc.current;e=b.type;if(null!==a&& +null!=b.stateNode)a.memoizedProps!==d&&(b.flags|=4);else{if(!d){if(null===b.stateNode)throw Error(v(166));xa(b);b.subtreeFlags&=-33554433;return null}a=Xb.current;Rd(b)?Ek(b,a):(a=em(e,d,c),b.stateNode=a,b.flags|=4)}xa(b);b.subtreeFlags&=-33554433;return null;case 5:kc(b);e=b.type;if(null!==a&&null!=b.stateNode)a.memoizedProps!==d&&(b.flags|=4);else{if(!d){if(null===b.stateNode)throw Error(v(166));xa(b);b.subtreeFlags&=-33554433;return null}f=Xb.current;if(Rd(b))Ek(b,f);else{var g=Ue(Bc.current); +switch(f){case 1:f=g.createElementNS("http://www.w3.org/2000/svg",e);break;case 2:f=g.createElementNS("http://www.w3.org/1998/Math/MathML",e);break;default:switch(e){case "svg":f=g.createElementNS("http://www.w3.org/2000/svg",e);break;case "math":f=g.createElementNS("http://www.w3.org/1998/Math/MathML",e);break;case "script":f=g.createElement("div");f.innerHTML="\x3cscript\x3e\x3c/script\x3e";f=f.removeChild(f.firstChild);break;case "select":f="string"===typeof d.is?g.createElement("select",{is:d.is}): +g.createElement("select");d.multiple?f.multiple=!0:d.size&&(f.size=d.size);break;default:f="string"===typeof d.is?g.createElement(e,{is:d.is}):g.createElement(e)}}f[Ta]=b;f[hb]=d;g=b.child;a:for(;null!==g;){if(5===g.tag||6===g.tag)f.appendChild(g.stateNode);else if(4!==g.tag&&27!==g.tag&&null!==g.child){g.child.return=g;g=g.child;continue}if(g===b)break a;for(;null===g.sibling;){if(null===g.return||g.return===b)break a;g=g.return}g.sibling.return=g.return;g=g.sibling}b.stateNode=f;a:switch(Xa(f,e, +d),e){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}d&&(b.flags|=4)}}xa(b);b.subtreeFlags&=-33554433;si(b,b.type,null===a?null:a.memoizedProps,b.pendingProps,c);return null;case 6:if(a&&null!=b.stateNode)a.memoizedProps!==d&&(b.flags|=4);else{if("string"!==typeof d&&null===b.stateNode)throw Error(v(166));a=Bc.current;if(Rd(b)){a=b.stateNode;c=b.memoizedProps;d=null;e=Qa;if(null!==e)switch(e.tag){case 27:case 5:d=e.memoizedProps}a[Ta]= +b;(a=a.nodeValue===c||null!==d&&!0===d.suppressHydrationWarning||Fk(a.nodeValue,c)?!0:!1)||Ec(b,!0)}else a=Ue(a).createTextNode(d),a[Ta]=b,b.stateNode=a}xa(b);return null;case 31:c=b.memoizedState;if(null===a||null!==a.memoizedState){d=Rd(b);if(null!==c){if(null===a){if(!d)throw Error(v(318));a=b.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(v(557));a[Ta]=b}else Fc(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;xa(b);a=!1}else c=zh(),null!==a&&null!==a.memoizedState&&(a.memoizedState.hydrationErrors= +c),a=!0;if(!a){if(b.flags&256)return ub(b),b;ub(b);return null}if(0!==(b.flags&128))throw Error(v(558));}xa(b);return null;case 13:d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){e=Rd(b);if(null!==d&&null!==d.dehydrated){if(null===a){if(!e)throw Error(v(318));e=b.memoizedState;e=null!==e?e.dehydrated:null;if(!e)throw Error(v(317));e[Ta]=b}else Fc(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;xa(b);e=!1}else e=zh(),null!==a&&null!==a.memoizedState&& +(a.memoizedState.hydrationErrors=e),e=!0;if(!e){if(b.flags&256)return ub(b),b;ub(b);return null}}ub(b);if(0!==(b.flags&128))return b.lanes=c,b;c=null!==d;a=null!==a&&null!==a.memoizedState;c&&(d=b.child,e=null,null!==d.alternate&&null!==d.alternate.memoizedState&&null!==d.alternate.memoizedState.cachePool&&(e=d.alternate.memoizedState.cachePool.pool),f=null,null!==d.memoizedState&&null!==d.memoizedState.cachePool&&(f=d.memoizedState.cachePool.pool),f!==e&&(d.flags|=2048));c!==a&&c&&(b.child.flags|= +8192);kg(b,b.updateQueue);xa(b);return null;case 4:return bb(),null===a&&ui(b.stateNode.containerInfo),b.flags|=67108864,xa(b),null;case 10:return qc(b.type),xa(b),null;case 19:Nh(b);d=b.memoizedState;if(null===d)return xa(b),null;e=0!==(b.flags&128);f=d.rendering;if(null===f)if(e)Te(d,!1);else{if(0!==Da||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){f=Tf(a);if(null!==f){b.flags|=128;Te(d,!1);a=f.updateQueue;b.updateQueue=a;kg(b,a);b.subtreeFlags=0;a=c;for(c=b.child;null!==c;)zk(c,a),c=c.sibling; +Ne(b,Va.current&1|2);S&&pc(b,d.treeForkCount);return b.child}a=a.sibling}null!==d.tail&&xb()>lg&&(b.flags|=128,e=!0,Te(d,!1),b.lanes=4194304)}else{if(!e)if(a=Tf(f),null!==a){if(b.flags|=128,e=!0,a=a.updateQueue,b.updateQueue=a,kg(b,a),Te(d,!0),null===d.tail&&"collapsed"!==d.tailMode&&"visible"!==d.tailMode&&!f.alternate&&!S)return xa(b),null}else 2*xb()-d.renderingStartTime>lg&&536870912!==c&&(b.flags|=128,e=!0,Te(d,!1),b.lanes=4194304);d.isBackwards?(f.sibling=b.child,b.child=f):(a=d.last,null!== +a?a.sibling=f:b.child=f,d.last=f)}if(null!==d.tail){a=d.tail;a:{for(c=a;null!==c;){if(null!==c.alternate){c=!1;break a}c=c.sibling}c=!0}d.rendering=a;d.tail=a.sibling;d.renderingStartTime=xb();a.sibling=null;f=Va.current;f=e?f&1|2:f&1;"visible"===d.tailMode||"collapsed"===d.tailMode||!c||S?Ne(b,f):(c=f,ia(Wa,b),ia(Va,c),null===$a&&($a=b));S&&pc(b,d.treeForkCount);return a}xa(b);return null;case 22:case 23:return ub(b),Lh(),d=null!==b.memoizedState,null!==a?null!==a.memoizedState!==d&&(b.flags|=8192): +d&&(b.flags|=8192),d?0!==(c&536870912)&&0===(b.flags&128)&&(xa(b),b.subtreeFlags&6&&(b.flags|=8192)):xa(b),c=b.updateQueue,null!==c&&kg(b,c.retryQueue),c=null,null!==a&&null!==a.memoizedState&&null!==a.memoizedState.cachePool&&(c=a.memoizedState.cachePool.pool),d=null,null!==b.memoizedState&&null!==b.memoizedState.cachePool&&(d=b.memoizedState.cachePool.pool),d!==c&&(b.flags|=2048),null!==a&&Ba(md),null;case 24:return c=null,null!==a&&(c=a.memoizedState.cache),b.memoizedState.cache!==c&&(b.flags|= +2048),qc(Fa),xa(b),null;case 25:return null;case 30:return b.flags|=33554432,xa(b),null}throw Error(v(156,b.tag));}function hp(a,b){vh(b);switch(b.tag){case 1:return a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return qc(Fa),bb(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 26:case 27:case 5:return kc(b),null;case 31:if(null!==b.memoizedState){ub(b);if(null===b.alternate)throw Error(v(340));Fc()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 13:ub(b); +a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(v(340));Fc()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return Nh(b),a=b.flags,a&65536?(b.flags=a&-65537|128,a=b.memoizedState,null!==a&&(a.rendering=null,a.tail=null),b.flags|=4,b):null;case 4:return bb(),null;case 10:return qc(b.type),null;case 22:case 23:return ub(b),Lh(),null!==a&&Ba(md),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 24:return qc(Fa),null;case 25:return null;default:return null}} +function jm(a,b){vh(b);switch(b.tag){case 3:qc(Fa);bb();break;case 26:case 27:case 5:kc(b);break;case 4:bb();break;case 31:null!==b.memoizedState&&ub(b);break;case 13:ub(b);break;case 19:Nh(b);break;case 10:qc(b.type);break;case 22:case 23:ub(b);Lh();null!==a&&Ba(md);break;case 24:qc(Fa)}}function Ve(a,b){try{var c=b.updateQueue,d=null!==c?c.lastEffect:null;if(null!==d){var e=d.next;c=e;do{if((c.tag&a)===a){d=void 0;var f=c.create,g=c.inst;d=f();g.destroy=d}c=c.next}while(c!==e)}}catch(h){oa(b,b.return, +h)}}function Sc(a,b,c){try{var d=b.updateQueue,e=null!==d?d.lastEffect:null;if(null!==e){var f=e.next;d=f;do{if((d.tag&a)===a){var g=d.inst,h=g.destroy;if(void 0!==h){g.destroy=void 0;e=b;var k=c,q=h;try{q()}catch(u){oa(e,k,u)}}}d=d.next}while(d!==f)}}catch(u){oa(b,b.return,u)}}function km(a){var b=a.updateQueue;if(null!==b){var c=a.stateNode;try{Rk(b,c)}catch(d){oa(a,a.return,d)}}}function lm(a,b,c){c.props=qd(a.type,a.memoizedProps);c.state=a.memoizedState;try{c.componentWillUnmount()}catch(d){oa(a, +b,d)}}function ac(a,b){try{var c=a.ref;if(null!==c){switch(a.tag){case 26:case 27:case 5:var d=a.stateNode;break;case 30:var e=a.stateNode,f=mc(a.memoizedProps,e);if(null===e.ref||e.ref.name!==f)e.ref=mm(f);d=e.ref;break;case 7:if(null===a.stateNode){var g=new yb(a);Z(a.child,!1,ip,g,void 0,void 0);a.stateNode=g}d=a.stateNode;break;default:d=a.stateNode}"function"===typeof c?a.refCleanup=c(d):c.current=d}}catch(h){oa(a,b,h)}}function Ya(a,b){var c=a.ref,d=a.refCleanup;if(null!==c)if("function"=== +typeof d)try{d()}catch(e){oa(a,b,e)}finally{a.refCleanup=null,a=a.alternate,null!=a&&(a.refCleanup=null)}else if("function"===typeof c)try{c(null)}catch(e){oa(a,b,e)}else c.current=null}function mg(a,b){if((5===a.tag||27===a.tag||6===a.tag)&&null===a.alternate&&null!==b)for(var c=0;cQ&&(g=Q,Q=E,E=g);var p=rk(h,E),l=rk(h,Q);if(p&&l&&(1!==t.rangeCount||t.anchorNode!==p.node||t.anchorOffset!==p.offset||t.focusNode!==l.node||t.focusOffset!==l.offset)){var r=x.createRange();r.setStart(p.node,p.offset);t.removeAllRanges();E>Q?(t.addRange(r),t.extend(l.node,l.offset)):(r.setEnd(l.node,l.offset),t.addRange(r))}}}}x=[];for(t=h;t=t.parentNode;)1===t.nodeType&&x.push({element:t,left:t.scrollLeft,top:t.scrollTop});"function"=== +typeof h.focus&&h.focus();for(h=0;hc?32:c;O.T=null;c=Wi;Wi=null;var f=Qb,g=ic;Aa=0;vd=Qb= +null;ic=0;if(0!==(ka&6))throw Error(v(331));var h=ka;ka|=4;Tm(f.current);Om(f,f.current,g,c);ka=h;bf(0,!1);if(qb&&"function"===typeof qb.onPostCommitFiberRoot)try{qb.onPostCommitFiberRoot(te,f)}catch(k){}return!0}finally{ja.p=e,O.T=d,en(a,b)}}function fn(a,b,c){b=Fb(c,b);b=hi(a.stateNode,b,2);a=Lc(a,b,2);null!==a&&(G(a,2),hc(a))}function oa(a,b,c){if(3===a.tag)fn(a,a,c);else for(;null!==b;){if(3===b.tag){fn(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError|| +"function"===typeof d.componentDidCatch&&(null===Rc||!Rc.has(d))){a=Fb(c,a);c=Ml(2);d=Lc(b,c,2);null!==d&&(Nl(c,d,b,a),G(d,2),hc(d));break}}b=b.return}}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new Ep;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(Si=!0,e.add(c),a=Fp.bind(null,a,b,c),b.then(a,a))}function Fp(a,b,c){var d=a.pingCache;null!==d&&d.delete(b);a.pingedLanes|=a.suspendedLanes&c;a.warmLanes&=~c;va===a&&(Y&c)===c&&(4===Da||3=== +Da&&(Y&62914560)===Y&&300>xb()-vg?0===(ka&2)?he(a,0):zg|=c:zg|=c,$d===Y&&($d=0));hc(a)}function gn(a,b){0===b&&(b=m());a=gd(a,b);null!==a&&(G(a,b),hc(a))}function np(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);gn(a,c)}function qp(a,b){var c=0;switch(a.tag){case 31:case 13:var d=a.stateNode,e=a.memoizedState;null!==e&&(c=e.retryLane);break;case 19:d=a.stateNode;break;case 22:d=a.stateNode._retryCache;break;default:throw Error(v(314));}null!==d&&d.delete(b);gn(a,c)}function zp(a,b){return bj(a, +b)}function hc(a){a!==le&&null===a.next&&(null===le?me=le=a:le=le.next=a);Dg=!0;cj||(cj=!0,Gp())}function bf(a,b){if(!dj&&Dg){dj=!0;do for(var c=!1,d=me;null!==d;){if(!b)if(0!==a){var e=d.pendingLanes;if(0===e)var f=0;else{var g=d.suspendedLanes,h=d.pingedLanes;f=(1<<31-rb(42|a)+1)-1;f&=e&~(g&~h);f=f&201326741?f&201326741|1:f?f|2:0}0!==f&&(c=!0,hn(d,f))}else f=Y,f=L(d,d===va?f:0,null!==d.cancelPendingCommit||-1!==d.timeoutHandle),0===(f&3)||pb(d,f)||(c=!0,hn(d,f));d=d.next}while(c);dj=!1}}function Hp(){jn()} +function jn(){Dg=cj=!1;var a=0;0!==Wc&&Ip()&&(a=Wc);for(var b=xb(),c=null,d=me;null!==d;){var e=d.next,f=kn(d,b);if(0===f)d.next=null,null===c?me=e:c.next=e,null===e&&(le=c);else if(c=d,0!==a||0!==(f&3))Dg=!0;d=e}0!==Aa&&5!==Aa||bf(a,!1);0!==Wc&&(Wc=0)}function kn(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes&-62914561;0h)break;var u=k.transferSize, +x=k.initiatorType;u&&Cn(x)&&(k=k.responseEnd,g+=u*(kGg){E.length=B;break}r=new Promise(fq.bind(r));E.push(r)}}}if(0Gg?50:800)+b);a.unsuspend=c;return function(){a.unsuspend= +null;clearTimeout(d);clearTimeout(e)}}:null}function On(a){if(0===a.count&&(0===a.imgCount||!a.waitingForImages))if(a.stylesheets)Ig(a,a.stylesheets);else if(a.unsuspend){var b=a.unsuspend;a.unsuspend=null;b()}}function af(){this.count--;On(this)}function rq(){this.imgCount--;On(this)}function Ig(a,b){a.stylesheets=null;null!==a.unsuspend&&(a.count++,Jg=new Map,b.forEach(sq,a),Jg=null,af.call(a))}function sq(a,b){if(!(b.state.loading&4)){var c=Jg.get(a);if(c)var d=c.get(null);else{c=new Map;Jg.set(a, +c);for(var e=a.querySelectorAll("link[data-precedence],style[data-precedence]"),f=0;f=mf),ik=String.fromCharCode(32),hk=!1,Kd=!1,yo={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},ye=null,ze=null,zn=!1;if(xc){if(xc){var Aj="oninput"in document;if(!Aj){var ao=document.createElement("div");ao.setAttribute("oninput", +"return;");Aj="function"===typeof ao.oninput}var bo=Aj}else bo=!1;zn=bo&&(!document.documentMode||9=document.documentMode,Ld=null,oh=null,Be=null,nh=!1,Md={animationend:ed("Animation","AnimationEnd"),animationiteration:ed("Animation","AnimationIteration"),animationstart:ed("Animation","AnimationStart"),transitionrun:ed("Transition","TransitionRun"),transitionstart:ed("Transition","TransitionStart"), +transitioncancel:ed("Transition","TransitionCancel"),transitionend:ed("Transition","TransitionEnd")},ph={},vk={};xc&&(vk=document.createElement("div").style,"AnimationEvent"in window||(delete Md.animationend.animation,delete Md.animationiteration.animation,delete Md.animationstart.animation),"TransitionEvent"in window||delete Md.transitionend.transition);var tn=fd("animationend"),un=fd("animationiteration"),vn=fd("animationstart"),Oq=fd("transitionrun"),Pq=fd("transitionstart"),Qq=fd("transitioncancel"), +wn=fd("transitionend"),wk=new Map,Bj="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error fullscreenChange fullscreenError gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" "); +Bj.push("scrollEnd");var Fo=0,cg="function"===typeof reportError?reportError:function(a){if("object"===typeof window&&"function"===typeof window.ErrorEvent){var b=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"===typeof a&&null!==a&&"string"===typeof a.message?String(a.message):String(a),error:a});if(!window.dispatchEvent(b))return}else if("object"===typeof xj&&"function"===typeof xj.emit){xj.emit("uncaughtException",a);return}console.error(a)},Eb=[],Od=0,qh=0,Zd={},Bk=new WeakMap, +Pd=[],Qd=0,Hf=null,De=0,Gb=[],Hb=0,Dc=null,Zb=1,$b="",Qa=null,wa=null,S=!1,Gc=null,Ib=!1,wh=Error(v(519)),Ah=Za(null),kd=null,rc=null,Jo="undefined"!==typeof AbortController?AbortController:function(){var a=[],b=this.signal={aborted:!1,addEventListener:function(c,d){a.push(d)}};this.abort=function(){b.aborted=!0;a.forEach(function(c){return c()})}},Ko=La.unstable_scheduleCallback,Lo=La.unstable_NormalPriority,Fa={$$typeof:Wb,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0}, +Ie=null,He=null,Dh=0,ld=0,Sd=null,co=O.S;O.S=function(a,b){Wm=xb();"object"===typeof b&&null!==b&&"function"===typeof b.then&&No(a,b);if(null!==Ie)for(var c=me;null!==c;)Ik(c,Ie),c=c.next;c=a.types;if(null!==c){for(var d=me;null!==d;)Ik(d,c),d=d.next;if(0!==ld){d=Ie;null===d&&(d=Ie=[]);for(var e=0;e>>0),ea=0;function fa(a,b,c){return a.call.apply(a.bind,arguments)} +function ha(a,b,c){if(!a)throw Error();if(arguments.length>2){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}function ja(a,b,c){ja=Function.prototype.bind&&Function.prototype.bind.toString().indexOf("native code")!=-1?fa:ha;return ja.apply(null,arguments)} +function ka(a,b){function c(){}c.prototype=b.prototype;a.Uc=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.Ea=function(d,e,f){for(var h=Array(arguments.length-2),k=2;k>>16&65535)*d+c*(b>>>16&65535)<<16>>>0)|0};function mc(a){a=lc(a|0,-862048943);return lc(a<<15|a>>>-15,461845907)}function nc(a,b){a=(a|0)^(b|0);return lc(a<<13|a>>>-13,5)+-430675100|0}function oc(a,b){a=(a|0)^b;a=lc(a^a>>>16,-2048144789);a=lc(a^a>>>13,-1028477387);return a^a>>>16} +function pc(a){a:{var b=1;for(var c=0;;)if(b1024&&(qc={},rc=0);if(a==null)return 0;var b=qc[a];if(typeof b==="number")a=b;else{a:if(a!=null)if(b=a.length,b>0){for(var c=0,d=0;;)if(c>2)}function vc(a,b){return b instanceof a}function wc(a,b,c,d,e){this.dc=a;this.name=b;this.eb=c;this.Db=d;this.Ta=e;this.v=2154168321;this.R=4096}g=wc.prototype;g.toString=function(){return this.eb};g.Z=function(a,b){return b instanceof wc?this.eb===b.eb:!1}; +g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}};g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return B?B(a,this):xc.call(null,a,this)};g.j=function(a,b){return yc?yc(a,this,b):xc.call(null,a,this,b)};g.fa=function(){return this.Ta}; +g.ha=function(a,b){return new wc(this.dc,this.name,this.eb,this.Db,b)};g.ba=function(){var a=this.Db;return a!=null?a:this.Db=a=uc(pc(this.name),sc(this.dc))};g.W=function(a,b){return Mb(b,this.eb)};var zc=function zc(a){switch(arguments.length){case 1:return zc.i(arguments[0]);case 2:return zc.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}; +zc.i=function(a){for(;;){if(a instanceof wc)return a;if(typeof a==="string"){var b=a.indexOf("/");return b<1?zc.j(null,a):zc.j(a.substring(0,b),a.substring(b+1,a.length))}if(a instanceof D)a=a.za;else throw Error("no conversion to symbol");}};zc.j=function(a,b){var c=a!=null?[Ba(a),"/",Ba(b)].join(""):b;return new wc(a,b,c,null,null)};zc.N=2;function Cc(a){return a!=null?a.R&131072||t===a.je?!0:a.R?!1:w(cc,a):w(cc,a)} +function E(a){if(a==null)return null;if(a!=null&&(a.v&8388608||t===a.Id))return a.ga(null);if(u(xa(a))||typeof a==="string")return a.length===0?null:new G(a,0,null);if(a!=null&&a[Ca]!=null)return a=na(a,Ca).call(a),Dc.i?Dc.i(a):Dc.call(null,a);if(w(Db,a))return Eb(a);throw Error([Ba(a)," is not ISeqable"].join(""));}function H(a){if(a==null)return null;if(a!=null&&(a.v&64||t===a.Hb))return a.qa(null);a=E(a);return a==null?null:Ua(a)} +function Ec(a){return a!=null?a!=null&&(a.v&64||t===a.Hb)?a.ra(null):(a=E(a))?a.ra(null):Fc:Fc}function I(a){return a==null?null:a!=null&&(a.v&128||t===a.Sb)?a.pa():E(Ec(a))}var J=function J(a){switch(arguments.length){case 1:return J.i(arguments[0]);case 2:return J.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=d)return-1;!(c>0)&&c<0&&(c+=d,c=0>c?0:c);for(;;)if(c0?(--d,c=d=0){if(J.j(cd?cd(a,c):dd.call(null,a,c),b))return c;--c}else return-1}function ed(a,b){this.o=a;this.G=b}ed.prototype.na=function(){return this.Ga?0:a};g.Tb=function(){var a=this.ea(null);return a>0?new fd(this,a-1,null):null};g.ba=function(){return Jc(this)};g.Z=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return $c(this.o,b,this.o[this.G],this.G+1)}; +g.ua=function(a,b,c){return $c(this.o,b,c,this.G)};g.qa=function(){return this.o[this.G]};g.ra=function(){return this.G+10?this.G+b0?new fd(this.kc,this.G-1,null):null};g.ea=function(){return this.G+1};g.ba=function(){return Jc(this)}; +g.Z=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd?hd(b,this):id.call(null,b,this)};g.ua=function(a,b,c){return jd?jd(b,c,this):id.call(null,b,c,this)};g.qa=function(){return Ra(this.kc,this.G)};g.ra=function(){return this.G>0?new fd(this.kc,this.G-1,null):Fc};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new fd(this.kc,this.G,b)};g.ka=function(a,b){return P.j?P.j(b,this):P.call(null,b,this)}; +fd.prototype[Ca]=function(){return Hc(this)};function kd(a){return H(I(a))}function ld(a){for(;;){var b=I(a);if(b!=null)a=b;else return H(a)}}Ab._=function(a,b){return a===b};var md=function md(a){switch(arguments.length){case 0:return md.J();case 1:return md.i(arguments[0]);case 2:return md.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.J=function(){return this.P.J?this.P.J():this.P.call(null)};g.i=function(a){return this.P.i?this.P.i(a):this.P.call(null,a)};g.j=function(a,b){return this.P.j?this.P.j(a,b):this.P.call(null,a,b)}; +g.l=function(a,b,c){return this.P.l?this.P.l(a,b,c):this.P.call(null,a,b,c)};g.O=function(a,b,c,d){return this.P.O?this.P.O(a,b,c,d):this.P.call(null,a,b,c,d)};g.$=function(a,b,c,d,e){return this.P.$?this.P.$(a,b,c,d,e):this.P.call(null,a,b,c,d,e)};g.Fa=function(a,b,c,d,e,f){return this.P.Fa?this.P.Fa(a,b,c,d,e,f):this.P.call(null,a,b,c,d,e,f)};g.sb=function(a,b,c,d,e,f,h){return this.P.sb?this.P.sb(a,b,c,d,e,f,h):this.P.call(null,a,b,c,d,e,f,h)}; +g.tb=function(a,b,c,d,e,f,h,k){return this.P.tb?this.P.tb(a,b,c,d,e,f,h,k):this.P.call(null,a,b,c,d,e,f,h,k)};g.ub=function(a,b,c,d,e,f,h,k,l){return this.P.ub?this.P.ub(a,b,c,d,e,f,h,k,l):this.P.call(null,a,b,c,d,e,f,h,k,l)};g.hb=function(a,b,c,d,e,f,h,k,l,m){return this.P.hb?this.P.hb(a,b,c,d,e,f,h,k,l,m):this.P.call(null,a,b,c,d,e,f,h,k,l,m)};g.ib=function(a,b,c,d,e,f,h,k,l,m,n){return this.P.ib?this.P.ib(a,b,c,d,e,f,h,k,l,m,n):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n)}; +g.jb=function(a,b,c,d,e,f,h,k,l,m,n,p){return this.P.jb?this.P.jb(a,b,c,d,e,f,h,k,l,m,n,p):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p)};g.kb=function(a,b,c,d,e,f,h,k,l,m,n,p,q){return this.P.kb?this.P.kb(a,b,c,d,e,f,h,k,l,m,n,p,q):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q)};g.lb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v){return this.P.lb?this.P.lb(a,b,c,d,e,f,h,k,l,m,n,p,q,v):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v)}; +g.mb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A){return this.P.mb?this.P.mb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A)};g.nb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C){return this.P.nb?this.P.nb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C)};g.ob=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F){return this.P.ob?this.P.ob(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F)}; +g.pb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M){return this.P.pb?this.P.pb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M)};g.qb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R){return this.P.qb?this.P.qb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R)}; +g.rb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V){return this.P.rb?this.P.rb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V)};g.Mc=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V,ia){return vd.Mc?vd.Mc(this.P,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,new G([V,ia],0,null)):vd.call(null,this.P,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V,ia)};function wd(a,b){return typeof a==="function"?new ud(a,b):a==null?null:tb(a,b)} +function xd(a){var b=a!=null;return(b?a!=null?a.v&131072||t===a.Gd||(a.v?0:w(qb,a)):w(qb,a):b)?rb(a):null}function yd(a){return a==null?null:lb(a)}function zd(a){return a==null?null:mb(a)}var Ad=function Ad(a){switch(arguments.length){case 1:return Ad.i(arguments[0]);case 2:return Ad.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(eb?a:b};ke.u=function(a,b,c){return Ga(ke,ke.j(a,b),c)};ke.K=function(a){var b=H(a),c=I(a);a=H(c);c=I(c);return this.u(b,a,c)};ke.N=2;var le=function le(a){switch(arguments.length){case 1:return le.i(arguments[0]);case 2:return le.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=0?Math.floor(a):Math.ceil(a)}function ne(a){a-=a>>1&1431655765;a=(a&858993459)+(a>>2&858993459);return(a+(a>>4)&252645135)*16843009>>24} +var Ba=function Ba(a,b){if(a==null)return"";if(void 0===b)return[a].join("");for(var d=new ua,e=arguments,f=e.length,h=0;;)if(h20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return B(a,this)};g.j=function(a,b){return yc(a,this,b)}; +g.ba=function(){var a=this.Db;return a!=null?a:this.Db=a=uc(pc(this.name),sc(this.dc))+2654435769|0};g.W=function(a,b){return Mb(b,[":",Ba(this.za)].join(""))};function re(a,b){return a===b?!0:a instanceof D&&b instanceof D?a.za===b.za:!1}function se(a){if(a!=null&&(a.R&4096||t===a.ed))return a.dc;throw Error(["Doesn't support namespace: ",Ba(a)].join(""));} +var te=function te(a){switch(arguments.length){case 1:return te.i(arguments[0]);case 2:return te.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};te.i=function(a){if(a instanceof D)return a;if(a instanceof wc)return new D(se(a),ue.i?ue.i(a):ue.call(null,a),a.eb,null);if(J.j("/",a))return new D(null,a,a,null);if(typeof a==="string"){var b=a.split("/");return b.length===2?new D(b[0],b[1],a,null):new D(null,b[0],a,null)}return null}; +te.j=function(a,b){a=a instanceof D?ue.i?ue.i(a):ue.call(null,a):a instanceof wc?ue.i?ue.i(a):ue.call(null,a):a;b=b instanceof D?ue.i?ue.i(b):ue.call(null,b):b instanceof wc?ue.i?ue.i(b):ue.call(null,b):b;return new D(a,b,[u(a)?[Ba(a),"/"].join(""):null,Ba(b)].join(""),null)};te.N=2;function ve(a,b,c){this.F=a;this.Yb=b;this.s=null;this.I=c;this.v=32374988;this.R=1}g=ve.prototype;g.toString=function(){return hc(this)}; +function we(a){a.Yb!=null&&(a.s=a.Yb.J?a.Yb.J():a.Yb.call(null),a.Yb=null);return a.s}g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){this.ga(null);return this.s==null?null:I(this.s)};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)}; +g.ma=function(){return tb(Fc,this.F)};g.ta=function(a,b){return hd(b,this)};g.ua=function(a,b,c){return jd(b,c,this)};g.qa=function(){this.ga(null);return this.s==null?null:H(this.s)};g.ra=function(){this.ga(null);return this.s!=null?Ec(this.s):Fc};g.ga=function(){we(this);if(this.s==null)return null;for(var a=this.s;;)if(a instanceof ve)a=we(a);else return this.s=a,E(this.s)};g.ha=function(a,b){var c=this;return b===this.F?c:new ve(b,function(){return c.ga(null)},this.I)}; +g.ka=function(a,b){return P(b,this)};ve.prototype[Ca]=function(){return Hc(this)};function xe(a){this.Fc=a;this.end=0;this.v=2;this.R=0}xe.prototype.add=function(a){this.Fc[this.end]=a;return this.end+=1};xe.prototype.da=function(){var a=new ye(this.Fc,0,this.end);this.Fc=null;return a};xe.prototype.ea=function(){return this.end};function ze(a){return new xe(Array(a))}function ye(a,b,c){this.o=a;this.off=b;this.end=c;this.v=524306;this.R=0}g=ye.prototype;g.ea=function(){return this.end-this.off}; +g.Y=function(a,b){return this.o[this.off+b]};g.Da=function(a,b,c){return b>=0&&b1?new Ae(Wb(this.da),this.Pa,null,null):this.Pa==null?null:Eb(this.Pa)};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)}; +g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.qa=function(){return Ra(this.da,0)};g.ra=function(){return La(this.da)>1?new Ae(Wb(this.da),this.Pa,null,null):this.Pa==null?Fc:this.Pa};g.ga=function(){return this};g.Qb=function(){return this.da};g.Za=function(){return this.Pa==null?Fc:this.Pa};g.ha=function(a,b){return b===this.F?this:new Ae(this.da,this.Pa,b,this.I)};g.ka=function(a,b){return P(b,this)};g.lc=function(){return this.Pa==null?null:this.Pa};Ae.prototype[Ca]=function(){return Hc(this)}; +function Be(a,b){return La(a)===0?b:new Ae(a,b,null,null)}function Ce(a,b){a.add(b)}function Ke(a,b){if(ad(b))return N(b);var c=0;for(b=E(b);;)if(b!=null&&c2){p=0;for(var q=Array(arguments.length-2);p2){m=0;for(var n=Array(arguments.length-2);m0)for(var d=0,e=Array(arguments.length-0);d3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q3){C=0;for(var F=Array(arguments.length-3);C3){v=0;for(var A=Array(arguments.length-3);v0){l=0;for(var m=Array(arguments.length-0);l3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q3){C=0;for(var F=Array(arguments.length-3);C3){v=0;for(var A=Array(arguments.length-3);v3){F=0;for(var M=Array(arguments.length-3);F3){A=0;for(var C=Array(arguments.length-3);A0){l=0;for(var m=Array(arguments.length-0);l3){v=0;for(var A=Array(arguments.length-3);v3){p=0;for(var q=Array(arguments.length-3);p3){q=0;for(var v=Array(arguments.length-3);q3){n=0;for(var p=Array(arguments.length-3);n2){q=0;for(var v=Array(arguments.length-2);q2){n=0;for(var p=Array(arguments.length-2);n0){var c=E(b);return c?P(H(c),zf(a-1,Ec(c))):null}return null},null)} +function Af(a,b){if(b!=null&&(b.R&262144||t===b.fe)){var c=a>0?b.Wa(null,Math.ceil(a)):E(b);return u(c)?c:Fc}return new ve(null,function(){a:{var d=a;for(var e=b;;)if(e=E(e),d>0&&e)--d,e=Ec(e);else{d=e;break a}d=void 0}return d},null)}function Bf(a){return yf.l(function(b){return b},a,Af(2,a))}function Cf(a,b){var c=E(b);for(a=E(Af(a,b));;)if(a)c=I(c),a=I(a);else return c}function Df(a,b,c,d){this.F=a;this.count=b;this.U=c;this.next=d;this.I=null;this.v=32374988;this.R=262145}g=Df.prototype; +g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,this.count)}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){return this.next==null?this.count>1?this.next=new Df(null,this.count-1,this.U,null):-1===this.count?this:null:this.next}; +g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){if(this.count===-1)for(var c=b.j?b.j(this.U,this.U):b.call(null,this.U,this.U);;){if(Pc(c))return y(c);c=b.j?b.j(c,this.U):b.call(null,c,this.U)}else for(a=1,c=this.U;;)if(a1?this.next=new Df(null,this.count-1,this.U,null):-1===this.count?this:Fc:this.next};g.ga=function(){return this}; +g.ha=function(a,b){return b===this.F?this:new Df(b,this.count,this.U,this.next)};g.ka=function(a,b){return P(b,this)};g.Wa=function(a,b){if(this.count===-1)return this;a=this.count-b;return a>0?new Df(null,a,this.U,null):null};function Ef(a){return a>0?new Df(null,a,null,null):Fc}function Ff(a,b){return Ue(Me,Ve(yf,a,b))} +function Gf(a){return function(b){return function(){function c(h,k){return u(a.i?a.i(k):a.call(null,k))?b.j?b.j(h,k):b.call(null,h,k):h}function d(h){return b.i?b.i(h):b.call(null,h)}function e(){return b.J?b.J():b.call(null)}var f=null;f=function(h,k){switch(arguments.length){case 0:return e.call(this);case 1:return d.call(this,h);case 2:return c.call(this,h,k)}throw Error("Invalid arity: "+arguments.length);};f.J=e;f.i=d;f.j=c;return f}()}} +function Hf(a,b){return new ve(null,function(){var c=E(b);if(c){if(Rd(c)){for(var d=Yb(c),e=N(d),f=ze(e),h=0;;)if(h>>5<<5}function Xf(a,b,c){for(;;){if(b===0)return c;var d=Uf(a);d.o[0]=c;c=d;b-=5}}var Yf=function Yf(a,b,c,d){var f=Vf(c),h=a.B-1>>>b&31;5===b?f.o[h]=d:(c=c.o[h],c!=null?(b-=5,a=Yf.O?Yf.O(a,b,c,d):Yf.call(null,a,b,c,d)):a=Xf(null,b-5,d),f.o[h]=a);return f};function Zf(a,b){throw Error(["No item ",Ba(a)," in vector of length ",Ba(b)].join(""));} +function $f(a,b){if(b>=Wf(a))return a.tail;var c=a.root;for(a=a.shift;;)if(a>0){var d=a-5;c=c.o[b>>>a&31];a=d}else return c.o}function ag(a,b){return 0<=b&&b>>b&31;b-=5;c=c.o[k];a=bg.$?bg.$(a,b,c,d,e):bg.call(null,a,b,c,d,e);h.o[k]=a}return h},cg=function cg(a,b,c){var e=a.B-2>>>b&31;if(b>5){b-=5;var f=c.o[e];a=cg.l?cg.l(a,b,f):cg.call(null,a,b,f);if(a==null&&e===0)return null;c=Vf(c);c.o[e]=a;return c}if(e===0)return null;c=Vf(c);c.o[e]=null;return c};function dg(a,b,c,d,e,f){this.G=a;this.Ea=b;this.o=c;this.sa=d;this.start=e;this.end=f} +dg.prototype.na=function(){return this.G0?this.Y(null,this.B-1):null}; +g.xb=function(){if(this.B===0)throw Error("Can't pop empty vector");if(1===this.B)return tb(nd,this.F);if(10?new fd(this,this.B-1,null):null}; +g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){if(b instanceof X)if(this.B===N(b))for(a=this.Ca(null),b=b.Ca(null);;){if(a.na()){var c=a.next(),d=b.next();if(J.j(c,d))continue;else return!1}else return!0;break}else return!1;else return gd(this,b)};g.Eb=function(){return new mg(this.B,this.shift,ng.i?ng.i(this.root):ng.call(null,this.root),og.i?og.i(this.tail):og.call(null,this.tail))};g.ma=function(){return tb(nd,this.F)};g.ta=function(a,b){return gg(this,b,0,this.B)}; +g.ua=function(a,b,c){a=0;for(var d=c;;)if(a0)b-=5,a=a.o[0];else{a=a.o;break a}a=void 0}return pg?pg(this,a,0,0):qg.call(null,this,a,0,0)};g.ha=function(a,b){return b===this.F?this:new X(b,this.B,this.shift,this.root,this.tail,this.I)}; +g.ka=function(a,b){if(this.B-Wf(this)<32){a=this.tail.length;for(var c=Array(a+1),d=0;;)if(d>>5>1<20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){if(typeof a==="number")return this.Y(null,a);throw Error("Key must be integer");}; +g.Wa=function(a,b){if(ba?e:a;return zg.$?zg.$(b,c,d,a,null):zg.call(null,b,c,d,a,null)};g.Ca=function(){return this.sa!=null&&t===this.sa.Kc?eg(this.sa,this.start,this.end):new df(this)};g.fa=function(){return this.F};g.ea=function(){return this.end-this.start}; +g.wb=function(){return this.start===this.end?null:Ra(this.sa,this.end-1)};g.xb=function(){if(this.start===this.end)throw Error("Can't pop empty vector");var a=this.F,b=this.sa,c=this.start,d=this.end-1;return zg.$?zg.$(a,b,c,d,null):zg.call(null,a,b,c,d,null)};g.Tb=function(){return this.start!==this.end?new fd(this,this.end-this.start-1,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(nd,this.F)}; +g.ta=function(a,b){return this.sa!=null&&t===this.sa.Kc?gg(this.sa,b,this.start,this.end):Wc(this,b)};g.ua=function(a,b,c){return this.sa!=null&&t===this.sa.Kc?hg(this.sa,b,c,this.start,this.end):Xc(this,b,c)};g.Ia=function(a,b,c){if(typeof b==="number")return this.yb(null,b,c);throw Error("Subvec's key for assoc must be a number.");};g.Va=function(a,b){return Xd(b)?0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.Y(null,a)};g.j=function(a,b){return this.Da(null,a,b)};yg.prototype[Ca]=function(){return Hc(this)}; +function zg(a,b,c,d,e){for(;;)if(b instanceof yg)c=b.start+c,d=b.start+d,b=b.sa;else{if(!Qd(b))throw Error("v must satisfy IVector");if(c<0||dN(b))throw Error("Index out of bounds");return new yg(a,b,c,d,e)}}var Ag=function Ag(a){switch(arguments.length){case 2:return Ag.j(arguments[0],arguments[1]);case 3:return Ag.l(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};Ag.j=function(a,b){return Ag.l(a,b,N(a))}; +Ag.l=function(a,b,c){return zg(null,a,b|0,c|0,null)};Ag.N=3;function Bg(a,b){return a===b.ia?b:new Tf(a,Da(b.o))}function ng(a){return new Tf({},Da(a.o))}function og(a){var b=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];Sd(a,0,b,0,a.length);return b} +var Cg=function Cg(a,b,c,d){c=Bg(a.root.ia,c);var f=a.B-1>>>b&31;if(b===5)a=d;else{var h=c.o[f];h!=null?(b-=5,a=Cg.O?Cg.O(a,b,h,d):Cg.call(null,a,b,h,d)):a=Xf(a.root.ia,b-5,d)}c.o[f]=a;return c};function mg(a,b,c,d){this.B=a;this.shift=b;this.root=c;this.tail=d;this.R=88;this.v=275}g=mg.prototype; +g.Jb=function(a,b){if(this.root.ia){if(this.B-Wf(this)<32)this.tail[this.B&31]=b;else{a=new Tf(this.root.ia,this.tail);var c=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];c[0]=b;this.tail=c;this.B>>>5>1<>>f&31;f=k(f-5,h.o[l]);h.o[l]=f}return h}(a.shift,a.root);a.root=d}return a}if(b===a.B)return a.Jb(null,c);throw Error(["Index ",Ba(b)," out of bounds for TransientVector of length",Ba(a.B)].join(""));}throw Error("assoc! after persistent!");}g.ea=function(){if(this.root.ia)return this.B;throw Error("count after persistent!");}; +g.Y=function(a,b){if(this.root.ia)return ag(this,b)[b&31];throw Error("nth after persistent!");};g.Da=function(a,b,c){return 0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};function Eg(a,b){this.Lb=a;this.ec=b} +Eg.prototype.na=function(){var a=this.Lb!=null&&E(this.Lb);return a?a:(a=this.ec!=null)?this.ec.na():a};Eg.prototype.next=function(){if(this.Lb!=null){var a=H(this.Lb);this.Lb=I(this.Lb);return a}if(this.ec!=null&&this.ec.na())return this.ec.next();throw Error("No such element");};Eg.prototype.remove=function(){return Error("Unsupported operation")};function Fg(a,b,c,d){this.F=a;this.Ha=b;this.Ka=c;this.I=d;this.v=31850700;this.R=0}g=Fg.prototype;g.toString=function(){return hc(this)}; +g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){var a=I(this.Ha);return a?new Fg(this.F,a,this.Ka,null):this.Ka!=null?new Fg(this.F,this.Ka,null,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)}; +g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(Fc,this.F)};g.qa=function(){return H(this.Ha)};g.ra=function(){var a=I(this.Ha);return a?new Fg(this.F,a,this.Ka,null):this.Ka==null?this.ma(null):new Fg(this.F,this.Ka,null,null)};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new Fg(b,this.Ha,this.Ka,this.I)};g.ka=function(a,b){return P(b,this)};Fg.prototype[Ca]=function(){return Hc(this)}; +function Gg(a,b,c,d,e){this.F=a;this.count=b;this.Ha=c;this.Ka=d;this.I=e;this.R=139264;this.v=31858766}g=Gg.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,this.count.i?this.count.i(this):this.count.call(null,this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.Ca=function(){return new Eg(this.Ha,dc(this.Ka))};g.fa=function(){return this.F};g.ea=function(){return this.count};g.wb=function(){return H(this.Ha)}; +g.xb=function(){if(u(this.Ha)){var a=I(this.Ha);return a?new Gg(this.F,this.count-1,a,this.Ka,null):new Gg(this.F,this.count-1,E(this.Ka),nd,null)}return this};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(Hg,this.F)};g.qa=function(){return H(this.Ha)};g.ra=function(){return Ec(E(this))};g.ga=function(){var a=E(this.Ka),b=this.Ha;return u(u(b)?b:a)?new Fg(null,this.Ha,E(a),null):null}; +g.ha=function(a,b){return b===this.F?this:new Gg(b,this.count,this.Ha,this.Ka,this.I)};g.ka=function(a,b){u(this.Ha)?(a=this.Ka,b=new Gg(this.F,this.count+1,this.Ha,md.j(u(a)?a:nd,b),null)):b=new Gg(this.F,this.count+1,md.j(this.Ha,b),nd,null);return b};var Hg=new Gg(null,0,null,nd,Kc);Gg.prototype[Ca]=function(){return Hc(this)};function Ig(){this.v=2097152;this.R=0}Ig.prototype.Z=function(){return!1};var Jg=new Ig; +function Kg(a,b){return Vd(Od(b)&&!Pd(b)?N(a)===N(b)?(a!=null?a.v&1048576||t===a.Bd||(a.v?0:w(yb,a)):w(yb,a))?ee(function(c,d,e){return J.j(yc(b,d,Jg),e)?!0:new Oc},!0,a):ef(function(c){return J.j(yc(b,H(c),Jg),kd(c))},a):null:null)}function Lg(a,b,c,d){this.G=0;this.$d=a;this.Xc=b;this.Rd=c;this.kd=d}Lg.prototype.na=function(){var a=this.G20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.Y(null,a)};g.j=function(a,b){return this.Da(null,a,b)}; +function tg(a){return a!=null?a.v&2048||t===a.le?!0:!1:!1}function Qg(a,b,c){this.o=a;this.G=b;this.Ta=c;this.v=32374990;this.R=262144}g=Qg.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.Ta};g.pa=function(){return this.G=0){a=this.o.length;var c=a-2;if(c===0)return this.ma(null);c=Array(c);for(var d=0,e=0;;){if(d>=a)return new Z(this.F,this.B-1,c,null);J.j(b,this.o[d])?d+=2:(c[e]=this.o[d],c[e+1]=this.o[d+1],e+=2,d+=2)}}else return this}; +g.Ia=function(a,b,c){a=Pg(this.o,b);if(a===-1){if(this.B20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)}; +g.Wa=function(a,b){a=this.ga(null);return a==null?null:ec(a,b)};var W=new Z(null,0,[],Mc),Vg=8;function Xg(a,b){return a===b?!0:re(a,b)?!0:J.j(a,b)}function Yg(a){for(var b=0,c=0;;)if(b=e){if(Xg(c[e],c[h]))return h;h-=2}else return h}(),a[f]=c[e],a[f+1]=c[b+1],b=2+f,e=2+e,f=b);else break;return new Z(null,a.length/2,a,null)}return new Z(null,c.length/2,c,null)}Z.prototype[Ca]=function(){return Hc(this)}; +function Ug(a,b){this.Kb={};this.Ab=a;this.o=b;this.v=259;this.R=56}g=Ug.prototype;g.ea=function(){if(this.Kb)return me(this.Ab);throw Error("count after persistent!");};g.xa=function(a,b){return this.ca(null,b,null)};g.ca=function(a,b,c){if(this.Kb)return a=Pg(this.o,b),a===-1?c:this.o[a+1];throw Error("lookup after persistent!");}; +g.Jb=function(a,b){if(this.Kb){if(tg(b))return this.Ib(null,ug.i?ug.i(b):ug.call(null,b),vg.i?vg.i(b):vg.call(null,b));if(Qd(b))return this.Ib(null,b.i?b.i(0):b.call(null,0),b.i?b.i(1):b.call(null,1));a=E(b);for(b=this;;){var c=H(a);if(u(c))a=I(a),b=Vb(b,ug.i?ug.i(c):ug.call(null,c),vg.i?vg.i(c):vg.call(null,c));else return b}}else throw Error("conj! after persistent!");};g.Ub=function(){if(this.Kb)return this.Kb=!1,new Z(null,me(this.Ab),this.o,null);throw Error("persistent! called twice");}; +g.Ib=function(a,b,c){if(this.Kb){a=Pg(this.o,b);if(a===-1){if(this.Ab+2<=2*Vg)return this.Ab+=2,this.o.push(b),this.o.push(c),this;a=ah.j?ah.j(this.Ab,this.o):ah.call(null,this.Ab,this.o);return Vb(a,b,c)}c!==this.o[a+1]&&(this.o[a+1]=c);return this}throw Error("assoc! after persistent!");};g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}}; +g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.ca(null,a,null)};g.j=function(a,b){return this.ca(null,a,b)};function ah(a,b){for(var c=Sb(Wg),d=0;;)if(d>>a&31);if((this.ja&e)===0)return d;var f=ne(this.ja&e-1);e=this.o[2*f];f=this.o[2*f+1];return e==null?f.bb(a+5,b,c,d):Xg(c,e)?f:d}; +g.Oa=function(a,b,c,d,e,f){var h=1<<(c>>>b&31),k=ne(this.ja&h-1);if((this.ja&h)===0){var l=ne(this.ja);if(2*l=16){k=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];k[c>>>b&31]=nh.Oa(a,b+5,c,d,e,f);for(e=d=0;;)if(d<32)(this.ja>>>d&1)=== +0?d+=1:(k[d]=this.o[e]!=null?nh.Oa(a,b+5,tc(this.o[e]),this.o[e],this.o[e+1],f):this.o[e+1],e+=2,d+=1);else break;return new oh(a,l+1,k)}b=Array(2*(l+4));Sd(this.o,0,b,0,2*k);b[2*k]=d;b[2*k+1]=e;Sd(this.o,2*k,b,2*(k+1),2*(l-k));f.U=!0;a=this.zb(a);a.o=b;a.ja|=h;return a}l=this.o[2*k];h=this.o[2*k+1];if(l==null)return l=h.Oa(a,b+5,c,d,e,f),l===h?this:hh(this,a,2*k+1,l);if(Xg(d,l))return e===h?this:hh(this,a,2*k+1,e);f.U=!0;f=b+5;d=ph?ph(a,f,l,h,c,d,e):qh.call(null,a,f,l,h,c,d,e);e=2*k;k=2*k+1;a=this.zb(a); +a.o[e]=null;a.o[k]=d;return a}; +g.Na=function(a,b,c,d,e){var f=1<<(b>>>a&31),h=ne(this.ja&f-1);if((this.ja&f)===0){var k=ne(this.ja);if(k>=16){h=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];h[b>>>a&31]=nh.Na(a+5,b,c,d,e);for(d=c=0;;)if(c<32)(this.ja>>>c&1)===0?c+=1:(h[c]=this.o[d]!=null?nh.Na(a+5,tc(this.o[d]),this.o[d],this.o[d+1],e):this.o[d+1],d+=2,c+=1);else break;return new oh(null,k+1,h)}a=Array(2*(k+1));Sd(this.o, +0,a,0,2*h);a[2*h]=c;a[2*h+1]=d;Sd(this.o,2*h,a,2*(h+1),2*(k-h));e.U=!0;return new kh(null,this.ja|f,a)}var l=this.o[2*h];f=this.o[2*h+1];if(l==null)return k=f.Na(a+5,b,c,d,e),k===f?this:new kh(null,this.ja,dh(this.o,2*h+1,k));if(Xg(c,l))return d===f?this:new kh(null,this.ja,dh(this.o,2*h+1,d));e.U=!0;e=this.ja;k=this.o;a+=5;a=rh?rh(a,l,f,b,c,d):qh.call(null,a,l,f,b,c,d);c=2*h;h=2*h+1;d=Da(k);d[c]=null;d[h]=a;return new kh(null,e,d)}; +g.Zb=function(a,b,c,d){var e=1<<(b>>>a&31);if((this.ja&e)===0)return d;var f=ne(this.ja&e-1);e=this.o[2*f];f=this.o[2*f+1];return e==null?f.Zb(a+5,b,c,d):Xg(c,e)?new lg(e,f):d}; +g.ac=function(a,b,c){var d=1<<(b>>>a&31);if((this.ja&d)===0)return this;var e=ne(this.ja&d-1),f=this.o[2*e],h=this.o[2*e+1];return f==null?(a=h.ac(a+5,b,c),a===h?this:a!=null?new kh(null,this.ja,dh(this.o,2*e+1,a)):this.ja===d?null:new kh(null,this.ja^d,eh(this.o,e))):Xg(c,f)?this.ja===d?null:new kh(null,this.ja^d,eh(this.o,e)):this};g.Ca=function(){return new jh(this.o)};var nh=new kh(null,0,[]);function sh(a){this.o=a;this.G=0;this.Qa=null} +sh.prototype.na=function(){for(var a=this.o.length;;){if(this.Qa!=null&&this.Qa.na())return!0;if(this.G>>a&31];return e!=null?e.bb(a+5,b,c,d):d};g.Oa=function(a,b,c,d,e,f){var h=c>>>b&31,k=this.o[h];if(k==null)return a=hh(this,a,h,nh.Oa(a,b+5,c,d,e,f)),a.B+=1,a;b=k.Oa(a,b+5,c,d,e,f);return b===k?this:hh(this,a,h,b)}; +g.Na=function(a,b,c,d,e){var f=b>>>a&31,h=this.o[f];if(h==null)return new oh(null,this.B+1,dh(this.o,f,nh.Na(a+5,b,c,d,e)));a=h.Na(a+5,b,c,d,e);return a===h?this:new oh(null,this.B,dh(this.o,f,a))};g.Zb=function(a,b,c,d){var e=this.o[b>>>a&31];return e!=null?e.Zb(a+5,b,c,d):d}; +g.ac=function(a,b,c){var d=b>>>a&31,e=this.o[d];if(e!=null){a=e.ac(a+5,b,c);if(a===e)d=this;else if(a==null)if(this.B<=8)a:{e=this.o;a=e.length;b=Array(2*(this.B-1));c=0;for(var f=1,h=0;;)if(c2*this.B)return b=2*this.B,c=2*this.B+1,a=this.zb(a),a.o[b]=d,a.o[c]=e,f.U=!0,a.B+=1,a;c=this.o.length;b=Array(c+2);Sd(this.o,0,b,0,c);b[c]=d;b[c+1]=e;f.U=!0;d=this.B+1;a===this.ia?(this.o=b,this.B=d,a=this):a=new wh(this.ia,this.Xa,d,b);return a}return this.o[b+1]===e?this:hh(this,a,b+1,e)}return(new kh(a,1<<(this.Xa>>>b&31),[null,this,null,null])).Oa(a,b,c,d,e,f)}; +g.Na=function(a,b,c,d,e){return b===this.Xa?(a=vh(this.o,this.B,c),a===-1?(a=2*this.B,b=Array(a+2),Sd(this.o,0,b,0,a),b[a]=c,b[a+1]=d,e.U=!0,new wh(null,this.Xa,this.B+1,b)):J.j(this.o[a+1],d)?this:new wh(null,this.Xa,this.B,dh(this.o,a+1,d))):(new kh(null,1<<(this.Xa>>>a&31),[null,this])).Na(a,b,c,d,e)};g.Zb=function(a,b,c,d){a=vh(this.o,this.B,c);return a<0?d:new lg(this.o[a],this.o[a+1])}; +g.ac=function(a,b,c){a=vh(this.o,this.B,c);return a===-1?this:this.B===1?null:new wh(null,this.Xa,this.B-1,eh(this.o,me(a)))};g.Ca=function(){return new jh(this.o)};function qh(a){switch(arguments.length){case 6:return rh(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5]);case 7:return ph(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5],arguments[6]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}} +function rh(a,b,c,d,e,f){var h=tc(b);if(h===d)return new wh(null,h,2,[b,c,e,f]);var k=new ch;return nh.Na(a,h,b,c,k).Na(a,d,e,f,k)}function ph(a,b,c,d,e,f,h){var k=tc(c);if(k===e)return new wh(null,k,2,[c,d,f,h]);var l=new ch;return nh.Oa(a,b,k,c,d,l).Oa(a,b,e,f,h,l)}function xh(a,b,c,d,e){this.F=a;this.Ra=b;this.G=c;this.s=d;this.I=e;this.v=32374988;this.R=0}g=xh.prototype;g.toString=function(){return hc(this)}; +g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){if(this.s==null){var a=this.Ra,b=this.G+2;return yh?yh(a,b,null):mh.call(null,a,b,null)}a=this.Ra;b=this.G;var c=I(this.s);return yh?yh(a,b,c):mh.call(null,a,b,c)}; +g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd(b,this)};g.ua=function(a,b,c){return jd(b,c,this)};g.qa=function(){return this.s==null?new lg(this.Ra[this.G],this.Ra[this.G+1]):H(this.s)}; +g.ra=function(){var a=this,b=a.s==null?function(){var c=a.Ra,d=a.G+2;return yh?yh(c,d,null):mh.call(null,c,d,null)}():function(){var c=a.Ra,d=a.G,e=I(a.s);return yh?yh(c,d,e):mh.call(null,c,d,e)}();return b!=null?b:Fc};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new xh(b,this.Ra,this.G,this.s,this.I)};g.ka=function(a,b){return P(b,this)};xh.prototype[Ca]=function(){return Hc(this)}; +function mh(a){switch(arguments.length){case 1:return lh(arguments[0]);case 3:return yh(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}function lh(a){return yh(a,0,null)}function yh(a,b,c){if(c==null)for(c=a.length;;)if(b0){var a=this.root!=null?this.root.$b():null;return this.Aa?P(new lg(null,this.Ba),a):a}return null}; +g.ha=function(a,b){return b===this.F?this:new Ch(b,this.B,this.root,this.Aa,this.Ba,this.I)};g.ka=function(a,b){if(Qd(b))return this.Ia(null,Ra(b,0),Ra(b,1));a=this;for(b=E(b);;){if(b==null)return a;var c=H(b);if(Qd(c))a=cb(a,Ra(c,0),Ra(c,1)),b=I(b);else throw Error("conj on a map takes map entries or seqables of map entries");}}; +g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}};g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Wg=new Ch(null,0,null,!1,null,Mc); +function Eh(a,b){for(var c=a.length,d=0,e=Sb(Wg);;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Gh=function Gh(a){for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Ph=new Nh(null,W,Mc); +function Qh(a){for(var b=a.length,c=Sb(Ph),d=0;;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return Za(this.Bb,a,Td)===Td?null:a};g.j=function(a,b){return Za(this.Bb,a,Td)===Td?b:a}; +function Rh(a){if(Md(a))return wd(a,null);a=E(a);if(a==null)return Ph;if(a instanceof G&&a.G===0)return Qh(a.o);for(var b=Sb(Ph);;)if(a!=null){var c=I(a);b=Tb(b,Ua(a));a=c}else return Ub(b)}function Sh(a){return function e(c,d){return new ve(null,function(){var f;a:{var h=c;for(f=d;;){var k=h;h=S(k,0,null);if(k=E(k))if(Yd(f,h)){h=Ec(k);continue}else{f=P(h,e(Ec(k),md.j(f,h)));break a}else{f=null;break a}break}f=void 0}return f},null)}(a,Ph)} +function Th(a){for(var b=nd;;)if(I(a))b=md.j(b,H(a)),a=I(a);else return E(b)}function ue(a){if(a!=null&&(a.R&4096||t===a.ed))return a.name;if(typeof a==="string")return a;throw Error(["Doesn't support name: ",Ba(a)].join(""));} +var Uh=function Uh(a){switch(arguments.length){case 2:return Uh.j(arguments[0],arguments[1]);case 3:return Uh.l(arguments[0],arguments[1],arguments[2]);default:for(var c=[],d=arguments.length,e=0;;)if(e(a.i?a.i(c):a.call(null,c))?b:c}; +Uh.u=function(a,b,c,d){return Ga(function(e,f){return Uh.l(a,e,f)},Uh.l(a,b,c),d)};Uh.K=function(a){var b=H(a),c=I(a);a=H(c);var d=I(c);c=H(d);d=I(d);return this.u(b,a,c,d)};Uh.N=3;function Vh(a,b){return new ve(null,function(){var c=E(b);if(c){var d=H(c);d=a.i?a.i(d):a.call(null,d);c=u(d)?P(H(c),Vh(a,Ec(c))):null}else c=null;return c},null)}function Wh(a,b,c){this.start=a;this.step=b;this.count=c;this.v=82;this.R=0}g=Wh.prototype;g.ea=function(){return this.count};g.qa=function(){return this.start}; +g.Y=function(a,b){return this.start+b*this.step};g.Da=function(a,b,c){return b>=0&&b0?this.Gthis.end};Xh.prototype.next=function(){var a=this.G;this.G+=this.step;return a}; +function Yh(a,b,c,d,e,f){this.F=a;this.start=b;this.end=c;this.step=d;this.B=e;this.I=f;this.v=32375006;this.R=402944}g=Yh.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.Y=function(a,b){if(0<=b&&bthis.end&&this.step===0)return this.start;throw Error("Index out of bounds");}; +g.Da=function(a,b,c){return 0<=b&&bthis.end&&this.step===0?this.start:c};g.Ca=function(){return new Xh(this.start,this.end,this.step)};g.fa=function(){return this.F}; +g.pa=function(){return this.step>0?this.start+this.stepthis.end?new Yh(null,this.start+this.step,this.end,this.step,Math.ceil((this.end-(this.start+this.step))/this.step),null):null};g.ea=function(){return this.B};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc}; +g.ta=function(a,b){return Wc(this,b)};g.ua=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Pc(c))return y(c);a+=this.step}else return c};g.qa=function(){return this.start};g.ra=function(){var a=this.pa();return a==null?Fc:a};g.ga=function(){return this};g.Qb=function(){var a=this.B;return new Wh(this.start,this.step,a<32?a:32)}; +g.Za=function(){if(this.B<=32)return Fc;var a=this.start+this.step*32;return this.step>0?this.end<=a?Fc:new Yh(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.step<0?this.end>=a?Fc:new Yh(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.end===a?Fc:new Df(null,-1,a,null)};g.ha=function(a,b){return b===this.F?this:new Yh(b,this.start,this.end,this.step,this.B,this.I)};g.ka=function(a,b){return P(b,this)}; +g.Wa=function(a,b){return b>0?b0?da.end)continue;else{c=a.da=new ye(b,0,c);break a}else{c=d;break a}break}c=void 0}a.da==null&&(a.da=new ye(b,0,32),(a.step>0?ca.end)&&(a.jc=new Zh(null,c,a.end,a.step,null,null,null)))}} +g.Ca=function(){return new Xh(this.start,this.end,this.step)};g.fa=function(){return this.F};g.pa=function(){return this.step>0?this.start+this.stepthis.end?new Zh(null,this.start+this.step,this.end,this.step,null,null,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd(b,this)}; +g.ua=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Pc(c))return y(c);a+=this.step}else return c};g.qa=function(){return this.start};g.ra=function(){var a=this.pa();return a==null?Fc:a};g.ga=function(){return this};g.Qb=function(){$h(this);return this.da};g.Za=function(){$h(this);return this.jc==null?Fc:this.jc};g.ha=function(a,b){return b===this.F?this:new Zh(b,this.start,this.end,this.step,this.da,this.jc,this.I)}; +g.ka=function(a,b){return P(b,this)};g.lc=function(){return E(this.Za(null))};Zh.prototype[Ca]=function(){return Hc(this)}; +function ai(a,b){return function(){function c(l,m,n){return new X(null,2,5,Y,[a.l?a.l(l,m,n):a.call(null,l,m,n),b.l?b.l(l,m,n):b.call(null,l,m,n)],null)}function d(l,m){return new X(null,2,5,Y,[a.j?a.j(l,m):a.call(null,l,m),b.j?b.j(l,m):b.call(null,l,m)],null)}function e(l){return new X(null,2,5,Y,[a.i?a.i(l):a.call(null,l),b.i?b.i(l):b.call(null,l)],null)}function f(){return new X(null,2,5,Y,[a.J?a.J():a.call(null),b.J?b.J():b.call(null)],null)}var h=null,k=function(){function l(n,p,q,v){var A=null; +if(arguments.length>3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q0){e=0;for(var f=Array(arguments.length-0);e0){q=0;for(var v=Array(arguments.length- +0);q0){A=0;for(var C=Array(arguments.length-0);A0){e=0;for(var f=Array(arguments.length-0);e0){e=0;for(var f=Array(arguments.length-0);e=e&&f.push(xo(a,k));return f},[c,d],b))}}function Jp(a,b,c,d){this.tag=a;this.id=b;this.className=c;this.Qd=d}function Kp(a,b,c){var d=na(a,vo(c));d==null?Zo(a)?a=op(c,a,a):(d=xd(a),d=T.l(d,im,a),d=np(d,c),a=op(c,a,d)):a=d;c={};c.argv=b;b=io(b);b!=null&&(c.key=b);return Tn.createElement(a,c)} +function Lp(a,b,c,d){var e={};e.cb=a;e.argv=Ag.j(b,c);b=io(b);b!=null&&(e.key=b);return Tn.createElement(sp(d,a),e)}function Mp(a,b,c){var d;if(d=td(a))d=a==null?null:a.prototype,d=(d==null?null:d.render)!=null;return d?Kp(a,b,c):Lp(a,b,1,c)}var Np={}; +function Op(a,b,c,d){var e=a.tag,f=S(b,c,null),h=f==null||Od(f),k=function(){var n=h?f:null,p=xj.i(n);p=u(p)?T.l(n,xj,go.i(p)):n;var q=a.id;n=a.className;q=q!=null&&gl.i(p)==null?T.l(p,gl,q):p;if(u(n)){var v=T.l,A=go.j,C=xj.i(p);p=u(C)?C:Pl.i(p);n=v.call(T,q,xj,A.call(go,n,p))}else n=q;n=u(a.Qd)?Hp(n):Ep(n);u(u(n)?n.dangerouslySetInnerHTML:n)&&delete n.dangerouslySetInnerHTML;return u(n)?n:{}}();c+=h?1:0;a:switch(e){case "input":case "textarea":var l=!0;break a;default:l=!1}if(l){l=ho(f);var m=function(){var n= +d.Zd;if(u(n))return n;n=np(zp,d);return d.Zd=n}();return xo(d,wd(new X(null,6,5,Y,[m,b,e,k,c,d],null),Jh.u(Q([u(l)?new Z(null,1,[An,l],null):null,xd(b)]))))}l=ho(xd(b));l!=null&&(k.key=l);return yo(d,b,e,k,c)}function Pp(a,b){return Ea(yf.j(function(c){return xo(b,c)},a))} +function Qp(a,b,c){b==null&&console.error("vec-to-elem",ri(Q([a])));var d=S(a,0,null);switch(d instanceof D?d.za:null){case "\x3e":return d=S(a,1,null),Op(new Jp(d,null,null,null),a,2,b);case "r\x3e":d=S(a,1,null);c=S(a,2,null);c=u(c)?c:{};var e=ho(xd(a));e!=null&&(c.key=e);return yo(b,a,d,c,3);case "f\x3e":return Lp(S(a,1,null),a,2,b);case "\x3c\x3e":return c=S(a,1,null),d=c==null||Od(c),c=Ep(d?c:null),c=u(c)?c:{},d=1+(d?1:0),e=io(a),e!=null&&(c.key=e),yo(b,a,Tn.Fragment,c,d);default:if(d.se)b={}, +b.argv=Ag.j(a,1),a=io(a),a!=null&&(b.key=a),a=Tn.createElement(d,b);else if(fo(d)||typeof d==="string")a:{for(;;){d=S(a,0,null);c=ue(d);e=c.indexOf("\x3e");switch(e){case -1:a=Op(wo(b,c,d),a,1,b);break a;case 0:a=null;break a;default:a=wd(new X(null,2,5,Y,[c.substring(0,e),T.l(wd(a,null),0,c.substring(e+1))],null),xd(a));continue}break}a=void 0}else a=c.l?c.l(d,a,b):c.call(null,d,a,b);return a}}function Rp(a,b,c,d,e){this.Wd=a;this.id=b;this.ld=c;this.zc=d;this.Ud=e;this.v=393216;this.R=0}g=Rp.prototype; +g.ha=function(a,b){return new Rp(this.Wd,this.id,this.ld,this.zc,b)};g.fa=function(){return this.Ud};g.Yd=function(){return this.id};g.sd=function(a,b,c){return this.zc.l?this.zc.l(this,b,c):this.zc.call(null,this,b,c)};g.qd=function(a,b){return"object"!==r(b)?b:Qd(b)?Qp(b,this,this.ld):Ud(b)?Pp(b,this):fo(b)?ue(b):(b!=null?b.v&2147483648||t===b.la||(b.v?0:w(Nb,b)):w(Nb,b))?ri(Q([b])):b};g.rd=function(a,b,c,d,e){return Ip(this,b,c,d,e)}; +var Sp,Wp=W,Ps=xi("reagent-compiler"),Qs=u(Gk.i(Wp))?Mp:Kp,Rs=yc(Wp,kj,function(a,b){a=Cp(Np,b);if(a==null){a=ue(b);if(typeof a==="string"){var c=Ap.exec(a);a=c!=null&&J.j(c[0],a)?c.length===1?c[0]:sg(c):null}else throw new TypeError("re-matches must match against a string.");var d=I(a);a=S(d,0,null);c=S(d,1,null);d=S(d,2,null);d=d==null?null:Xn(d,/\./," ");var e=Ze(-1,a.indexOf("-"));a=new Jp(a,c,d,e);return Np[b]=a}return a});Sp=new Rp(Wp,Ps,Qs,Rs,W);function qt(a){Tn.useEffect(function(){var c=rs;rs=!1;try{to.flush_after_render()}finally{rs=c}});var b=rs;rs=!0;try{return Tn.createElement(a.comp)}finally{rs=b}};function Xp(){return ra("iPhone")&&!ra("iPod")&&!ra("iPad")};ra("Opera");ra("Trident")||ra("MSIE");ra("Edge");var Yp=ra("Gecko")&&!(qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge"))&&!(ra("Trident")||ra("MSIE"))&&!ra("Edge"),Zp=qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge");Zp&&ra("Mobile");ra("Macintosh");ra("Windows");ra("Linux")||ra("CrOS");ra("Android");Xp();ra("iPad");ra("iPod");Xp()||ra("iPad")||ra("iPod");qa().toLowerCase().indexOf("kaios");function $p(){var a=document;var b="IFRAME";a.contentType==="application/xhtml+xml"&&(b=b.toLowerCase());return a.createElement(b)};function aq(a,b,c){var d=a;b&&(d=ja(a,b));d=aq.ce(d);typeof aa.setImmediate==="function"&&(c||aq.be())?aa.setImmediate(d):(aq.md||(aq.md=aq.Sd()),aq.md(d))}aq.be=function(){return aa.Window&&aa.Window.prototype&&!ra("Edge")&&aa.Window.prototype.setImmediate==aa.setImmediate?!1:!0}; +aq.Sd=function(){var a=aa.MessageChannel;typeof a==="undefined"&&typeof window!=="undefined"&&window.postMessage&&window.addEventListener&&!ra("Presto")&&(a=function(){var e=$p();e.style.display="none";document.documentElement.appendChild(e);var f=e.contentWindow;e=f.document;e.open();e.close();var h="callImmediate"+Math.random(),k=f.location.protocol=="file:"?"*":f.location.protocol+"//"+f.location.host;e=ja(function(l){if((k=="*"||l.origin==k)&&l.data==h)this.port1.onmessage()},this);f.addEventListener("message", +e,!1);this.port1={};this.port2={postMessage:function(){f.postMessage(h,k)}}});if(typeof a!=="undefined"&&!ra("Trident")&&!ra("MSIE")){var b=new a,c={},d=c;b.port1.onmessage=function(){if(c.next!==void 0){c=c.next;var e=c.Yc;c.Yc=null;e()}};return function(e){d.next={Yc:e};d=d.next;b.port2.postMessage(0)}}return function(e){aa.setTimeout(e,0)}};aq.ce=function(a){return a};function bq(a,b){this.type=a;this.currentTarget=this.target=b;this.defaultPrevented=!1}bq.prototype.stopPropagation=function(){};bq.prototype.preventDefault=function(){this.defaultPrevented=!0};var cq=function(){if(!aa.addEventListener||!Object.defineProperty)return!1;var a=!1,b=Object.defineProperty({},"passive",{get:function(){a=!0}});try{let c=()=>{};aa.addEventListener("test",c,b);aa.removeEventListener("test",c,b)}catch(c){}return a}();function dq(a,b){bq.call(this,a?a.type:"");this.relatedTarget=this.currentTarget=this.target=null;this.button=this.screenY=this.screenX=this.clientY=this.clientX=this.offsetY=this.offsetX=0;this.key="";this.charCode=this.keyCode=0;this.metaKey=this.shiftKey=this.altKey=this.ctrlKey=!1;this.state=null;this.pointerId=0;this.pointerType="";this.Xb=null;a&&this.init(a,b)}ka(dq,bq);var eq={2:"touch",3:"pen",4:"mouse"}; +dq.prototype.init=function(a,b){var c=this.type=a.type,d=a.changedTouches&&a.changedTouches.length?a.changedTouches[0]:null;this.target=a.target||a.srcElement;this.currentTarget=b;if(b=a.relatedTarget){if(Yp){a:{try{la(b.nodeName);var e=!0;break a}catch(f){}e=!1}e||(b=null)}}else c=="mouseover"?b=a.fromElement:c=="mouseout"&&(b=a.toElement);this.relatedTarget=b;d?(this.clientX=d.clientX!==void 0?d.clientX:d.pageX,this.clientY=d.clientY!==void 0?d.clientY:d.pageY,this.screenX=d.screenX||0,this.screenY= +d.screenY||0):(this.offsetX=Zp||a.offsetX!==void 0?a.offsetX:a.layerX,this.offsetY=Zp||a.offsetY!==void 0?a.offsetY:a.layerY,this.clientX=a.clientX!==void 0?a.clientX:a.pageX,this.clientY=a.clientY!==void 0?a.clientY:a.pageY,this.screenX=a.screenX||0,this.screenY=a.screenY||0);this.button=a.button;this.keyCode=a.keyCode||0;this.key=a.key||"";this.charCode=a.charCode||(c=="keypress"?a.keyCode:0);this.ctrlKey=a.ctrlKey;this.altKey=a.altKey;this.shiftKey=a.shiftKey;this.metaKey=a.metaKey;this.pointerId= +a.pointerId||0;this.pointerType=typeof a.pointerType==="string"?a.pointerType:eq[a.pointerType]||"";this.state=a.state;this.Xb=a;a.defaultPrevented&&dq.Uc.preventDefault.call(this)};dq.prototype.stopPropagation=function(){dq.Uc.stopPropagation.call(this);this.Xb.stopPropagation?this.Xb.stopPropagation():this.Xb.cancelBubble=!0};dq.prototype.preventDefault=function(){dq.Uc.preventDefault.call(this);var a=this.Xb;a.preventDefault?a.preventDefault():a.returnValue=!1};var fq="closure_listenable_"+(Math.random()*1E6|0);var gq=0;function hq(a,b,c,d,e){this.listener=a;this.proxy=null;this.src=b;this.type=c;this.capture=!!d;this.handler=e;this.key=++gq;this.Bc=this.Gc=!1}function iq(a){a.Bc=!0;a.listener=null;a.proxy=null;a.src=null;a.handler=null};function jq(a){this.src=a;this.listeners={};this.Dc=0}jq.prototype.add=function(a,b,c,d,e){var f=a.toString();a=this.listeners[f];a||(a=this.listeners[f]=[],this.Dc++);var h=kq(a,b,d,e);h>-1?(b=a[h],c||(b.Gc=!1)):(b=new hq(b,this.src,f,!!d,e),b.Gc=c,a.push(b));return b}; +jq.prototype.remove=function(a,b,c,d){a=a.toString();if(!(a in this.listeners))return!1;var e=this.listeners[a];b=kq(e,b,c,d);return b>-1?(iq(e[b]),Array.prototype.splice.call(e,b,1),e.length==0&&(delete this.listeners[a],this.Dc--),!0):!1};function kq(a,b,c,d){for(var e=0;e=0)&&Array.prototype.splice.call(k,l,1);k=m}k&&(iq(a),f.listeners[h].length== +0&&(delete f.listeners[h],f.Dc--));f.Dc==0&&(f.src=null,e[lq]=null)}else iq(a)}}a=c.call(d,b)}return a}function sq(a){a=a[lq];return a instanceof jq?a:null}var wq="__closure_events_fn_"+(Math.random()*1E9>>>0);function qq(a){if(typeof a==="function")return a;a[wq]||(a[wq]=function(b){return a.handleEvent(b)});return a[wq]};function xq(a){return to.add_after_render(a)};var yq=Lf.j(Hg,nd);function zq(a){return a!=null?a.v&32768||t===a.bd?!0:a.v?!1:w(pb,a):w(pb,a)}function Aq(a,b){Ro(a,b)}function Bq(a,b){return setTimeout(a,b)}function Cq(a){return a!=null&&t===a.Ac?""+z.i(u(vc.j?vc.j(Ko,a):vc.call(null,Ko,a))?"ra":u(vc.j?vc.j(Po,a):vc.call(null,Po,a))?"rc":u(vc.j?vc.j(So,a):vc.call(null,So,a))?"rx":u(vc.j?vc.j(Oo,a):vc.call(null,Oo,a))?"tr":"other")+z.i(tc(a)):null};var Dq=Lo.i(W);var Eq=sf(new Z(null,6,[xm,console.log.bind(console),dm,console.warn.bind(console),ok,console.error.bind(console),al,console.debug.bind(console),wk,u(console.group)?console.group.bind(console):console.log.bind(console),ej,u(console.groupEnd)?console.groupEnd.bind(console):function(){return Fc}],null)),Fq=function Fq(a){for(var c=[],d=arguments.length,e=0;;)if(e0){f=0;for(var h=Array(arguments.length-0);f0)?T.l(d,Vk,a("scale")):d}();b=new Z(null,1,[on,b],null);return u(a("iter"))?T.l(b,Vp,ke.j(1,Math.round(a("iter")))):b};var Gs=Eh([$m,xl,Yk,ql,rm,Qj,dj,Kk,Vp,hj,on,Lk,yk,Si],[nd,W,null,!1,new X(null,2,5,Y,[0,0],null),W,null,1,256,W,xs,!1,!1,null]),Hs=sf(null),Is=sf(W),Js=function Js(a,b){if(u(Pf(y(Is),new X(null,2,5,Y,[a,Wj],null))))return uf.O(Is,Qf,new X(null,2,5,Y,[a,zj],null),b);var d=U(b),e=B(d,kn);b=B(d,on);var f=B(d,rm),h=B(d,jl),k=B(d,Kk);d=B(d,Vp);S(f,0,null);var l=S(f,1,null),m=S(h,0,null),n=S(h,1,null),p=Cs(b,f,h,k);uf.O(Is,T,a,new Z(null,1,[Wj,!0],null));return vs(e,p,d).then(function(q){var v=U(q);q=B(v, +Kj);var A=B(v,Ak);v=B(v,Dm);var C=Fl.i(p),F=Xl.i(p),M=y(Hs);if(u(M)){var R=document.createElement("canvas");M=M.getContext("2d");R.width=C;R.height=F;R.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(q),C,F),0,0);M.imageSmoothingEnabled=!1;M.drawImage(R,0,0,C,F,m,0,n,l)}return gs(new X(null,3,5,Y,[aj,a,new Z(null,5,[kn,e,Ak,A,Dm,v,Fl,Fl.i(p),Xl,Xl.i(p)],null)],null))}).catch(function(q){return ur(new X(null,4,5,Y,[Mj,a,e,q.message],null))}).finally(function(){var q=Pf(y(Is),new X(null, +2,5,Y,[a,zj],null));uf.O(Is,T,a,new Z(null,1,[Wj,!1],null));return u(q)?Js.j?Js.j(a,q):Js.call(null,a,q):null})};Pq(Pi,pn,function(a){var b=U(a),c=B(b,on),d=B(b,rm);a=B(b,ql);var e=B(b,Si),f=B(b,dj),h=B(b,Kk),k=B(b,Vp);b=S(d,0,null);var l=S(d,1,null);var m=b>=8;l=m?(l=l>=8)?e:l:m;return u(l)?(l=u(a)?Math.floor(b/2):b,c=new Z(null,4,[on,c,rm,d,Kk,h,Vp,k],null),Js(em,T.u(c,kn,e,Q([jl,new X(null,2,5,Y,[0,l],null)]))),u(u(a)?f:a)?Js(Wl,T.u(c,kn,f,Q([jl,new X(null,2,5,Y,[l,b-l],null)]))):null):null}); +Pq(Pi,Kn,function(a){var b=S(a,0,null),c=S(a,1,null);a=history;var d=a.replaceState,e=U(b);b=B(e,Ej);var f=B(e,Aj);e=B(e,Vk);c=""+z.i("#")+z.i(new URLSearchParams({cx:b,cy:f,scale:e,iter:c}));return d.call(a,null,"",c)});function Ks(a){return new Z(null,2,[Hj,a,Pi,new X(null,2,5,Y,[new X(null,2,5,Y,[pn,Kh(a,new X(null,7,5,Y,[on,rm,ql,Si,dj,Kk,Vp],null))],null),new X(null,2,5,Y,[Kn,new X(null,2,5,Y,[on.i(a),Vp.i(a)],null)],null)],null)],null)}is.j(nj,function(){return Gs}); +js(el,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=sg(b);return Ks(T.u(a,$m,c,Q([Lk,!0,Si,H(c),dj,function(){var d=H(Hf(function(e){return e.startsWith("server-")},c));return u(d)?d:H(c)}()])))});js(Up,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,rm,b))}); +js(Ik,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=Jh.u(Q([on.i(a),Kh(b,new X(null,3,5,Y,[Ej,Aj,Vk],null))]));var d=U(c);var e=B(d,Ej),f=B(d,Aj);d=B(d,Vk);e=Number.isFinite(e);u(e)?(f=Number.isFinite(f),u(f)?(f=Number.isFinite(d),d=u(f)?d>0:f):d=f):d=e;a=u(d)?T.l(a,on,c):a;a=u(Vp.i(b))?T.l(a,Vp,ke.j(1,Math.round(Vp.i(b)))):a;return Ks(a)}); +js(tj,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return Ks(Sf.Fa(a,on,zs,rm.i(a),new X(null,2,5,Y,[c,d],null),b))});js(Vi,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=rm.i(a),d=S(c,0,null);c=S(c,1,null);return Ks(Sf.Fa(a,on,zs,rm.i(a),new X(null,2,5,Y,[d/2,c/2],null),b))});js(Ti,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return Ks(Sf.O(a,on,As,new X(null,2,5,Y,[c,b],null)))}); +js(Yi,function(a){a=U(a);a=B(a,Hj);return Ks(T.u(a,on,xs,Q([yk,!1])))});js(ek,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,Vp,b))});js(Fm,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,Kk,b))});js(vn,function(a){a=U(a);a=B(a,Hj);return Ks(Sf.l(a,ql,ya))});js($l,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return Ks(T.l(a,c,b))}); +is.j(aj,function(a,b){S(b,0,null);var c=S(b,1,null);b=S(b,2,null);b=U(b);var d=B(b,kn),e=B(b,Ak),f=B(b,Dm);return Rf.l(Rf.l(Qf(a,new X(null,2,5,Y,[hj,c],null),b),new X(null,2,5,Y,[Qj,d],null),mf(function(h){var k=S(h,0,null),l=S(h,1,null);h=S(h,2,null);return new X(null,3,5,Y,[k+e,l+f,h+1],null)},new X(null,3,5,Y,[0,0,0],null))),new X(null,2,5,Y,[xl,d],null),function(h){return sg(Cf(120,md.j(u(h)?h:nd,e)))})}); +is.j(Mj,function(a,b){S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return Qf(a,new X(null,2,5,Y,[hj,c],null),new Z(null,2,[kn,d,ok,b],null))});var Ls=sf(null);Pq(Pi,gk,function(a){var b=y(Ls);u(b)&&(clearInterval(b),tf(Ls,null));return u(a)?tf(Ls,setInterval(function(){return ur(new X(null,1,5,Y,[Ol],null))},50)):null});js(uj,function(a){a=U(a);a=B(a,Hj);var b=ya(yk.i(a));return new Z(null,2,[Hj,T.l(a,yk,b),Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[gk,b],null)],null)],null)}); +js(nn,function(a){a=U(a);a=B(a,Hj);return new Z(null,2,[Hj,T.l(a,yk,!1),Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[gk,!1],null)],null)],null)});js(Ol,function(a){a=U(a);var b=B(a,Hj);a=Ds(on.i(b));var c=T.u;var d=Vp.i(b);d=le.j(4096,ke.j(d,Math.round(200+60*Math.log2(Bs(a)))));b=c.call(T,b,on,a,Q([Vp,d]));return Vk.i(a)<1E-13?Sf.O(Ks(T.l(b,yk,!1)),Pi,md,new X(null,2,5,Y,[gk,!1],null)):Ks(b)}); +var Ms=new X(null,4,5,Y,[new X(null,5,5,Y,["full set",-.6,0,3/256,256],null),new X(null,5,5,Y,["seahorse valley",-.745,.112,3.125E-5,512],null),new X(null,5,5,Y,["spiral",-.10109636384562,.95628651080914,1.953125E-6,1024],null),new X(null,5,5,Y,["deep pinch",-1.768778833,.001738996,3.90625E-9,1024],null)],null); +function Ns(a,b){return ef(function(c){var d=S(c,0,null);c=Hf(function(f){var h=J.j(d,on.i(f));return h?(h=Om.i(f),u(h)?ya(ok.i(f)):h):h},a);var e=E(c);return e?Ue(J,yf.j(ai(Dm,Zk),c)):e},b)} +function Os(a){var b=U(a),c=B(b,$m),d=yc(b,Fl,256),e=yc(b,Xl,192),f=yc(b,Fj,Ms);ur(new X(null,2,5,Y,[Rm,new Z(null,4,[Fl,d,Xl,e,$m,c,Fj,f],null)],null));var h=function(){return function m(l){return new ve(null,function(){for(var n=l;;){var p=E(n);if(p){var q=p,v=H(q),A=S(v,0,null),C=S(v,1,null),F=S(v,2,null),M=S(v,3,null),R=S(v,4,null);if(p=E(function(V,ia,ta,Fb,ic,Rc,Cd,Dd,jc,Ed,bh,De,Fd,Sc,Ac){return function Gd(fg){return new ve(null,function(Hd,$d,wb,Xb,Bb,kc,Ya,ae,Id,Ee,Fe,be,Ge,He){return function(){for(;;){var Tc= +E(fg);if(Tc){if(Rd(Tc)){var Jb=Yb(Tc),Jd=N(Jb),Uc=ze(Jd);a:{for(var Vc,Ie=0;;)if(Ie0?gs(new X(null,4,5,Y,[tj,(H(n)+H(p))/2,(kd(n)+kd(p))/2,v/q],null)):null;uf.O(c,T,n.pointerId,p);return gs(new X(null,3,5,Y,[Ti,q-H(p),v-kd(p)],null))}return null});for(var d=E(new X(null,2,5,Y,["pointerup","pointercancel"],null)),e=null,f=0,h=0;;)if(h1){e.strokeStyle=bt(b,p);e.beginPath();p=E(nf(n));n=null;for(var q=0,v=0;;)if(v1){e.strokeStyle=bt(b,p);e.beginPath();k=E(nf(n));l=null;for(p=m=0;;)if(p (engines/load!) (.then #(rf/dispatch [::ev/engines-loaded %])))) diff --git a/docs/examples/fractal/web/src/fractal/engines.cljs b/docs/examples/fractal/web/src/fractal/engines.cljs index c0f61b8c9..35c66fe11 100644 --- a/docs/examples/fractal/web/src/fractal/engines.cljs +++ b/docs/examples/fractal/web/src/fractal/engines.cljs @@ -1,58 +1,34 @@ (ns fractal.engines - "Render engines. Every engine answers render-on with a promise of - {:pixels Uint8ClampedArray(w*h*4 RGBA) :ms render-ms :iters total}. + "Renderers for the page: an open set behind one port. Every renderer answers + `render` with a promise of {:pixels Uint8ClampedArray(w*h*4) :ms :iters}. + browser- that kernel's .wasm instantiated in this tab (WasmKernel) + browser-js the same pixel math in plain JavaScript (JsRaster) + server- cljw renders natively, zwasm or plain Clojure (RemoteTiles) + the JVM server (jvm/): raster, CUDA (RemoteTiles) + Discovery (Collect) fills `registry`; nothing parses an engine name.") + +;; --- the port ----------------------------------------------------------------- +(defprotocol IRenderer + (id [r]) + (exact? [r] "True when held to golden.edn: fp64, kernel operation order. False for fp32.") + (render [r tile iter] "Promise of {:pixels :ms :iters}; tile is {:cx :cy :scale :w :h}.")) - browser- that kernel's .wasm instantiated in this tab - browser-js the same pixel math in plain JavaScript, no wasm - server- native cljw renders (zwasm jit/interp, or cljw-raster)") - -(defonce kernels (atom {})) - -(def raster-url - "The raster engine's tile server (replikativ/raster on the JVM, ../raster). - Default: port 8081 on this host; ?raster= overrides." - (or (.get (js/URLSearchParams. js/location.search) "raster") - (str js/location.protocol "//" js/location.hostname ":8081"))) - -(defn- probe-raster - "Resolves to [\"raster-jvm\"] when the raster server answers, else []." - [] - (let [ctl (js/AbortController.)] - (js/setTimeout #(.abort ctl) 1500) - (-> (js/fetch (str raster-url "/engines") #js {:signal (.-signal ctl)}) - (.then #(if (.-ok %) ["raster-jvm"] [])) - (.catch (constantly []))))) - -(defn- fetch-json [url] - (-> (js/fetch url) (.then #(.json %)))) +(defn- now [] (js/performance.now)) -(defn load! - "Fetch the server's engine list and instantiate every kernel this browser - validates. Resolves to {:server [...] :kernels [...]}." - [] - (-> (fetch-json "/engines") - (.catch (fn [_] #js {:engines #js [] :kernels #js ["zig"]})) - (.then - (fn [^js data] - (let [server (vec (.-engines data)) - ids (vec (.-kernels data))] - (-> (js/Promise.all - (clj->js - (for [k ids] - (-> (js/fetch (str "/kernel.wasm?k=" k)) - (.then #(.arrayBuffer %)) - (.then (fn [bytes] - (when (js/WebAssembly.validate bytes) - (.then (js/WebAssembly.instantiate bytes #js {}) - #(swap! kernels assoc k (.. % -instance -exports)))))) - (.catch #(js/console.warn "kernel" k %)))))) - (.then (fn [_] (probe-raster))) - (.then (fn [raster] {:server server :raster raster :kernels (filterv @kernels ids)})))))))) - -(defn names - "Engine names in display order." - [{:keys [server kernels raster]}] - (vec (concat (map #(str "browser-" %) kernels) ["browser-js"] (map #(str "server-" %) server) raster))) +;; --- adapters ----------------------------------------------------------------- +(defrecord WasmKernel [rid ^js exports simd?] + IRenderer + (id [_] rid) + (exact? [_] true) + (render [_ {:keys [cx cy scale w h]} iter] + (let [f (if simd? (.-render_simd exports) (.-render exports)) + t0 (now) + _ (.set_view exports cx cy scale) + iters (js/Number (f w h iter)) + ms (- (now) t0)] + (js/Promise.resolve + {:pixels (js/Uint8ClampedArray. (.. exports -memory -buffer) (.fb_ptr exports) (* w h 4)) + :ms ms :iters iters})))) (defonce ^:private js-fb (atom (js/Uint8ClampedArray. 0))) @@ -89,48 +65,103 @@ (color! fb (* 4 (+ (* y w) x)) n limit (+ (* zr zr) (* zi zi)))))))))) {:pixels (.subarray fb 0 (* w h 4)) :iters @total})) -(defn- now [] (js/performance.now)) - -(defn render-on - "Render one w x h tile of view (cx, cy, scale) on engine. Server ms is the - guest render time from X-Render-Ms, so network time is excluded." - [engine {:keys [cx cy scale w h]} iter] - (cond - (= engine "browser-js") +(defrecord JsRaster [] + IRenderer + (id [_] "browser-js") + (exact? [_] true) + (render [_ {:keys [cx cy scale w h]} iter] (let [t0 (now) r (js-raster cx cy scale w h iter)] - (js/Promise.resolve (assoc r :ms (- (now) t0)))) - - (.startsWith engine "browser-") - (let [k (subs engine 8) - ^js ex (get @kernels k) - f (if (= k "zig-simd") (.-render_simd ex) (.-render ex)) - t0 (now) - _ (.set_view ex cx cy scale) - iters (js/Number (f w h iter)) - ms (- (now) t0)] - (js/Promise.resolve - {:pixels (js/Uint8ClampedArray. (.. ex -memory -buffer) (.fb_ptr ex) (* w h 4)) - :ms ms :iters iters})) - - :else - (let [q (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :w w :h h :iter iter - :engine (if (= engine "raster-jvm") engine (subs engine 7))}) - base (if (= engine "raster-jvm") raster-url "") + (js/Promise.resolve (assoc r :ms (- (now) t0)))))) + +;; A tile server speaking the shared protocol: X-Render-Ms is the guest render +;; time, so network time is excluded from :ms. +(defrecord RemoteTiles [rid base engine exact] + IRenderer + (id [_] rid) + (exact? [_] exact) + (render [_ {:keys [cx cy scale w h]} iter] + (let [q (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :w w :h h :iter iter :engine engine}) t0 (now)] (-> (js/fetch (str base "/tile?" q)) (.then (fn [^js resp] (if (.-ok resp) (.then (.arrayBuffer resp) (fn [buf] - (let [total (- (now) t0) - rms (js/parseFloat (or (.get (.-headers resp) "X-Render-Ms") "NaN"))] + (let [rms (js/parseFloat (or (.get (.-headers resp) "X-Render-Ms") "NaN"))] {:pixels (js/Uint8ClampedArray. buf) - :ms (if (js/Number.isFinite rms) rms total) + :ms (if (js/Number.isFinite rms) rms (- (now) t0)) :iters (js/Number (or (.get (.-headers resp) "X-Iters") "0"))}))) (.then (.text resp) #(throw (js/Error. (str "tile: HTTP " (.-status resp) " " %))))))))))) +;; --- Collect: discovery ---------------------------------------------------------- +(def jvm-url + "The JVM engines' server (jvm/, raster and CUDA). Default: port 8081 on this + host; ?jvm= overrides." + (or (.get (js/URLSearchParams. js/location.search) "jvm") + (str js/location.protocol "//" js/location.hostname ":8081"))) + +(defn- fetch-engines + "Promise of the server at base's {:engines [...] :kernels [...]}, or empty + when it does not answer within ms." + [base ms] + (let [ctl (js/AbortController.)] + (js/setTimeout #(.abort ctl) ms) + (-> (js/fetch (str base "/engines") #js {:signal (.-signal ctl)}) + (.then #(.json %)) + (.then (fn [^js d] {:engines (vec (.-engines d)) + :kernels (vec (or (.-kernels d) #js [])) + :inexact (set (or (.-inexact d) #js []))})) + (.catch (constantly {:engines [] :kernels [] :inexact #{}}))))) + +(defn- wasm-kernel + "Promise of a WasmKernel for kernel k, or nil when this browser cannot run it." + [k] + (-> (js/fetch (str "/kernel.wasm?k=" k)) + (.then #(.arrayBuffer %)) + (.then (fn [bytes] + (when (js/WebAssembly.validate bytes) + (.then (js/WebAssembly.instantiate bytes #js {}) + #(->WasmKernel (str "browser-" k) (.. % -instance -exports) (= k "zig-simd")))))) + (.catch (fn [e] (js/console.warn "kernel" k e) nil)))) + +(defn discover + "Promise of every renderer this page can reach, in display order." + [] + (-> (js/Promise.all #js [(fetch-engines "" 5000) (fetch-engines jvm-url 1500)]) + (.then (fn [[cljw jvm]] + (.then (js/Promise.all (clj->js (map wasm-kernel (:kernels cljw)))) + (fn [kernels] + (vec (concat (remove nil? kernels) + [(->JsRaster)] + (for [e (:engines cljw)] + (->RemoteTiles (str "server-" e) "" e (not ((:inexact cljw) e)))) + (for [e (:engines jvm)] + (->RemoteTiles e jvm-url e (not ((:inexact jvm) e)))))))))))) + +;; --- the registry the page renders through ---------------------------------------- +(defonce registry (atom (array-map))) + +(defn load! + "Discover and register every renderer; resolves to their ids in order." + [] + (.then (discover) + (fn [rs] + (reset! registry (into (array-map) (map (juxt id identity)) rs)) + (mapv id rs)))) + +(defn names [ids] (vec ids)) + +(defn exact-engine? [engine] (boolean (some-> (get @registry engine) exact?))) + +(defn render-on + "Render a tile on the registered renderer named engine." + [engine tile iter] + (if-let [r (get @registry engine)] + (render r tile iter) + (js/Promise.reject (js/Error. (str "unknown engine " engine))))) + (defn checksum - "The bench's framebuffer checksum, identical to bench.clj's." + "The golden checksum, identical to golden.edn's." [px] (let [n (.-length px)] (loop [i 0 c 1] diff --git a/docs/examples/fractal/web/src/fractal/events.cljs b/docs/examples/fractal/web/src/fractal/events.cljs index 25145e962..55fdef342 100644 --- a/docs/examples/fractal/web/src/fractal/events.cljs +++ b/docs/examples/fractal/web/src/fractal/events.cljs @@ -149,10 +149,12 @@ ["spiral" -0.10109636384562 0.95628651080914 (/ 0.0005 256) 1024] ["deep pinch" -1.768778833 0.001738996 (/ 1e-6 256) 1024]]) -(defn parity? [rows views] +(defn parity? + "Every exact engine gave the same iteration total and checksum on each view." + [rows views] (every? (fn [[name]] - (let [its (keep #(when (and (= name (:view %)) (not (:error %))) (:iters %)) rows)] - (and (seq its) (apply = its)))) + (let [rs (filter #(and (= name (:view %)) (:exact? %) (not (:error %))) rows)] + (and (seq rs) (apply = (map (juxt :iters :checksum) rs))))) views)) (defn run-bench! @@ -166,6 +168,7 @@ (-> (engines/render-on e pv it) (.then (fn [{:keys [pixels ms iters]}] {:view name :engine e :ms ms :iters iters + :exact? (engines/exact-engine? e) :mits (/ iters ms 1000) :checksum (engines/checksum pixels)})) (.catch (fn [err] {:view name :engine e :error (.-message err)})) (.then (fn [row] (rf/dispatch [::bench-row row]) (conj rows row))))))) diff --git a/docs/examples/fractal/web/src/fractal/ui.cljs b/docs/examples/fractal/web/src/fractal/ui.cljs index b20dafbc8..7eb9e7cbe 100644 --- a/docs/examples/fractal/web/src/fractal/ui.cljs +++ b/docs/examples/fractal/web/src/fractal/ui.cljs @@ -10,7 +10,7 @@ "#ff7b72" "#a5d6ff" "#7ee787" "#e3b341" "#ffa198" "#d2a8ff"]) (defn color-of [engines e] - (nth palette (mod (max 0 (.indexOf (clj->js engines) e)) (count palette)))) + (nth palette (mod (max 0 (.indexOf (vec engines) e)) (count palette)))) (defn- swatch [engines e] [:span {:style {:color (color-of engines e)}} "▬ "]) @@ -85,7 +85,7 @@ (let [[x y] (canvas-point c e)] (rf/dispatch [::ev/zoom-at x y (if (.-shiftKey e) 0.5 2)])))) (.addEventListener c "pointerdown" - (fn [e] (.setPointerCapture c (.-pointerId e)) + (fn [e] (try (.setPointerCapture c (.-pointerId e)) (catch :default _ nil)) (swap! pointers assoc (.-pointerId e) (canvas-point c e)) (rf/dispatch [::ev/stop-tour]))) (.addEventListener c "pointermove" @@ -141,8 +141,8 @@ [:div#wrap [:canvas#view {:ref mount-canvas!}] [:div#divider {:style {:display (if split? "block" "none")}}] - [:div.hud#hudL (when-let [p (:L panes)] (hud-text p v iter))] - [:div.hud#hudR {:style {:display (if split? "block" "none")}} + [:div#hudL.hud (when-let [p (:L panes)] (hud-text p v iter))] + [:div#hudR.hud {:style {:display (if split? "block" "none")}} (when-let [p (:R panes)] (hud-text p v iter))]]))})) ;; --- stats -------------------------------------------------------------------------- diff --git a/docs/examples/fractal/web/test/fractal/view_test.cljs b/docs/examples/fractal/web/test/fractal/view_test.cljs new file mode 100644 index 000000000..57ea8ebc3 --- /dev/null +++ b/docs/examples/fractal/web/test/fractal/view_test.cljs @@ -0,0 +1,65 @@ +(ns fractal.view-test + "Property-based laws of the view value: zooming keeps the point under the + cursor, panning is reversible, the URL hash round-trips, and split panes + tile the shared view without a seam." + (:require [cljs.test :refer [deftest is]] + [clojure.test.check :as tc] + [clojure.test.check.generators :as gen] + [clojure.test.check.properties :as prop] + [fractal.view :as view])) + +(def gen-view + (gen/let [cx (gen/double* {:min -2.0 :max 1.0 :NaN? false :infinite? false}) + cy (gen/double* {:min -1.5 :max 1.5 :NaN? false :infinite? false}) + e (gen/choose 1 40)] + {:cx cx :cy cy :scale (js/Math.pow 2 (- e))})) + +(def gen-canvas (gen/tuple (gen/choose 8 1024) (gen/choose 8 1024))) + +(defn- close? [a b tol] (<= (js/Math.abs (- a b)) (* tol (max 1 (js/Math.abs a) (js/Math.abs b))))) + +(defn- plane-point [{:keys [cx cy scale]} [w h] [px py]] + [(+ cx (* (- px (/ w 2)) scale)) (+ cy (* (- py (/ h 2)) scale))]) + +(defn- check [n p] + (let [r (tc/quick-check n p)] + (is (:pass? r) (pr-str (select-keys r [:shrunk :seed]))))) + +(deftest zoom-keeps-the-cursor-point + (check 300 (prop/for-all [v gen-view [w h] gen-canvas + fx (gen/double* {:min 0 :max 1 :NaN? false}) + fy (gen/double* {:min 0 :max 1 :NaN? false}) + factor (gen/elements [0.5 1.5 2 4 0.25])] + (let [px (* fx w) py (* fy h) + before (plane-point v [w h] [px py]) + after (plane-point (view/zoom-at v [w h] [px py] factor) [w h] [px py])] + (and (close? (first before) (first after) 1e-9) + (close? (second before) (second after) 1e-9)))))) + +(deftest zoom-scales-by-the-factor + (check 200 (prop/for-all [v gen-view c gen-canvas factor (gen/elements [0.5 2 4])] + (close? (/ (:scale v) factor) (:scale (view/zoom-at v c [0 0] factor)) 1e-12)))) + +(deftest pan-is-reversible + (check 300 (prop/for-all [v gen-view dx (gen/choose -500 500) dy (gen/choose -500 500)] + (let [back (view/pan (view/pan v [dx dy]) [(- dx) (- dy)])] + (and (close? (:cx v) (:cx back) 1e-9) (close? (:cy v) (:cy back) 1e-9)))))) + +(deftest hash-round-trips + (check 300 (prop/for-all [v gen-view iter (gen/choose 1 4096)] + (= {:view v :iter iter} (view/<-hash (view/->hash v iter)))))) + +(deftest hash-ignores-garbage + (is (= {:view {}} (view/<-hash "#cx=abc&scale=-1&iter="))) + (is (= {:view {}} (view/<-hash nil)))) + +(deftest split-panes-tile-the-view + (check 200 (prop/for-all [v gen-view [w h] gen-canvas] + (let [lw (js/Math.floor (/ w 2)) + left (view/pane-view v [w h] [0 lw] 1) + right (view/pane-view v [w h] [lw (- w lw)] 1) + ;; the plane x of the left pane's last column + 1 is the right pane's first + left-edge (+ (:cx left) (* (- lw (/ (:w left) 2)) (:scale left))) + right-edge (+ (:cx right) (* (- 0 (/ (:w right) 2)) (:scale right)))] + (and (= (+ (:w left) (:w right)) w) + (close? left-edge right-edge 1e-9)))))) diff --git a/test/e2e/phase16_fractal_kernel.sh b/test/e2e/phase16_fractal_kernel.sh index 890f0233a..0a268e8d1 100644 --- a/test/e2e/phase16_fractal_kernel.sh +++ b/test/e2e/phase16_fractal_kernel.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # e2e-process-boundary: rebuilds Zig wasm artifacts and compares compiler output # Layer 2 correctness gate, not a performance gate: no timing thresholds. -# Committed artifacts must pass bench.clj's small parity cases across engines. -# Both exact header recipes must reproduce the committed files byte-for-byte. +# Committed artifacts must reproduce golden.edn's :ci views on every engine. +# Every exact header recipe must reproduce its committed file byte-for-byte. set -euo pipefail cd "$(dirname "$0")/../.." BIN="${CLJW_BIN:-zig-out/bin/cljw}" @@ -25,32 +25,50 @@ DEMO="$(pwd)/docs/examples/fractal" scratch="$(mktemp -d "${TMPDIR:-/tmp}/cljw_fractal_XXXXXX")" trap 'rm -rf "$scratch"' EXIT -# Run in a scratch repo-shaped tree, so CI never overwrites the human PPM. -mkdir -p "$scratch/docs/examples/fractal" -cp "$DEMO/bench.clj" "$DEMO/kernel.zig" "$DEMO/kernel.wasm" "$DEMO/kernel_simd.wasm" \ +# Run in a scratch repo-shaped tree holding only the cljw half of the demo. +mkdir -p "$scratch/docs/examples/fractal/cljw" +cp "$DEMO/bench.clj" "$DEMO/mandel.clj" "$DEMO/golden.edn" \ + "$DEMO/kernel.zig" "$DEMO/kernel.c" "$DEMO/kernel.rs" \ + "$DEMO/kernel.wasm" "$DEMO/kernel_simd.wasm" "$DEMO/kernel_c.wasm" "$DEMO/kernel_rust.wasm" \ "$scratch/docs/examples/fractal/" +cp "$DEMO"/cljw/*.clj "$scratch/docs/examples/fractal/cljw/" out="$(cd "$scratch" && CLJW_FRACTAL_CI=1 run_bounded 60 "$BIN" docs/examples/fractal/bench.clj 2>&1)" \ - || fail "committed artifact parity failed: + || fail "committed artifacts do not reproduce golden.edn: $out" -for marker in "PASS scalar artifact: strict engine and vector-source parity" \ - "PASS simd128 JIT strict parity"; do - grep -qF "$marker" <<<"$out" || fail "missing $marker: +# The :ci golden views (odd-tail, tip) on 8 engines: Zig, C and Rust on zwasm +# jit and interp, Zig simd128 on jit, plain Clojure. Every frame is golden. +grep -qF "PASS golden: 16 frames on 8 engines reproduce golden.edn" <<<"$out" \ + || fail "missing golden pass marker: $out" -done -# CI selects reduced views only, with exactly 18 frame comparisons. -[ "$(grep -c '^:.* checksum ' <<<"$out")" -eq 18 ] || fail "expected 18 parity frames: +[ "$(grep -c '^frame .* golden$' <<<"$out")" -eq 16 ] || fail "expected 16 golden frames: $out" -echo "PASS fractal-kernel-committed -> 18 frames, scalar JIT/interp and simd128 JIT parity" +echo "PASS fractal-kernel-committed -> 16 golden frames on 8 engines" # Rebuild from the header recipes, spelled out like phase16_zig_native.sh. # Scratch is repo-shaped so the source and output paths match the header. -rm "$scratch/docs/examples/fractal/kernel.wasm" "$scratch/docs/examples/fractal/kernel_simd.wasm" +rm "$scratch/docs/examples/fractal/kernel.wasm" "$scratch/docs/examples/fractal/kernel_simd.wasm" \ + "$scratch/docs/examples/fractal/kernel_c.wasm" (cd "$scratch" && run_bounded 90 zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel.wasm) \ || fail "scalar header recipe failed" (cd "$scratch" && run_bounded 90 zig build-exe docs/examples/fractal/kernel.zig -target wasm32-freestanding -mcpu=generic+simd128 -O ReleaseFast -fstrip -fno-entry -rdynamic -femit-bin=docs/examples/fractal/kernel_simd.wasm) \ || fail "SIMD header recipe failed" -for artifact in kernel.wasm kernel_simd.wasm; do +(cd "$scratch" && run_bounded 90 zig cc -target wasm32-freestanding -O3 -nostdlib -fno-builtin -Wl,--no-entry -Wl,--strip-all -o docs/examples/fractal/kernel_c.wasm docs/examples/fractal/kernel.c) \ + || fail "C header recipe failed" +for artifact in kernel.wasm kernel_simd.wasm kernel_c.wasm; do cmp -s "$DEMO/$artifact" "$scratch/docs/examples/fractal/$artifact" \ || fail "$artifact differs from exact header recipe output" done -echo "PASS fractal-kernel-rebuilt -> both artifacts byte-identical to committed files" +echo "PASS fractal-kernel-rebuilt -> Zig scalar, Zig simd128 and C artifacts byte-identical to committed files" + +# Rust needs rustup's wasm32-unknown-unknown target; CI images may lack it. +if command -v rustc >/dev/null 2>&1 && rustc --print sysroot >/dev/null 2>&1 \ + && [ -d "$(rustc --print sysroot)/lib/rustlib/wasm32-unknown-unknown" ]; then + rm "$scratch/docs/examples/fractal/kernel_rust.wasm" + (cd "$scratch" && run_bounded 120 rustc --edition 2021 --crate-type=cdylib --target wasm32-unknown-unknown -C opt-level=3 -C panic=abort -C strip=symbols -o docs/examples/fractal/kernel_rust.wasm docs/examples/fractal/kernel.rs) \ + || fail "Rust header recipe failed" + cmp -s "$DEMO/kernel_rust.wasm" "$scratch/docs/examples/fractal/kernel_rust.wasm" \ + || fail "kernel_rust.wasm differs from exact header recipe output" + echo "PASS fractal-kernel-rust-rebuilt -> byte-identical to committed file" +else + echo "SKIP fractal-kernel-rust-rebuilt: rustc wasm32-unknown-unknown target not installed" +fi From a5543614edf3e8ca737ee32ad31c66a45b22574c Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 21:05:30 -0300 Subject: [PATCH 45/51] fix(gate): fractal and http e2e on macOS, server.zig markers in the top 20 lines check_surface_marker reads only the first 20 lines; the byte-array docstring pushed `Impl deps:` and `Clojure peer:` to lines 21 and 22. The docstring is rewrapped to the same content in two fewer lines. On macOS `wc -c` pads its count, so the /bytes length check strips spaces, and phase16_fractal_kernel runs unbounded without timeout or gtimeout like its sibling gates instead of failing. Smell-audited: 1: comment rewrap and two portability fixes, no behaviour change --- src/runtime/cljw/http/server.zig | 14 ++++++-------- test/e2e/phase16_fractal_kernel.sh | 2 +- test/e2e/phase16_http_server.sh | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/runtime/cljw/http/server.zig b/src/runtime/cljw/http/server.zig index 37f36bf7c..c35543f9a 100644 --- a/src/runtime/cljw/http/server.zig +++ b/src/runtime/cljw/http/server.zig @@ -3,14 +3,12 @@ //! `std.Io.net` + `std.http.Server`. Layer 0 (the cljw-original surface tree). //! //! Ring-style: `run-server` serves a blocking serial accept loop; each request -//! becomes a Ring request map `{:request-method :get :uri "/path" :query-string -//! "…" :headers {…} :body "…"}` passed to the handler (invoked via -//! `rt.vtable.callFn`), whose response map `{:status N :body "…"}` (or a bare -//! string body) is written back. A response `:body` may also be a primitive -//! byte-array (`byte-array`, e.g. from `wasm/mem-read-array`): its bytes are -//! copied verbatim. Header names are lowercased (Ring); -//! `:body` is nil when the client declared none (read only on -//! Content-Length/chunked, capped at `max_body_bytes`). +//! becomes a Ring map `{:request-method :get :uri "/path" :query-string "…" +//! :headers {…} :body "…"}` for the handler (`rt.vtable.callFn`), whose response +//! `{:status N :body …}` (or a bare string) is written back. A `:body` is a +//! string or a primitive byte-array (e.g. `wasm/mem-read-array`), copied +//! verbatim. Header names are lowercased (Ring); the request `:body` is nil when +//! none was declared (Content-Length/chunked, capped at `max_body_bytes`). //! //! Follow-ons (D-257 remainder): per-connection threading + a non-blocking stop //! handle (so keep-alive can be re-enabled), and per-request GC rooting of the diff --git a/test/e2e/phase16_fractal_kernel.sh b/test/e2e/phase16_fractal_kernel.sh index 890f0233a..de7480253 100644 --- a/test/e2e/phase16_fractal_kernel.sh +++ b/test/e2e/phase16_fractal_kernel.sh @@ -11,7 +11,7 @@ run_bounded() { local secs="$1"; shift if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" - else fail "timeout or gtimeout is required"; fi + else "$@"; fi } # The harness owns building cljw. Never silently rebuild a non-wasm binary. diff --git a/test/e2e/phase16_http_server.sh b/test/e2e/phase16_http_server.sh index ec864da71..21147e582 100755 --- a/test/e2e/phase16_http_server.sh +++ b/test/e2e/phase16_http_server.sh @@ -59,7 +59,7 @@ echo "PASS http-html-body -> $body" # A primitive byte-array :body is served verbatim: 256 bytes, one of each # value 0..255. A UTF-8 path would expand bytes >= 0x80 and grow the body. -BLEN=$(curl -s "http://127.0.0.1:$PORT/bytes" | wc -c) +BLEN=$(curl -s "http://127.0.0.1:$PORT/bytes" | wc -c | tr -d ' ') [[ "$BLEN" == "256" ]] || fail "GET /bytes length: got $BLEN, want 256" BOD=$(curl -s "http://127.0.0.1:$PORT/bytes" | od -An -tu1 | tr -s ' ') EXP="" From 8378fa067376afa93feb24a954361a7591e8f89d Mon Sep 17 00:00:00 2001 From: clojurewasm-release Date: Sun, 8 Nov 2026 00:30:59 +0000 Subject: [PATCH 46/51] chore(release): v1.15.6 --- CHANGELOG.md | 2 ++ build.zig.zon | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c616fcac0..552eb8cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +## [1.15.6] - 2026-11-08 + ### Added - `cljw.http.server` accepts a primitive byte-array `:body` (for example the diff --git a/build.zig.zon b/build.zig.zon index 97890e403..74c0bd2f7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cljw, - .version = "1.15.5", + .version = "1.15.6", .fingerprint = 0x1869d207073beffa, .minimum_zig_version = "0.16.0", .dependencies = .{ From 9faf089f664cd8dcea336b5f9d7899eabe0f5bca Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 21:54:49 -0300 Subject: [PATCH 47/51] feat(examples): fractal formulas as data, on the benchmark winners first A formula is one orbit step in a small expression language (fractal.formula): Mandelbrot, Burning Ship, Tricorn and Multibrot z^3, each on the c-plane or as a Julia set (the seed is a separate axis). Every backend compiles the same expression: the oracle to a Clojure fn, raster to a generated deftm per formula and seed, CUDA to generated C (escape.cu is gone). They agree bit for bit. Renderers state what they render in describe (:formulas, :julia?); one that states nothing stays Mandelbrot only, so the wasm kernels, the browser engines and cljw need no change. /tile refuses the rest with a 400, and the page offers only engines whose caps cover the fractal. Tests: golden views for every formula on both seeds; 149 step mutants generated from the formula data, all killed; the compiled step equals an independent interpreter; exact symmetry laws; the unit-disk Julia set; page scenarios for formula golden, switch, Julia at the cursor, refusal. --- docs/examples/fractal/README.md | 59 +- docs/examples/fractal/bench.clj | 3 +- docs/examples/fractal/cljw/renderer.clj | 10 + docs/examples/fractal/golden.edn | 20 +- docs/examples/fractal/hive-cljs.edn | 44 +- docs/examples/fractal/jvm/deps.edn | 3 +- .../fractal/jvm/resources/fractal/escape.cu | 31 - .../fractal/jvm/src/fractal/bench.clj | 6 +- .../fractal/jvm/src/fractal/formula.clj | 113 ++ .../examples/fractal/jvm/src/fractal/http.clj | 52 +- .../fractal/jvm/src/fractal/renderer.clj | 24 +- .../fractal/jvm/src/fractal/renderer/cuda.clj | 41 +- .../jvm/src/fractal/renderer/cuda_source.clj | 77 + .../jvm/src/fractal/renderer/oracle.clj | 61 +- .../jvm/src/fractal/renderer/raster.clj | 91 +- .../examples/fractal/jvm/src/fractal/tile.clj | 39 +- .../fractal/jvm/test/fractal/formula_test.clj | 119 ++ .../jvm/test/fractal/mutation_test.clj | 105 +- .../jvm/test/fractal/property_test.clj | 66 +- docs/examples/fractal/server.clj | 30 +- docs/examples/fractal/web/public/js/main.js | 1265 +++++++++-------- .../fractal/web/src/fractal/core.cljs | 19 +- .../fractal/web/src/fractal/engines.cljs | 133 +- .../fractal/web/src/fractal/events.cljs | 147 +- docs/examples/fractal/web/src/fractal/ui.cljs | 46 +- .../fractal/web/src/fractal/view.cljs | 48 +- .../fractal/web/test/fractal/view_test.cljs | 74 +- 27 files changed, 1806 insertions(+), 920 deletions(-) delete mode 100644 docs/examples/fractal/jvm/resources/fractal/escape.cu create mode 100644 docs/examples/fractal/jvm/src/fractal/formula.clj create mode 100644 docs/examples/fractal/jvm/src/fractal/renderer/cuda_source.clj create mode 100644 docs/examples/fractal/jvm/test/fractal/formula_test.clj diff --git a/docs/examples/fractal/README.md b/docs/examples/fractal/README.md index 7c8392d22..0d71cd0b2 100644 --- a/docs/examples/fractal/README.md +++ b/docs/examples/fractal/README.md @@ -1,10 +1,11 @@ -# Fractal zoom: one Mandelbrot, nineteen engines +# Fractal zoom: four formulas, nineteen engines -A Mandelbrot explorer where the same picture is rendered by kernels written in +A fractal explorer where the same picture is rendered by kernels written in Zig, C and Rust (compiled to WebAssembly), by plain Clojure, by replikativ/raster on the JVM and by CUDA on the GPU. The page zooms, pans and -compares any two engines side by side. Every exact engine paints the same -bytes, and `golden.edn` proves it. +compares any two engines side by side. It shows the Mandelbrot set, the +Burning Ship, the Tricorn and the cubic Multibrot, each also as a Julia set. +Every exact engine paints the same bytes, and `golden.edn` proves it. ``` browser (re-frame + reagent, web/) @@ -39,6 +40,8 @@ with server.clj running, or `npx shadow-cljs release app` for the committed buil | wheel, `+` / `-`, double-click (shift: out), pinch | zoom at the cursor | | drag, arrows, `WASD` | slide x and y | | x, y, scale fields + Go | jump to a point | +| fractal, Julia | pick the formula; show its Julia set | +| `j` | the Julia set of the point under the cursor | | left / right, Split | pick engines, compare them side by side | | iter, res | iteration cap, render resolution | | Tour | continuous zoom into seahorse valley | @@ -66,6 +69,43 @@ On the JVM a renderer computes only the escape field (count and |z|^2 per pixel), and `palette.clj` paints it once, above the port. The wasm kernels paint inside wasm; golden.edn holds them to the same bytes. +## Formulas + +A formula is data: one step of the orbit, `z <- f(z, c)`, in a small +expression language over `zr zi cr ci` (`+ - * abs`, literals, `:let` +bindings). The registry in `jvm/src/fractal/formula.clj` holds four: + +| id | step | +|---|---| +| `mandelbrot` | z^2 + c | +| `burning-ship` | (\|Re z\| + i\|Im z\|)^2 + c | +| `tricorn` | conj(z)^2 + c | +| `multibrot3` | z^3 + c | + +The seed is a separate axis. On the c-plane z0 = 0 and c is the pixel; on the +z-plane (a Julia set) z0 is the pixel and c = k. Any formula takes either seed. + +Every backend compiles the same expression, so they agree bit for bit: + +| backend | compiles the step to | +|---|---| +| oracle | a Clojure fn (`eval`), the reference | +| raster | one typed `deftm` per formula and seed, generated by a macro | +| CUDA | C source, generated by `renderer/cuda_source.clj`, one kernel per formula, seed and precision | + +A renderer states what it renders in `describe` (`:formulas`, `:julia?`). One +that states nothing is Mandelbrot on the c-plane, so an engine opts in and the +rest need no change. The new formulas start on the benchmark winners: raster +and CUDA, plus the oracle that judges them. The wasm kernels, the browser +engines and cljw stay Mandelbrot only. The page reads every engine's caps from +`/engines`, offers only the engines that can draw the current fractal, and +`/tile` refuses the rest with a 400. + +Adding a formula is one registry entry and two golden views (c-plane and +Julia). The tests then demand the rest: every backend compiles it, the compiled +step equals an independent interpreter, and every one-mistake mutant of the +step must be killed by its golden views. + ## The kernel ABI Every `.wasm` kernel has no imports and exports: @@ -95,11 +135,12 @@ build recipe; the e2e gate rebuilds them and requires identical bytes. | suite | what | run | |---|---|---| -| golden | every exact engine reproduces `golden.edn` (iterations and framebuffer checksum) | `cljw bench.clj`, `clojure -M:jvm:test`, page `:golden` | -| property | test.check: every JVM renderer equals the oracle bit for bit on generated tiles; palette, tile and HTTP laws | `clojure -M:jvm:test` | -| view laws | test.check on the page's view value: zoom keeps the cursor point, pan reverses, hash round-trips, split panes meet | `cd web && npx shadow-cljs compile test && node target/test.js` | -| mutation | 6 escape-rule and 4 palette mutants must each be rejected by the golden views | `clojure -M:jvm:test` | -| page | hive-cljs scenarios: boot, cross-engine parity, zoom, pan, hash, split, bench, JVM and GPU engines | `code cljs e2e run directory=docs/examples/fractal tags=fractal` | +| golden | every exact engine reproduces `golden.edn` (iterations and framebuffer checksum) on every view whose formula and seed it renders | `cljw bench.clj`, `clojure -M:jvm:test`, page `:golden`, `:formula-golden` | +| property | test.check: every JVM renderer equals the oracle bit for bit on generated tiles of every formula and seed; palette, tile and HTTP laws, including the refusal of what an engine does not render | `clojure -M:jvm:test` | +| formula | the registry is well formed and has golden views on both seeds; every backend compiles every formula; the compiled step equals an independent interpreter on generated expressions; exact conjugate and odd symmetries; the Julia set of z^2 at k = 0 is the unit disk | `clojure -M:jvm:test` | +| view laws | test.check on the page's values: zoom keeps the cursor point, pan reverses, hash round-trips with the fractal, split panes meet, engine fitting picks only engines that render the fractal | `cd web && npx shadow-cljs compile test && node target/test.js` | +| mutation | 4 orbit mutants, 149 step mutants generated from the formula data, and 4 palette mutants must each be rejected by the golden views | `clojure -M:jvm:test` | +| page | hive-cljs scenarios: boot, cross-engine parity, zoom, pan, hash, split, bench, JVM and GPU engines, formula golden, formula switch, Julia at the cursor, refusal | `code cljs e2e run directory=docs/examples/fractal tags=fractal` | | gate | `test/e2e/phase16_fractal_kernel.sh`: the `:ci` golden views on 8 cljw engines, recipes byte-identical | `bash test/run_all.sh` | The mutation suite earned its place at once. No golden pixel sat exactly on diff --git a/docs/examples/fractal/bench.clj b/docs/examples/fractal/bench.clj index 1c93bf75a..113ca1381 100644 --- a/docs/examples/fractal/bench.clj +++ b/docs/examples/fractal/bench.clj @@ -22,7 +22,8 @@ (defn selected? [v] (if ci? (:ci v) (or (not (:heavy v)) heavy?))) (defn runs? [rr v] - (or (not (:heavy v)) (not (:interp? (fractal.cljw.renderer/describe rr))))) + (and (fractal.cljw.renderer/renders? rr v) + (or (not (:heavy v)) (not (:interp? (fractal.cljw.renderer/describe rr)))))) (defn measure [rr v] (let [t (fractal.cljw.tile/tile v) diff --git a/docs/examples/fractal/cljw/renderer.clj b/docs/examples/fractal/cljw/renderer.clj index fc0caea7a..6a01bb2e1 100644 --- a/docs/examples/fractal/cljw/renderer.clj +++ b/docs/examples/fractal/cljw/renderer.clj @@ -9,6 +9,16 @@ (describe [r] "{:interp? bool :what str}; interpreted engines skip heavy views.") (render! [r t] "Render Tile t: {:iters total :rgba byte-array}.")) +(defn renders? + "Whether r renders view v ({:formula id :julia [kr ki]}, both optional). + Same rule as the JVM port: a renderer that names no :formulas renders + Mandelbrot only, and the z-plane seed only when it says :julia?. Every cljw + engine today is Mandelbrot on the c-plane." + [r v] + (let [d (describe r)] + (and (contains? (or (:formulas d) #{"mandelbrot"}) (or (:formula v) "mandelbrot")) + (or (nil? (:julia v)) (boolean (:julia? d)))))) + (defn frame "Render Tile t on r, timed: {:iters :rgba :ms}." [r t] diff --git a/docs/examples/fractal/golden.edn b/docs/examples/fractal/golden.edn index a8b8e794a..d5876a85e 100644 --- a/docs/examples/fractal/golden.edn +++ b/docs/examples/fractal/golden.edn @@ -6,6 +6,10 @@ ;; :ci marks the small views the e2e gate runs. :heavy views (billions of ;; iterations) run only where asked (HEAVY=1 on cljw); "deep" was measured on ;; the JVM oracle and confirmed by raster-jvm and cuda-f64. +;; :formula (default "mandelbrot") names a fractal.formula registry entry and +;; :julia [kr ki] selects the z-plane seed. A view runs only on the engines +;; that render its formula and seed; the formula views were measured on the +;; JVM oracle and confirmed by raster-jvm and cuda-f64. {:views [{:name "full" :cx -0.5 :cy 0.0 :scale 0.0068359375 :w 512 :h 512 :iter 256 :iters 9284549 :checksum 1665484798} @@ -18,4 +22,18 @@ {:name "tip" :cx -2.0 :cy 0.0 :scale 0.25 :w 8 :h 8 :iter 50 :iters 317 :checksum 2086288669 :ci true} {:name "deep" :cx -0.743643887037151 :cy 0.13182590420533 :scale 2.0e-9 :w 1024 :h 768 :iter 4096 - :iters 2391873304 :checksum 1958721964 :heavy true}]} + :iters 2391873304 :checksum 1958721964 :heavy true} + {:name "mandelbrot-julia" :formula "mandelbrot" :julia [-0.8 0.156] :cx 0.0 :cy 0.0 :scale 0.034 :w 96 :h 72 :iter 300 + :iters 191953 :checksum 1423124412} + {:name "burning-ship" :formula "burning-ship" :cx -0.45 :cy -0.5 :scale 0.03 :w 96 :h 72 :iter 300 + :iters 630142 :checksum 31342209} + {:name "burning-ship-julia" :formula "burning-ship" :julia [-1.2 0.0] :cx 0.0 :cy 0.0 :scale 0.034 :w 96 :h 72 :iter 300 + :iters 240255 :checksum 1143885555} + {:name "tricorn" :formula "tricorn" :cx -0.3 :cy 0.0 :scale 0.0375 :w 96 :h 72 :iter 300 + :iters 192243 :checksum 838797695} + {:name "tricorn-julia" :formula "tricorn" :julia [-0.4 0.3] :cx 0.0 :cy 0.0 :scale 0.034 :w 96 :h 72 :iter 300 + :iters 27187 :checksum 1060707954} + {:name "multibrot3" :formula "multibrot3" :cx 0.0 :cy 0.0 :scale 0.03 :w 96 :h 72 :iter 300 + :iters 613480 :checksum 683277785} + {:name "multibrot3-julia" :formula "multibrot3" :julia [-0.2 0.75] :cx 0.0 :cy 0.0 :scale 0.034 :w 96 :h 72 :iter 300 + :iters 370534 :checksum 823951201}]} diff --git a/docs/examples/fractal/hive-cljs.edn b/docs/examples/fractal/hive-cljs.edn index 5c938d0e8..32538e26c 100644 --- a/docs/examples/fractal/hive-cljs.edn +++ b/docs/examples/fractal/hive-cljs.edn @@ -93,7 +93,7 @@ :doc "The bench renders the view list on every engine and reports parity." :steps [[:goto "/"] [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] - [:expect-js "window.fractalApi.bench({w:64,h:48}).then(function(r){return r.parity===true && r.rows.length===4*window.fractalApi.engines().length && r.rows.every(function(x){return !x.error;});})"] + [:expect-js "window.fractalApi.bench({w:64,h:48}).then(function(r){return r.parity===true && r.rows.length===r.views.length*window.fractalApi.engines().length && r.rows.every(function(x){return !x.error;});})"] [:wait-for "#benchtab"]]} {:id :jvm-engines @@ -114,4 +114,44 @@ [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] [:expect-js "(function(){var s=document.getElementById('engR');s.value='cuda-f64';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"] [:click "#split"] - [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(p.R&&p.R.engine==='cuda-f64'&&p.R.iters>0){clearInterval(t);ok(true);}},100);})"]]}]}} + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(p.R&&p.R.engine==='cuda-f64'&&p.R.iters>0){clearInterval(t);ok(true);}},100);})"]]} + + {:id :mandelbrot-only-engines-refuse + :tags [:fractal :formula] + :doc "An engine whose caps name only Mandelbrot refuses another formula + and a Julia seed, in the page, before any request is made." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "Promise.all([window.fractalApi.render('browser-zig',-0.45,-0.5,0.03,8,8,50,'burning-ship').then(function(){return false;},function(){return true;}), window.fractalApi.render('browser-js',0,0,0.034,8,8,50,'mandelbrot',-0.8,0.156).then(function(){return false;},function(){return true;})]).then(function(rs){return rs[0]&&rs[1];})"]]} + + {:id :formula-golden + :tags [:fractal :jvm :formula :golden] + :doc "Through the page, every exact engine that renders a formula + reproduces golden.edn's burning-ship and multibrot3-julia views: + raster on the CPU, CUDA fp64 on the GPU, the plain-Clojure oracle." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "Promise.all(['raster-jvm','cuda-f64','clojure-jvm'].map(function(e){return window.fractalApi.render(e,-0.45,-0.5,0.03,96,72,300,'burning-ship');})).then(function(rs){return rs.every(function(r){return r.iters===630142&&r.checksum===31342209;});})"] + [:expect-js "Promise.all(['raster-jvm','cuda-f64','clojure-jvm'].map(function(e){return window.fractalApi.render(e,0,0,0.034,96,72,300,'multibrot3',-0.2,0.75);})).then(function(rs){return rs.every(function(r){return r.iters===370534&&r.checksum===823951201;});})"]]} + + {:id :formula-switch + :tags [:fractal :jvm :formula :interaction] + :doc "Picking a formula frames it at home, moves the panes to engines + that render it (the browser kernels are disabled), and writes it + into the URL hash." + :steps [[:goto "/"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){if(window.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:expect-js "window.fractalApi.formulas().indexOf('tricorn')>=0"] + [:expect-js "(function(){var s=document.getElementById('formula');s.value='tricorn';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(window.fractalApi.fractal().formula==='tricorn'&&p.L&&p.L.iters>0&&window.fractalApi.usable().indexOf(p.L.engine)>=0){clearInterval(t);ok(true);}},100);})"] + [:expect-js "window.fractalApi.usable().indexOf('browser-zig')<0 && document.querySelector('#engL option[value=\"browser-zig\"]').disabled && new URLSearchParams(location.hash.slice(1)).get('formula')==='tricorn'"]]} + + {:id :julia-at-cursor + :tags [:fractal :jvm :formula :interaction] + :doc "j opens the Julia set of the point under the cursor; the deep + link carries k, and reloading it lands on the same Julia set." + :steps [[:goto "/#cx=-0.45&cy=-0.5&scale=0.0055&iter=256&formula=burning-ship"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var p=window.fractalApi.panes();if(window.fractalApi.ready()&&p.L&&p.L.iters>0){clearInterval(t);ok(true);}},100);})"] + [:expect-js "(function(){var c=document.getElementById('view'),r=c.getBoundingClientRect();c.dispatchEvent(new PointerEvent('pointermove',{pointerId:3,clientX:r.left+r.width*0.25,clientY:r.top+r.height*0.5,bubbles:true}));window.dispatchEvent(new KeyboardEvent('keydown',{key:'j'}));return true;})()"] + [:expect-js "new Promise(function(ok){var t=setInterval(function(){var f=window.fractalApi.fractal(),p=window.fractalApi.panes();if(f.julia&&f.formula==='burning-ship'&&p.L&&p.L.iters>0&&!p.L.error){clearInterval(t);ok(true);}},100);})"] + [:expect-js "(function(){var q=new URLSearchParams(location.hash.slice(1));return q.get('formula')==='burning-ship'&&q.has('jr')&&q.has('ji')&&Number(q.get('jr'))<-0.45;})()"]]}]}} diff --git a/docs/examples/fractal/jvm/deps.edn b/docs/examples/fractal/jvm/deps.edn index 7f479538c..f6b693017 100644 --- a/docs/examples/fractal/jvm/deps.edn +++ b/docs/examples/fractal/jvm/deps.edn @@ -8,7 +8,8 @@ ;; point at local checkouts of raster and clojure-cpp. {:paths ["src" "resources"] :deps {org.replikativ/raster {:mvn/version "0.2.1720"} - uncomplicate/clojurecuda {:mvn/version "0.33.0"}} + uncomplicate/clojurecuda {:mvn/version "0.33.0"} + org.clojure/data.json {:mvn/version "2.5.1"}} :aliases {:jvm {:jvm-opts ["--enable-preview" "--add-modules=jdk.incubator.vector" diff --git a/docs/examples/fractal/jvm/resources/fractal/escape.cu b/docs/examples/fractal/jvm/resources/fractal/escape.cu deleted file mode 100644 index abc671c5b..000000000 --- a/docs/examples/fractal/jvm/resources/fractal/escape.cu +++ /dev/null @@ -1,31 +0,0 @@ -// Escape-time kernels for fractal.renderer.cuda, compiled at run time by NVRTC. -// One thread per pixel. Same operation order as fractal.renderer.oracle/escape; -// compiled with --fmad=false so the fp64 kernel rounds exactly like the JVM. -// Writes the EscapeField only (count and |z|^2); the palette runs on the host. - -#define ESCAPE_BODY(REAL) \ - int x = blockIdx.x * blockDim.x + threadIdx.x; \ - int y = blockIdx.y * blockDim.y + threadIdx.y; \ - if (x >= w || y >= h) return; \ - REAL cr = (REAL)cx + ((REAL)x - (REAL)w / (REAL)2.0) * (REAL)scale; \ - REAL ci = (REAL)cy + ((REAL)y - (REAL)h / (REAL)2.0) * (REAL)scale; \ - REAL zr = 0, zi = 0; \ - int n = 0; \ - while (n < limit && zr * zr + zi * zi <= (REAL)4.0) { \ - REAL next = zr * zr - zi * zi + cr; \ - zi = ((REAL)2.0 * zr) * zi + ci; \ - zr = next; \ - n++; \ - } \ - counts[y * w + x] = n; \ - mag2[y * w + x] = (double)(zr * zr + zi * zi); - -extern "C" __global__ void escape_f64(int* counts, double* mag2, int w, int h, - double cx, double cy, double scale, int limit) { - ESCAPE_BODY(double) -} - -extern "C" __global__ void escape_f32(int* counts, double* mag2, int w, int h, - double cx, double cy, double scale, int limit) { - ESCAPE_BODY(float) -} diff --git a/docs/examples/fractal/jvm/src/fractal/bench.clj b/docs/examples/fractal/jvm/src/fractal/bench.clj index 4d2e07440..04ed09857 100644 --- a/docs/examples/fractal/jvm/src/fractal/bench.clj +++ b/docs/examples/fractal/jvm/src/fractal/bench.clj @@ -25,9 +25,11 @@ :mismatch (golden/verdict v (:iters best) (palette/checksum (:rgba best)))}))) (defn runs? - "Whether renderer rr runs view v: heavy views only on :heavy? renderers." + "Whether renderer rr runs view v: it renders the view's formula and seed, + and heavy views only on :heavy? renderers." [rr v] - (or (not (:heavy v)) (:heavy? (r/describe rr)))) + (and (r/renders? rr (golden/view->tile v)) + (or (not (:heavy v)) (boolean (:heavy? (r/describe rr)))))) (defn rows [renderers views opts] (vec (for [rr renderers v views :when (runs? rr v)] diff --git a/docs/examples/fractal/jvm/src/fractal/formula.clj b/docs/examples/fractal/jvm/src/fractal/formula.clj new file mode 100644 index 000000000..80ed0606c --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/formula.clj @@ -0,0 +1,113 @@ +(ns fractal.formula + "Domain: the escape-time formulas, as data. A Formula is one step of the + orbit, z <- f(z, c), written in a small expression language over the + doubles zr zi cr ci: + + (+ a b) (- a b) (* a b) (abs a), number literals, and symbols bound by + the step's :let pairs (evaluated in order, from the OLD z). + + The step is data, not code, so every backend compiles the SAME expression + (the oracle to a Clojure fn, raster to a typed deftm, CUDA to C) and they + agree to the last bit: each operation is written once, in one order, and + fp64 without FMA rounds it the same way everywhere. Adding a formula is + adding one entry to `registry`; the mutation suite derives its mutants from + the expression. + + A Seed says where the orbit starts: :c-plane (z0 = 0, c = pixel, the + Mandelbrot-style set) or :z-plane (z0 = pixel, c = k, the Julia set of the + same formula). Seed and formula are independent axes.") + +(def ops + "Operator symbol of the expression language -> the Clojure form it lowers to." + {'+ 'clojure.core/+ '- 'clojure.core/- '* 'clojure.core/* 'abs 'Math/abs}) + +(def arity {'+ 2 '- 2 '* 2 'abs 1}) + +(def inputs '#{zr zi cr ci}) + +(def registry + "Formula id -> Formula, in display order. :home is the c-plane view that + frames the set; :julia-k is a k whose Julia set is worth opening on." + (array-map + "mandelbrot" + {:id "mandelbrot" :what "z^2 + c" + :step {:let [] :zr '(+ (- (* zr zr) (* zi zi)) cr) :zi '(+ (* (* 2.0 zr) zi) ci)} + :home {:cx -0.5 :cy 0.0 :scale 0.0068359375} + :julia-k [-0.8 0.156]} + + "burning-ship" + {:id "burning-ship" :what "(|Re z| + i|Im z|)^2 + c" + :step {:let [] :zr '(+ (- (* zr zr) (* zi zi)) cr) :zi '(+ (* (* 2.0 (abs zr)) (abs zi)) ci)} + :home {:cx -0.45 :cy -0.5 :scale 0.0055} + :julia-k [-1.2 0.0]} + + "tricorn" + {:id "tricorn" :what "conj(z)^2 + c" + :step {:let [] :zr '(+ (- (* zr zr) (* zi zi)) cr) :zi '(+ (* (* -2.0 zr) zi) ci)} + :home {:cx -0.3 :cy 0.0 :scale 0.0068359375} + :julia-k [-0.4 0.3]} + + "multibrot3" + {:id "multibrot3" :what "z^3 + c" + :step {:let '[zr2 (* zr zr) zi2 (* zi zi)] + :zr '(+ (* zr (- zr2 (* 3.0 zi2))) cr) + :zi '(+ (* zi (- (* 3.0 zr2) zi2)) ci)} + :home {:cx 0.0 :cy 0.0 :scale 0.0055} + :julia-k [-0.2 0.75]})) + +(def default-id "mandelbrot") + +(defn known? [id] (contains? registry id)) + +(defn ids [] (vec (keys registry))) + +(defn formula + "The Formula for id; throws ex-info for an unknown id." + [id] + (or (get registry id) + (throw (ex-info (str "unknown formula " id) {:formula id :known (ids)})))) + +;; --- the expression language ------------------------------------------------ + +(defn expr-problems + "Why expr is not an expression over the symbols in scope, or nil." + [scope expr] + (cond + (number? expr) nil + (symbol? expr) (when-not (contains? scope expr) [(str "unbound symbol " expr)]) + (seq? expr) (let [[op & args] expr] + (cond + (not (contains? ops op)) [(str "unknown operator " op)] + (not= (arity op) (count args)) [(str op " takes " (arity op) " arguments")] + :else (not-empty (vec (mapcat #(expr-problems scope %) args))))) + :else [(str "not an expression: " (pr-str expr))])) + +(defn step-problems + "Why step is not a well-formed step, or nil." + [{lets :let :keys [zr zi] :as step}] + (if-not (and (map? step) (vector? lets) (even? (count lets)) zr zi) + ["a step is {:let [sym expr ...] :zr expr :zi expr}"] + (let [[scope ps] (reduce (fn [[scope ps] [s e]] + [(conj scope s) + (cond-> (into ps (expr-problems scope e)) + (not (symbol? s)) (conj (str "let binds a non-symbol " (pr-str s))) + (contains? inputs s) (conj (str "let shadows input " s)))]) + [inputs []] + (partition 2 lets))] + (not-empty (into ps (concat (expr-problems scope zr) (expr-problems scope zi))))))) + +(defn lower + "The expression as a Clojure form, operators qualified, literals double." + [expr] + (cond + (seq? expr) (let [[op & args] expr] (apply list (ops op) (map lower args))) + (number? expr) (double expr) + :else expr)) + +(defn step-form + "The step as a Clojure form over zr zi cr ci: the :let bindings, then the + call `(~@k zr' zi')`. k is the head of that call: `[vector]` builds a pair + in a fn body, `[recur (inc n)]` continues a loop." + [{lets :let :keys [zr zi]} k] + (list 'clojure.core/let (vec (mapcat (fn [[s e]] [s (lower e)]) (partition 2 lets))) + (concat k [(lower zr) (lower zi)]))) diff --git a/docs/examples/fractal/jvm/src/fractal/http.clj b/docs/examples/fractal/jvm/src/fractal/http.clj index af6f7d592..57e4626bd 100644 --- a/docs/examples/fractal/jvm/src/fractal/http.clj +++ b/docs/examples/fractal/jvm/src/fractal/http.clj @@ -6,7 +6,9 @@ GET /tile?cx=&cy=&scale=&w=&h=&iter=&engine=" (:require [clojure.string :as str] [fractal.renderer :as r] - [fractal.tile :as tile]) + [fractal.tile :as tile] + [clojure.data.json :as json] + [fractal.formula :as formula]) (:import (com.sun.net.httpserver HttpExchange HttpHandler HttpServer) (java.net InetSocketAddress URLDecoder) (java.util.concurrent Executors))) @@ -20,15 +22,26 @@ [(subs pair 0 i) (URLDecoder/decode (subs pair (inc i)) "UTF-8")])))) (.split raw "&")))) -(defn- json-strings [xs] (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) +(defn catalog + "The formula catalog the page shows: id, formula text, home view, a Julia k." + [] + (for [{:keys [id what home julia-k]} (vals formula/registry)] + {:id id :what what :home home :julia julia-k})) (defn engines-json "Engines, the inexact ones among them (not held to golden.edn, e.g. fp32), - and the unavailable ones." + the unavailable ones, the formula catalog, and what each engine renders." [registry unavailable] - (str "{\"engines\":" (json-strings (keys registry)) - ",\"inexact\":" (json-strings (for [[k rr] registry :when (not (:parity? (r/describe rr)))] k)) - ",\"unavailable\":" (json-strings (map :id unavailable)) "}")) + (json/write-str + {:engines (keys registry) + :inexact (for [[k rr] registry :when (not (:parity? (r/describe rr)))] k) + :unavailable (map :id unavailable) + :formulas (catalog) + :renders (into {} (for [[k rr] registry] + [k {:formulas (filter (r/formulas rr) (formula/ids)) + :julia (boolean (:julia? (r/describe rr)))}]))})) + +(defn- text [status s] {:status status :headers {} :body (.getBytes (str s) "UTF-8")}) (defn respond "Pure: a request {:path :query} to a response {:status :headers :body bytes}." @@ -38,19 +51,22 @@ "/engines" {:status 200 :headers {"content-type" "application/json"} :body (.getBytes ^String (engines-json registry unavailable) "UTF-8")} "/tile" (let [t (tile/parse-query query) - rid (get query "engine" (first (keys registry)))] - (if-let [rr (get registry rid)] - (let [{:keys [ms iters rgba]} (r/frame rr t)] - {:status 200 - :headers {"content-type" "application/octet-stream" - "cache-control" "no-store" - "x-render-ms" (format "%.3f" ms) - "x-iters" (str iters)} - :body rgba}) - {:status 400 :headers {} :body (.getBytes (str "unknown engine " rid) "UTF-8")})) - {:status 404 :headers {} :body (.getBytes "not found\n" "UTF-8")}) + rid (get query "engine" (first (keys registry))) + rr (get registry rid)] + (cond + (nil? rr) (text 400 (str "unknown engine " rid)) + (not (r/renders? rr t)) (text 400 (str rid " does not render " (:formula t) + (when (:julia t) " (Julia)"))) + :else (let [{:keys [ms iters rgba]} (r/frame rr t)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body rgba}))) + (text 404 "not found\n")) (catch Exception e - {:status 400 :headers {} :body (.getBytes (str "tile error: " (.getMessage e)) "UTF-8")}))) + (text 400 (str "tile error: " (.getMessage e)))))) (defn- send! [^HttpExchange ex {:keys [status headers ^bytes body]}] (let [h (.getResponseHeaders ex)] diff --git a/docs/examples/fractal/jvm/src/fractal/renderer.clj b/docs/examples/fractal/jvm/src/fractal/renderer.clj index f2381fb56..dadc01343 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer.clj @@ -5,15 +5,31 @@ palette is applied once, above the port, so every renderer that computes the same escape field paints the same bytes." (:require [fractal.palette :as palette] - [fractal.tile :as tile])) + [fractal.tile :as tile] + [fractal.formula :as formula])) (defprotocol IRenderer (id [r] "Engine name as the page and the bench show it.") - (describe [r] "{:parity? bool :heavy? bool :what str}. :parity? false for - renderers whose arithmetic differs (fp32), excluded from golden - checks; :heavy? true when billion-iteration views are practical.") + (describe [r] "{:parity? bool :heavy? bool :what str :formulas #{id} :julia? bool}. + :parity? false for renderers whose arithmetic differs (fp32), + excluded from golden checks; :heavy? true when billion-iteration + views are practical; :formulas the fractal.formula ids it + renders (absent: Mandelbrot only); :julia? whether it renders + the z-plane seed.") (escape! [r t field] "Fill the EscapeField `field` for Tile t.")) +(defn formulas + "The formula ids r renders. A renderer that names none is Mandelbrot only, + so a new formula costs no adapter that does not opt in." + [r] + (or (:formulas (describe r)) #{formula/default-id})) + +(defn renders? + "Whether r can render Tile t: its formula, and its seed." + [r t] + (and (contains? (formulas r) (:formula t formula/default-id)) + (or (nil? (:julia t)) (boolean (:julia? (describe r)))))) + (defn frame "Render Tile t on r: {:iters :ms :rgba}. :ms covers the escape computation only; painting is shared work and timed apart as :paint-ms." diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj index 585006963..e434a782e 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj @@ -1,17 +1,18 @@ (ns fractal.renderer.cuda "CUDA renderers through uncomplicate/clojurecuda: one GPU thread per pixel. - escape.cu is compiled at start-up by NVRTC straight to a CUBIN for this GPU - (SASS, no PTX JIT, so a toolkit newer than the driver still loads). A CUDA - context is current per thread, so the adapter owns ONE worker thread that - creates the context and runs every GPU call; callers never see CUDA thread - affinity. fp64 with --fmad=false rounds like the JVM (golden parity); fp32 - trades exactness for the GPU's single-precision throughput." - (:require [clojure.java.io :as io] - [uncomplicate.clojurecuda.core :as cu] + The program is generated from the formula registry (fractal.renderer.cuda-source) + and compiled at start-up by NVRTC straight to a CUBIN for this GPU (SASS, no + PTX JIT, so a toolkit newer than the driver still loads). A CUDA context is + current per thread, so the adapter owns ONE worker thread that creates the + context and runs every GPU call; callers never see CUDA thread affinity. fp64 + with --fmad=false rounds like the JVM (golden parity); fp32 trades exactness + for the GPU's single-precision throughput." + (:require [uncomplicate.clojurecuda.core :as cu] [uncomplicate.clojure-cpp :as cpp] [uncomplicate.commons.core :as cc] [fractal.renderer :as r] - [fractal.tile :as tile]) + [fractal.tile :as tile] + [fractal.renderer.cuda-source :as src]) (:import (java.util.concurrent Callable ExecutorService Executors ThreadFactory) (org.bytedeco.cuda.global nvrtc) (org.bytedeco.cuda.nvrtc _nvrtcProgram) @@ -43,30 +44,32 @@ (str "-arch=sm_" compute-capability-major compute-capability-minor))) (defn- open-gpu - "On the worker: context, compiled module and kernel functions." + "On the worker: context, the generated program compiled, and every kernel + function keyed [formula-id seed precision]." [] (cu/init) (let [dev (cu/device 0) ctx (cu/context dev)] (cu/current-context! ctx) - (let [prog (cu/compile! (cu/program (slurp (io/resource "fractal/escape.cu"))) - ["--fmad=false" (sm-arch dev)]) + (let [prog (cu/compile! (cu/program (src/program)) ["--fmad=false" (sm-arch dev)]) m (cu/module (cubin prog))] {:ctx ctx :device (:name (cc/info dev)) - :kernels {:f64 (cu/function m "escape_f64") :f32 (cu/function m "escape_f32")}}))) + :kernels (update-vals (src/kernels) #(cu/function m %))}))) (defn- escape-on-gpu! "On the worker: launch kernel for tile t and copy the field back." - [kernel {:keys [cx cy scale w h iter] :as t} {:keys [^ints counts ^doubles mag2]}] - (let [n (tile/pixels t)] + [kernel {:keys [cx cy scale w h iter julia] :as t} {:keys [^ints counts ^doubles mag2]}] + (let [n (tile/pixels t) + [kr ki] (or julia [0.0 0.0])] (cc/with-release [d-counts (cu/mem-alloc-driver (* 4 n)) d-mag2 (cu/mem-alloc-driver (* 8 n)) h-counts (cpp/int-pointer n) h-mag2 (cpp/double-pointer n)] (cu/launch! kernel (cu/grid-2d w h 16 16) (cu/parameters d-counts d-mag2 (int w) (int h) - (double cx) (double cy) (double scale) (int iter))) + (double cx) (double cy) (double scale) (int iter) + (double kr) (double ki))) (cu/synchronize!) (cu/memcpy-host! d-counts h-counts) (cu/memcpy-host! d-mag2 h-mag2) @@ -77,10 +80,12 @@ (defrecord Cuda [rid precision gpu worker] r/IRenderer (id [_] rid) - (describe [_] {:parity? (= precision :f64) :heavy? true + (describe [_] {:parity? (= precision :f64) :heavy? true :julia? true + :formulas (set (map first (keys (:kernels gpu)))) :what (str "CUDA " (name precision) " on " (:device gpu))}) (escape! [_ t field] - (on-gpu worker #(escape-on-gpu! (get-in gpu [:kernels precision]) t field)))) + (let [kernel (get-in gpu [:kernels [(:formula t) (if (:julia t) :z-plane :c-plane) precision]])] + (on-gpu worker #(escape-on-gpu! kernel t field))))) (def ^:private shared (delay (let [worker (Executors/newSingleThreadExecutor diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/cuda_source.clj b/docs/examples/fractal/jvm/src/fractal/renderer/cuda_source.clj new file mode 100644 index 000000000..c9d629265 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/cuda_source.clj @@ -0,0 +1,77 @@ +(ns fractal.renderer.cuda-source + "Promote: the CUDA C source of the escape kernels, generated from the + formula registry. Pure, so the program text is testable without a GPU. + + One kernel per formula, seed and precision. Each operation of a step + expression becomes one parenthesised C operation in the same order, and + NVRTC compiles with --fmad=false, so the fp64 kernels round exactly like + the JVM. Kernels write the EscapeField only; the palette runs on the host." + (:require [clojure.string :as str] + [fractal.formula :as formula])) + +(def precisions {:f64 "double" :f32 "float"}) + +(def seeds + "Seed -> [cr ci zr0 zi0] as C expressions over px, py, kr, ki." + {:c-plane ["px" "py" "(REAL)0.0" "(REAL)0.0"] + :z-plane ["(REAL)kr" "(REAL)ki" "px" "py"]}) + +(defn c-expr + "A step expression as C over REAL." + [expr] + (cond + (number? expr) (str "(REAL)(" (double expr) ")") + (symbol? expr) (str expr) + :else (let [[op a b] expr] + (if (= 'abs op) + (str "fabs(" (c-expr a) ")") + (str "(" (c-expr a) " " op " " (c-expr b) ")"))))) + +(defn kernel-name + "The C identifier of the kernel for formula id, seed and precision." + [id seed precision] + (str/replace (str "escape_" id "_" (name seed) "_" (name precision)) "-" "_")) + +(defn kernel + "The C source of one kernel." + [{:keys [id step]} seed precision] + (let [[cr ci zr0 zi0] (seeds seed) + lets (for [[s e] (partition 2 (:let step))] (str " REAL " s " = " (c-expr e) ";\n"))] + (str "extern \"C\" __global__ void " (kernel-name id seed precision) + "(int* counts, double* mag2, int w, int h, double cx, double cy, double scale,\n" + " int limit, double kr, double ki) {\n" + " typedef " (precisions precision) " REAL;\n" + " int x = blockIdx.x * blockDim.x + threadIdx.x;\n" + " int y = blockIdx.y * blockDim.y + threadIdx.y;\n" + " if (x >= w || y >= h) return;\n" + " REAL px = (REAL)cx + ((REAL)x - (REAL)w / (REAL)2.0) * (REAL)scale;\n" + " REAL py = (REAL)cy + ((REAL)y - (REAL)h / (REAL)2.0) * (REAL)scale;\n" + " REAL cr = " cr ", ci = " ci ";\n" + " REAL zr = " zr0 ", zi = " zi0 ";\n" + " int n = 0;\n" + " while (n < limit && zr * zr + zi * zi <= (REAL)4.0) {\n" + (apply str lets) + " REAL nzr = " (c-expr (:zr step)) ";\n" + " REAL nzi = " (c-expr (:zi step)) ";\n" + " zr = nzr;\n" + " zi = nzi;\n" + " n++;\n" + " }\n" + " counts[y * w + x] = n;\n" + " mag2[y * w + x] = (double)(zr * zr + zi * zi);\n" + "}\n"))) + +(defn kernels + "[formula-id seed precision] -> kernel name, for every kernel of registry." + ([] (kernels formula/registry)) + ([registry] + (into {} (for [id (keys registry) seed (keys seeds) p (keys precisions)] + [[id seed p] (kernel-name id seed p)])))) + +(defn program + "The whole CUDA program for registry." + ([] (program formula/registry)) + ([registry] + (str "// Generated by fractal.renderer.cuda-source from fractal.formula/registry.\n\n" + (str/join "\n" (for [f (vals registry) seed (keys seeds) p (keys precisions)] + (kernel f seed p)))))) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj index f07293e8c..43d20c182 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj @@ -2,37 +2,52 @@ "The reference renderer: the escape-time definition in plain Clojure, one pixel at a time, no library. Every other renderer is tested against it." (:require [fractal.renderer :as r] - [fractal.tile :as tile])) + [fractal.tile :as tile] + [fractal.formula :as formula])) -(defn escape - "[n mag2] for the point (cr, ci): iterate z <- z^2 + c from 0 while - n < limit and |z|^2 <= 4. The operation order is the kernels' exactly." - [cr ci limit] - (loop [n 0 zr 0.0 zi 0.0] +(defn orbit + "[n mag2] for one orbit: iterate z <- (step zr zi cr ci) from z0 while + n < limit and |z|^2 <= 4. Every kernel uses this test in this order." + [step zr zi cr ci limit] + (loop [n 0 zr (double zr) zi (double zi)] (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) - (recur (inc n) (+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)) + (let [[zr' zi'] (step zr zi cr ci)] (recur (inc n) (double zr') (double zi'))) [n (+ (* zr zr) (* zi zi))]))) +(defn step-fn + "Compile a Formula's step (data) to a fn of zr zi cr ci returning [zr' zi']." + [f] + (eval (list 'fn ['^double zr '^double zi '^double cr '^double ci] + (formula/step-form (:step f) ['clojure.core/vector])))) + +(defn steps + "Formula id -> compiled step, for every formula in registry." + ([] (steps formula/registry)) + ([registry] (into {} (map (fn [[id f]] [id (step-fn f)])) registry))) + (defn fill! - "Fill field for tile t with the escape rule esc (default `escape`); returns field." - ([t field] (fill! escape t field)) - ([esc t {:keys [^ints counts ^doubles mag2] :as field}] - (let [{:keys [w h iter]} t] - (dotimes [y h] - (dotimes [x w] - (let [[cr ci] (tile/point t x y) - [n m] (esc cr ci iter) - p (+ (* y w) x)] - (aset counts p (int n)) - (aset mag2 p (double m))))) - field))) + "Fill field for tile t: orbit rule orb, step fn from step-of (formula id -> + step); returns field. The reference passes `orbit` and `steps`; the + mutation suite passes deliberately broken ones." + [orb step-of t {:keys [^ints counts ^doubles mag2] :as field}] + (let [{:keys [w h iter formula]} t + step (step-of formula)] + (dotimes [y h] + (dotimes [x w] + (let [[zr zi cr ci] (tile/seed t x y) + [n m] (orb step zr zi cr ci iter) + p (+ (* y w) x)] + (aset counts p (int n)) + (aset mag2 p (double m))))) + field)) ;; esc is the escape rule as a value: the reference passes `escape`, the ;; mutation suite passes deliberately broken rules. -(defrecord Oracle [rid esc] +(defrecord Oracle [rid orb step-of] r/IRenderer (id [_] rid) - (describe [_] {:parity? true :heavy? false :what "plain Clojure on the JVM, one thread, the reference"}) - (escape! [_ t field] (fill! esc t field))) + (describe [_] {:parity? true :heavy? false :julia? true :formulas (set (keys step-of)) + :what "plain Clojure on the JVM, one thread, the reference"}) + (escape! [_ t field] (fill! orb step-of t field))) -(defn renderer [] (->Oracle "clojure-jvm" escape)) +(defn renderer [] (->Oracle "clojure-jvm" orbit (steps))) diff --git a/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj index 28af6540a..9758e7181 100644 --- a/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj @@ -8,37 +8,75 @@ (:require [raster.core :refer [deftm]] [raster.arrays :refer [aget aset]] [raster.par.runtime :as rt] - [fractal.renderer :as r])) - -(deftm escape-rows! - [counts :- (Array int), mag2 :- (Array double), y0 :- Long, y1 :- Long, w :- Long, h :- Long, - cx :- Double, cy :- Double, scale :- Double, limit :- Long] :- Long - (let [hw (/ (double w) 2.0) - hh (/ (double h) 2.0)] - (loop [y y0] - (if (< y y1) - (let [ci (+ cy (* (- (double y) hh) scale))] - (loop [x 0] - (when (< x w) - (let [cr (+ cx (* (- (double x) hw) scale)) - p (+ (* y w) x)] - (loop [n 0 zr 0.0 zi 0.0] - (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) - (recur (inc n) (+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)) - (do (aset counts p (unchecked-int n)) - (aset mag2 p (+ (* zr zr) (* zi zi)))))) - (recur (inc x))))) - (recur (inc y))) - y1)))) + [fractal.renderer :as r] + [clojure.walk :as walk] + [fractal.formula :as formula])) + +(def seeds + "Seed -> [cr ci zr0 zi0] as forms over the pixel point (px, py) and k (kr, ki)." + {:c-plane '[px py 0.0 0.0] + :z-plane '[kr ki px py]}) + +(def ^:private kernel-template + "The escape loop over rows [y0, y1) of a tile, with holes for the seed (CR CI + ZR0 ZI0) and the formula's step (STEP). One test, one order, every formula." + '(raster.core/deftm NAME + [counts :- (Array int), mag2 :- (Array double), y0 :- Long, y1 :- Long, w :- Long, h :- Long, + cx :- Double, cy :- Double, scale :- Double, limit :- Long, kr :- Double, ki :- Double] :- Long + (let [hw (/ (double w) 2.0) + hh (/ (double h) 2.0)] + (loop [y y0] + (if (< y y1) + (let [py (+ cy (* (- (double y) hh) scale))] + (loop [x 0] + (when (< x w) + (let [px (+ cx (* (- (double x) hw) scale)) + p (+ (* y w) x) + cr CR + ci CI] + (loop [n 0 zr ZR0 zi ZI0] + (if (and (< n limit) (<= (+ (* zr zr) (* zi zi)) 4.0)) + STEP + (do (aset counts p (unchecked-int n)) + (aset mag2 p (+ (* zr zr) (* zi zi)))))) + (recur (inc x))))) + (recur (inc y))) + y1))))) + +(defn kernel-name [formula-id seed] (symbol (str "escape-" formula-id "-" (name seed) "!"))) + +(defn kernel-form + "The deftm source of the escape loop for Formula f and seed: pure Promote, + formula data in, raster source out." + [f seed] + (let [[cr ci zr0 zi0] (seeds seed)] + (walk/postwalk-replace + {'NAME (kernel-name (:id f) seed) 'CR cr 'CI ci 'ZR0 zr0 'ZI0 zi0 + 'STEP (formula/step-form (:step f) '[recur (inc n)])} + kernel-template))) + +(defmacro ^:private defkernels + "One deftm per formula in the registry and per seed." + [] + (cons 'do (for [f (vals formula/registry) seed (keys seeds)] (kernel-form f seed)))) + +(defkernels) + +(def kernels + "[formula-id seed] -> compiled escape loop." + (into {} (for [id (formula/ids) seed (keys seeds)] + [[id seed] @(ns-resolve 'fractal.renderer.raster (kernel-name id seed))]))) (def ^:private row-block 4) -(defn- escape-tile! [threads {:keys [cx cy scale w h iter]} {:keys [counts mag2]}] - (let [blocks (long (Math/ceil (/ (double h) row-block))) +(defn- escape-tile! [threads {:keys [cx cy scale w h iter formula julia]} {:keys [counts mag2]}] + (let [kernel (kernels [formula (if julia :z-plane :c-plane)]) + [kr ki] (or julia [0.0 0.0]) + blocks (long (Math/ceil (/ (double h) row-block))) work (fn [lo hi] (doseq [b (range lo hi)] (let [y0 (* row-block b)] - (escape-rows! counts mag2 y0 (min h (+ y0 row-block)) w h cx cy scale iter))))] + (kernel counts mag2 y0 (min h (+ y0 row-block)) w h cx cy scale iter kr ki))))] (binding [rt/*par-min-size* 1 rt/*par-threads* threads] (rt/parallel-for! blocks work)))) @@ -46,7 +84,8 @@ (defrecord Raster [rid threads] r/IRenderer (id [_] rid) - (describe [_] {:parity? true :heavy? (nil? threads) + (describe [_] {:parity? true :heavy? (nil? threads) :julia? true + :formulas (set (map first (keys kernels))) :what (str "raster deftm escape loop, " (if threads (str threads " thread") "all cores"))}) (escape! [_ t field] (escape-tile! threads t field))) diff --git a/docs/examples/fractal/jvm/src/fractal/tile.clj b/docs/examples/fractal/jvm/src/fractal/tile.clj index 78612e1dd..20855a108 100644 --- a/docs/examples/fractal/jvm/src/fractal/tile.clj +++ b/docs/examples/fractal/jvm/src/fractal/tile.clj @@ -2,7 +2,8 @@ "Domain values. A Tile is the question: {:cx :cy :scale :w :h :iter}, pixel (x, y) being the point (cx + (x - w/2) scale, cy + (y - h/2) scale). An EscapeField is the answer every renderer gives: per pixel the escape count - (ints) and |z|^2 at escape (doubles). Pure functions only.") + (ints) and |z|^2 at escape (doubles). Pure functions only." + (:require [fractal.formula :as formula])) (def max-pixels (* 1024 1024)) @@ -12,23 +13,29 @@ (defn problems "Why m is not a Tile, or nil when it is one." - [{:keys [cx cy scale w h iter]}] + [{:keys [cx cy scale w h iter formula julia]}] (not-empty (cond-> [] (not (every? finite? [cx cy scale])) (conj "cx, cy and scale must be finite numbers") (and (finite? scale) (not (pos? scale))) (conj "scale must be positive") (not (and (int? w) (int? h) (pos? w) (pos? h))) (conj "w and h must be positive integers") (and (int? w) (int? h) (> (* w h) max-pixels)) (conj (str "w*h must be at most " max-pixels)) - (not (and (int? iter) (pos? iter))) (conj "iter must be a positive integer")))) + (not (and (int? iter) (pos? iter))) (conj "iter must be a positive integer") + (and formula (not (formula/known? formula))) (conj (str "unknown formula " formula)) + (and julia (not (and (sequential? julia) (= 2 (count julia)) (every? finite? julia)))) + (conj "julia must be [kr ki], two finite numbers")))) (defn tile - "Validate and normalise m into a Tile; throws ex-info listing the problems." + "Validate and normalise m into a Tile; throws ex-info listing the problems. + :formula defaults to Mandelbrot; :julia is present only for the z-plane seed." [m] (if-let [p (problems m)] (throw (ex-info (first p) {:tile m :problems p})) - (-> (select-keys m [:cx :cy :scale :w :h :iter]) - (update :cx double) (update :cy double) (update :scale double) - (update :iter #(min % max-iter))))) + (cond-> (-> (select-keys m [:cx :cy :scale :w :h :iter]) + (update :cx double) (update :cy double) (update :scale double) + (update :iter #(min % max-iter)) + (assoc :formula (or (:formula m) formula/default-id))) + (:julia m) (assoc :julia (mapv double (:julia m)))))) (defn pixels [{:keys [w h]}] (* w h)) @@ -38,6 +45,15 @@ [(+ cx (* (- (double x) (/ (double w) 2.0)) scale)) (+ cy (* (- (double y) (/ (double h) 2.0)) scale))]) +(defn seed + "Where pixel (x, y)'s orbit starts, [zr zi cr ci]: z0 = 0 and c = the + point on the c-plane; z0 = the point and c = k on the z-plane (Julia)." + [t x y] + (let [[pr pi] (point t x y)] + (if-let [[kr ki] (:julia t)] + [pr pi kr ki] + [0.0 0.0 pr pi]))) + (defn escape-field "An empty EscapeField sized for tile t." [t] @@ -49,9 +65,12 @@ (areduce counts i acc 0 (+ acc (aget counts i)))) (defn parse-query - "Promote a map of query strings into a Tile, defaults filled in." + "Promote a map of query strings into a Tile, defaults filled in. jr and ji + together select the z-plane (Julia) seed with k = jr + i ji." [q] (let [num #(if-let [s (get q %1)] (Double/parseDouble s) %2) lng #(if-let [s (get q %1)] (Long/parseLong s) %2)] - (tile {:cx (num "cx" -0.6) :cy (num "cy" 0.0) :scale (num "scale" 0.004) - :w (lng "w" 640) :h (lng "h" 480) :iter (lng "iter" 256)}))) + (tile (cond-> {:cx (num "cx" -0.6) :cy (num "cy" 0.0) :scale (num "scale" 0.004) + :w (lng "w" 640) :h (lng "h" 480) :iter (lng "iter" 256) + :formula (get q "formula" formula/default-id)} + (and (get q "jr") (get q "ji")) (assoc :julia [(num "jr" 0.0) (num "ji" 0.0)]))))) diff --git a/docs/examples/fractal/jvm/test/fractal/formula_test.clj b/docs/examples/fractal/jvm/test/fractal/formula_test.clj new file mode 100644 index 000000000..298c33d75 --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/formula_test.clj @@ -0,0 +1,119 @@ +(ns fractal.formula-test + "The formula domain. Every registry entry is well formed and has golden + views on both seeds; every backend compiles every entry; the compiled step + is the expression, bit for bit; and the maths a formula promises holds on + the oracle (exact symmetries, the unit-disk Julia set)." + (:require [clojure.test :refer [deftest is testing]] + [clojure.test.check :as tc] + [clojure.test.check.generators :as gen] + [clojure.test.check.properties :as prop] + [fractal.formula :as formula] + [fractal.golden :as golden] + [fractal.renderer.cuda-source :as cuda-src] + [fractal.renderer.oracle :as oracle] + [fractal.renderer.raster :as raster])) + +(defn- check [n p] + (let [res (tc/quick-check n p)] + (is (:pass? res) (pr-str (select-keys res [:fail :shrunk :seed]))))) + +(defn- same-double? [a b] (= (Double/doubleToLongBits a) (Double/doubleToLongBits b))) + +;; --- the registry ----------------------------------------------------------------- + +(deftest every-formula-is-well-formed + (doseq [[id f] formula/registry] + (testing id + (is (= id (:id f))) + (is (nil? (formula/step-problems (:step f)))) + (is (every? number? (map (:home f) [:cx :cy :scale]))) + (is (and (= 2 (count (:julia-k f))) (every? number? (:julia-k f))))))) + +(deftest every-formula-has-golden-views-on-both-seeds + (let [views (golden/load-views) + of? (fn [id julia?] (some #(and (= id (:formula % formula/default-id)) + (= julia? (some? (:julia %)))) + views))] + (doseq [id (formula/ids)] + (is (of? id false) (str id ": no c-plane golden view")) + (is (of? id true) (str id ": no Julia golden view"))))) + +(deftest every-backend-compiles-every-formula + (let [want (set (for [id (formula/ids) seed [:c-plane :z-plane]] [id seed]))] + (is (= want (set (keys raster/kernels)))) + (is (= want (set (map (fn [[id seed _]] [id seed]) (keys (cuda-src/kernels)))))) + (is (= (set (formula/ids)) (set (keys (oracle/steps))))))) + +(deftest the-cuda-program-has-every-kernel + (let [src (cuda-src/program)] + (doseq [k (vals (cuda-src/kernels))] + (is (re-find (re-pattern (str "void " k "\\(")) src) k)) + (is (= (count (re-seq #"\(" src)) (count (re-seq #"\)" src)))))) + +;; --- the expression language ------------------------------------------------------ + +(deftest the-checker-names-what-it-cannot-compile + (is (formula/step-problems {:let [] :zr '(pow zr 2) :zi 'zi}) "unknown operator") + (is (formula/step-problems {:let [] :zr '(+ zr) :zi 'zi}) "arity") + (is (formula/step-problems {:let [] :zr 'w :zi 'zi}) "unbound symbol") + (is (formula/step-problems {:let '[zr (* zr zr)] :zr 'zr :zi 'zi}) "shadowed input") + (is (formula/step-problems {:zr 'zr :zi 'zi}) "no :let") + (is (nil? (formula/step-problems {:let '[a (* zr zr)] :zr '(+ a cr) :zi '(abs ci)})))) + +(def gen-expr + "Expressions of the language over the four inputs." + (gen/recursive-gen + (fn [inner] + (gen/one-of [(gen/fmap (fn [[op a b]] (list op a b)) (gen/tuple (gen/elements '[+ - *]) inner inner)) + (gen/fmap #(list 'abs %) inner)])) + (gen/one-of [(gen/elements '[zr zi cr ci]) + (gen/double* {:min -4.0 :max 4.0 :NaN? false :infinite? false})]))) + +(defn- interp + "An independent reading of the language: evaluate e in env, no compiler." + [env e] + (cond + (number? e) (double e) + (symbol? e) (env e) + :else (let [[op & args] e + [a b] (map #(interp env %) args)] + (case op + (+ a b) - (- a b) * (* a b) abs (Math/abs (double a)))))) + +(def gen-input (gen/double* {:min -3.0 :max 3.0 :NaN? false :infinite? false})) + +(deftest every-generated-expression-is-well-formed + (check 300 (prop/for-all [a gen-expr b gen-expr] + (nil? (formula/step-problems {:let [] :zr a :zi b}))))) + +(deftest the-compiled-step-is-the-expression + (check 200 (prop/for-all [a gen-expr b gen-expr zr gen-input zi gen-input cr gen-input ci gen-input] + (let [env {'zr zr 'zi zi 'cr cr 'ci ci} + [zr' zi'] ((oracle/step-fn {:step {:let [] :zr a :zi b}}) zr zi cr ci)] + (and (same-double? zr' (interp env a)) (same-double? zi' (interp env b))))))) + +;; --- the maths -------------------------------------------------------------------- + +(def symmetries + "Exact symmetries of the c-plane sets: negating a component of z is exact in + IEEE arithmetic, so these hold bit for bit, not just to rounding." + {"mandelbrot" [:conj] "tricorn" [:conj] "multibrot3" [:conj :odd]}) + +(deftest symmetric-formulas-are-exactly-symmetric + (let [steps (oracle/steps)] + (doseq [[id syms] symmetries sym syms] + (testing (str id " " sym) + (check 200 (prop/for-all [cr (gen/double* {:min -2.0 :max 2.0 :NaN? false}) + ci (gen/double* {:min -2.0 :max 2.0 :NaN? false}) + limit (gen/choose 1 400)] + (let [orb #(oracle/orbit (steps id) 0.0 0.0 %1 %2 limit)] + (= (orb cr ci) + (case sym :conj (orb cr (- ci)) :odd (orb (- cr) (- ci))))))))))) + +(deftest julia-of-z2-at-k-zero-is-the-unit-disk + (let [step ((oracle/steps) "mandelbrot")] + (check 300 (prop/for-all [zr gen-input zi gen-input limit (gen/choose 8 300)] + (let [m (+ (* zr zr) (* zi zi)) + [n] (oracle/orbit step zr zi 0.0 0.0 limit)] + (cond (<= m 0.81) (= n limit) + (>= m 1.21) (< n limit) + :else true)))))) diff --git a/docs/examples/fractal/jvm/test/fractal/mutation_test.clj b/docs/examples/fractal/jvm/test/fractal/mutation_test.clj index 11dcf782e..dcbc8dcc1 100644 --- a/docs/examples/fractal/jvm/test/fractal/mutation_test.clj +++ b/docs/examples/fractal/jvm/test/fractal/mutation_test.clj @@ -1,37 +1,59 @@ (ns fractal.mutation-test - "Mutation: the golden views must KILL every plausible mistake in the two - rules everything shares, the escape rule and the palette. Each mutant is a - value fed through the same seams the real rules use (an Oracle's escape fn, - a palette rule map); a mutant the golden views cannot tell apart from the - real rule is a hole in the golden set." + "Mutation: the golden views must KILL every plausible mistake in the rules + everything shares: the orbit (bailout test, iteration limit, count start), + every formula's step, and the palette. Each mutant is a value fed through + the same seams the real rules use (the oracle's orbit fn and step table, a + palette rule map); a mutant the golden views cannot tell apart from the + real rule is a hole in the golden set. + + Step mutants are not listed by hand: they are derived from the step + expression (fractal.formula), so a new formula gets its mutants, and needs + golden views that kill them, the moment it enters the registry." (:require [clojure.test :refer [deftest is testing]] + [fractal.formula :as formula] [fractal.golden :as golden] [fractal.palette :as palette] - [fractal.renderer :as r] [fractal.renderer.oracle :as oracle] [fractal.tile :as tile])) -(defn- escape-with - "An escape rule with its continue test, step and count start swapped in." - [continue? step n0] - (fn [cr ci limit] - (loop [n n0 zr 0.0 zi 0.0] +;; --- mutants ---------------------------------------------------------------------- + +(defn- orbit-with + "An orbit rule with its continue test and count start swapped in." + [continue? n0] + (fn [step zr zi cr ci limit] + (loop [n n0 zr (double zr) zi (double zi)] (if (continue? n limit (+ (* zr zr) (* zi zi))) - (let [[zr' zi'] (step zr zi cr ci)] (recur (inc n) zr' zi')) + (let [[zr' zi'] (step zr zi cr ci)] (recur (inc n) (double zr') (double zi'))) [n (+ (* zr zr) (* zi zi))])))) -(def ^:private kernel-step - (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (+ (* (* 2.0 zr) zi) ci)])) +(def orbit-mutants + {:strict-bailout (orbit-with (fn [n limit m] (and (< n limit) (< m 4.0))) 0) + :bailout-radius-3 (orbit-with (fn [n limit m] (and (< n limit) (<= m 9.0))) 0) + :limit-off-by-one (orbit-with (fn [n limit m] (and (<= n limit) (<= m 4.0))) 0) + :count-from-one (orbit-with (fn [n limit m] (and (< n limit) (<= m 4.0))) 1)}) -(def escape-mutants - {:strict-bailout (escape-with (fn [n limit m] (and (< n limit) (< m 4.0))) kernel-step 0) - :bailout-radius-3 (escape-with (fn [n limit m] (and (< n limit) (<= m 9.0))) kernel-step 0) - :limit-off-by-one (escape-with (fn [n limit m] (and (<= n limit) (<= m 4.0))) kernel-step 0) - :count-from-one (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) kernel-step 1) - :conjugate-step (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) - (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (- ci (* (* 2.0 zr) zi))]) 0) - :burning-ship (escape-with (fn [n limit m] (and (< n limit) (<= m 4.0))) - (fn [zr zi cr ci] [(+ (- (* zr zr) (* zi zi)) cr) (+ (Math/abs (* 2.0 zr zi)) ci)]) 0)}) +(defn expr-mutants + "Every expression one mistake away from e: + and - swapped, an abs dropped, + a literal negated or off by one, a symbol replaced by another in scope." + [scope e] + (cond + (number? e) [(- e) (+ e 1.0)] + (symbol? e) (for [s (sort scope) :when (not= s e)] s) + :else (let [[op & args] e] + (concat + (case op + [(apply list '- args)] - [(apply list '+ args)] abs [(first args)] []) + (for [i (range (count args)) m (expr-mutants scope (nth args i))] + (apply list op (assoc (vec args) i m))))))) + +(defn step-mutants + "Every step one mistake away from step, in :zr, :zi or a :let expression." + [{lets :let :as step}] + (let [scope (into formula/inputs (take-nth 2 lets))] + (concat + (for [k [:zr :zi] m (expr-mutants scope (k step))] (assoc step k m)) + (for [i (range 1 (count lets) 2) m (expr-mutants formula/inputs (nth lets i))] + (assoc-in step [:let i] m))))) (def palette-mutants {:speed-0.026 (assoc palette/rule :speed 0.026) @@ -39,27 +61,42 @@ :channels-swapped (assoc palette/rule :offsets [(/ 1.0 3.0) 0.0 (/ 2.0 3.0)]) :offsets-quarter (assoc palette/rule :offsets [0.0 0.25 0.5])}) -(defn- golden-views [] (remove :heavy (golden/load-views))) +;; --- the judge -------------------------------------------------------------------- + +(def ^:private views + "The golden views tests can afford, cheapest first so a kill comes early." + (delay (sort-by (fn [{:keys [w h iter]}] (* w h iter)) (remove :heavy (golden/load-views))))) + +(def ^:private base-steps (delay (oracle/steps))) (defn- killed? - "True when at least one golden view rejects the frames produced by escape - rule esc and palette rule pal." - [esc pal] + "True when a golden view of formula id (every view when id is nil) rejects + the frames of orbit orb, step table step-of and palette rule pal." + [id orb step-of pal] (boolean (some (fn [v] (let [t (golden/view->tile v) field (tile/escape-field t)] - (r/escape! (oracle/->Oracle "mutant" esc) t field) + (oracle/fill! orb step-of t field) (golden/verdict v (tile/total-iters field) (palette/checksum (palette/paint pal field t))))) - (golden-views)))) + (cond->> @views id (filter #(= id (:formula (golden/view->tile %)))))))) + +;; --- the suite -------------------------------------------------------------------- (deftest the-real-rules-survive - (is (not (killed? oracle/escape palette/rule)))) + (is (not (killed? nil oracle/orbit @base-steps palette/rule)))) + +(deftest every-orbit-mutant-is-killed + (doseq [[k orb] orbit-mutants] + (testing (name k) (is (killed? nil orb @base-steps palette/rule))))) -(deftest every-escape-mutant-is-killed - (doseq [[k esc] escape-mutants] - (testing (name k) (is (killed? esc palette/rule))))) +(deftest every-step-mutant-is-killed + (doseq [[id f] formula/registry + m (step-mutants (:step f))] + (testing (str id " " (pr-str m)) + (is (killed? id oracle/orbit (assoc @base-steps id (oracle/step-fn (assoc f :step m))) + palette/rule))))) (deftest every-palette-mutant-is-killed (doseq [[k pal] palette-mutants] - (testing (name k) (is (killed? oracle/escape pal))))) + (testing (name k) (is (killed? nil oracle/orbit @base-steps pal))))) diff --git a/docs/examples/fractal/jvm/test/fractal/property_test.clj b/docs/examples/fractal/jvm/test/fractal/property_test.clj index 958a0f06e..a6904033c 100644 --- a/docs/examples/fractal/jvm/test/fractal/property_test.clj +++ b/docs/examples/fractal/jvm/test/fractal/property_test.clj @@ -2,7 +2,7 @@ "Property-based: on generated tiles, every exact renderer computes the oracle's EscapeField bit for bit; the palette and the tile domain keep their laws; the HTTP boundary answers the frame it renders." - (:require [clojure.test :refer [deftest is]] + (:require [clojure.test :refer [deftest is testing]] [clojure.test.check :as tc] [clojure.test.check.generators :as gen] [clojure.test.check.properties :as prop] @@ -11,17 +11,25 @@ [fractal.palette :as palette] [fractal.renderer :as r] [fractal.renderer.oracle :as oracle] - [fractal.tile :as tile])) + [fractal.tile :as tile] + [fractal.formula :as formula])) (def gen-tile - "Small tiles around the set, from the full view down to deep zoom." - (gen/let [cx (gen/double* {:min -2.0 :max 0.6 :NaN? false :infinite? false}) + "Small tiles of every formula on both seeds, from the full view down to + deep zoom." + (gen/let [formula (gen/elements (formula/ids)) + julia (gen/one-of [(gen/return nil) + (gen/tuple (gen/double* {:min -1.5 :max 1.5 :NaN? false}) + (gen/double* {:min -1.5 :max 1.5 :NaN? false}))]) + cx (gen/double* {:min -2.0 :max 0.6 :NaN? false :infinite? false}) cy (gen/double* {:min -1.2 :max 1.2 :NaN? false :infinite? false}) zoom (gen/choose 2 40) w (gen/choose 1 24) h (gen/choose 1 24) iter (gen/choose 1 600)] - (tile/tile {:cx cx :cy cy :scale (/ 3.0 (Math/pow 2.0 (/ zoom 2.0))) :w w :h h :iter iter}))) + (tile/tile (cond-> {:cx cx :cy cy :scale (/ 3.0 (Math/pow 2.0 (/ zoom 2.0))) :w w :h h :iter iter + :formula formula} + julia (assoc :julia julia))))) (def exact-renderers (delay (filterv #(and (:parity? (r/describe %)) (not= "clojure-jvm" (r/id %))) @@ -40,8 +48,16 @@ (deftest every-exact-renderer-equals-the-oracle (let [o (oracle/renderer)] (doseq [rr @exact-renderers] - (check 60 (prop/for-all [t gen-tile] - (same-field? (field-of o t) (field-of rr t))))))) + (testing (r/id rr) + (check 80 (prop/for-all [t gen-tile] + (or (not (r/renders? rr t)) + (same-field? (field-of o t) (field-of rr t))))))))) + +(deftest the-winners-render-every-formula-on-both-seeds + (doseq [rr @exact-renderers + :when (#{"raster-jvm" "raster-jvm-1t" "cuda-f64"} (r/id rr))] + (is (= (set (formula/ids)) (r/formulas rr)) (r/id rr)) + (is (:julia? (r/describe rr)) (r/id rr)))) (deftest escape-counts-are-bounded (check 200 (prop/for-all [t gen-tile] @@ -68,18 +84,44 @@ (deftest tile-validation (check 300 (prop/for-all [w gen/small-integer h gen/small-integer - iter gen/small-integer scale gen/double] - (let [m {:cx 0.0 :cy 0.0 :scale scale :w w :h h :iter iter} - ok? (and (pos? w) (pos? h) (pos? iter) (Double/isFinite scale) (pos? scale))] + iter gen/small-integer scale gen/double + formula (gen/one-of [(gen/elements (formula/ids)) gen/string-alphanumeric]) + julia (gen/one-of [(gen/return nil) (gen/vector gen/double 0 3)])] + (let [m (cond-> {:cx 0.0 :cy 0.0 :scale scale :w w :h h :iter iter :formula formula} + julia (assoc :julia julia)) + ok? (and (pos? w) (pos? h) (pos? iter) (Double/isFinite scale) (pos? scale) + (formula/known? formula) + (or (nil? julia) (and (= 2 (count julia)) (every? #(Double/isFinite %) julia))))] (= ok? (nil? (tile/problems m))))))) +(defn- query-of + "The /tile query string map that asks for tile t on engine rid." + [rid t] + (cond-> (into {"engine" rid} + (map (fn [k] [(name k) (str (get t k))])) + [:cx :cy :scale :w :h :iter :formula]) + (:julia t) (assoc "jr" (str (first (:julia t))) "ji" (str (second (:julia t)))))) + (deftest http-tile-answers-the-frame (let [rr (oracle/renderer) registry (r/registry [rr])] (check 40 (prop/for-all [t gen-tile] - (let [q (into {"engine" "clojure-jvm"} (map (fn [[k v]] [(name k) (str v)])) t) - {:keys [status headers body]} (http/respond registry [] {:path "/tile" :query q}) + (let [{:keys [status headers body]} (http/respond registry [] {:path "/tile" :query (query-of "clojure-jvm" t)}) f (r/frame rr t)] (and (= 200 status) (= (str (:iters f)) (get headers "x-iters")) (java.util.Arrays/equals ^bytes body ^bytes (:rgba f)))))))) + +;; A renderer that names no formulas: the port's default, Mandelbrot on the +;; c-plane only. It paints nothing, so a 200 for any other tile is a leak. +(def ^:private mandelbrot-only + (reify r/IRenderer + (id [_] "mandelbrot-only") + (describe [_] {:parity? true :heavy? false :what "stub"}) + (escape! [_ _ field] field))) + +(deftest http-refuses-what-an-engine-does-not-render + (let [registry (r/registry [mandelbrot-only])] + (check 100 (prop/for-all [t gen-tile] + (let [{:keys [status]} (http/respond registry [] {:path "/tile" :query (query-of "mandelbrot-only" t)})] + (= status (if (and (= "mandelbrot" (:formula t)) (nil? (:julia t))) 200 400))))))) diff --git a/docs/examples/fractal/server.clj b/docs/examples/fractal/server.clj index 97be03f6c..86b26c2aa 100644 --- a/docs/examples/fractal/server.clj +++ b/docs/examples/fractal/server.clj @@ -61,16 +61,20 @@ (defn tile-response [q] (try (let [t (fractal.cljw.tile/parse-query q) - engine (get q "engine" "zig-jit")] - (if-let [rr (get registry engine)] - (let [{:keys [ms iters rgba]} (fractal.cljw.renderer/frame rr t)] - {:status 200 - :headers {"content-type" "application/octet-stream" - "cache-control" "no-store" - "x-render-ms" (format "%.3f" ms) - "x-iters" (str iters)} - :body rgba}) - {:status 400 :body (str "engine must be one of " (vec (keys registry)))})) + engine (get q "engine" "zig-jit") + want {:formula (get q "formula") :julia (when (get q "jr") [(get q "jr") (get q "ji")])} + rr (get registry engine)] + (cond + (nil? rr) {:status 400 :body (str "engine must be one of " (vec (keys registry)))} + (not (fractal.cljw.renderer/renders? rr want)) + {:status 400 :body (str engine " renders Mandelbrot on the c-plane only")} + :else (let [{:keys [ms iters rgba]} (fractal.cljw.renderer/frame rr t)] + {:status 200 + :headers {"content-type" "application/octet-stream" + "cache-control" "no-store" + "x-render-ms" (format "%.3f" ms) + "x-iters" (str iters)} + :body rgba}))) (catch Throwable e {:status 400 :body (str "tile error: " (.getMessage e))}))) @@ -78,9 +82,9 @@ (defn handler [req] (let [q (fractal.cljw.tile/query-map (:query-string req))] (case (:uri req) - "/" {:status 200 - :headers {"content-type" "text/html; charset=utf-8"} - :body (slurp (str public "/index.html"))} + ("/" "/index.html") {:status 200 + :headers {"content-type" "text/html; charset=utf-8"} + :body (slurp (str public "/index.html"))} "/kernel.wasm" (if-let [bytes (get kernel-bytes (get q "k" "zig"))] {:status 200 :headers {"content-type" "application/wasm" "cache-control" "no-cache"} diff --git a/docs/examples/fractal/web/public/js/main.js b/docs/examples/fractal/web/public/js/main.js index a5c2f1087..1eca4c42a 100644 --- a/docs/examples/fractal/web/public/js/main.js +++ b/docs/examples/fractal/web/public/js/main.js @@ -448,626 +448,649 @@ shadow$provide[12]=function(I,Ka,A){function v(){if(typeof __REACT_DEVTOOLS_GLOB Copyright The Closure Library Authors. SPDX-License-Identifier: Apache-2.0 */ -'use strict';var g,aa=this||self;function r(a){var b=typeof a;return b!="object"?b:a?Array.isArray(a)?"array":b:"null"}function ba(a){var b=typeof a;return b=="object"&&a!=null||b=="function"}function ca(a){return Object.prototype.hasOwnProperty.call(a,da)&&a[da]||(a[da]=++ea)}var da="closure_uid_"+(Math.random()*1E9>>>0),ea=0;function fa(a,b,c){return a.call.apply(a.bind,arguments)} -function ha(a,b,c){if(!a)throw Error();if(arguments.length>2){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}function ja(a,b,c){ja=Function.prototype.bind&&Function.prototype.bind.toString().indexOf("native code")!=-1?fa:ha;return ja.apply(null,arguments)} -function ka(a,b){function c(){}c.prototype=b.prototype;a.Uc=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.Ea=function(d,e,f){for(var h=Array(arguments.length-2),k=2;k>>16&65535)*d+c*(b>>>16&65535)<<16>>>0)|0};function mc(a){a=lc(a|0,-862048943);return lc(a<<15|a>>>-15,461845907)}function nc(a,b){a=(a|0)^(b|0);return lc(a<<13|a>>>-13,5)+-430675100|0}function oc(a,b){a=(a|0)^b;a=lc(a^a>>>16,-2048144789);a=lc(a^a>>>13,-1028477387);return a^a>>>16} -function pc(a){a:{var b=1;for(var c=0;;)if(b1024&&(qc={},rc=0);if(a==null)return 0;var b=qc[a];if(typeof b==="number")a=b;else{a:if(a!=null)if(b=a.length,b>0){for(var c=0,d=0;;)if(c>2)}function vc(a,b){return b instanceof a}function wc(a,b,c,d,e){this.dc=a;this.name=b;this.eb=c;this.Db=d;this.Ta=e;this.v=2154168321;this.R=4096}g=wc.prototype;g.toString=function(){return this.eb};g.Z=function(a,b){return b instanceof wc?this.eb===b.eb:!1}; -g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}};g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return B?B(a,this):xc.call(null,a,this)};g.j=function(a,b){return yc?yc(a,this,b):xc.call(null,a,this,b)};g.fa=function(){return this.Ta}; -g.ha=function(a,b){return new wc(this.dc,this.name,this.eb,this.Db,b)};g.ba=function(){var a=this.Db;return a!=null?a:this.Db=a=uc(pc(this.name),sc(this.dc))};g.W=function(a,b){return Mb(b,this.eb)};var zc=function zc(a){switch(arguments.length){case 1:return zc.i(arguments[0]);case 2:return zc.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}; -zc.i=function(a){for(;;){if(a instanceof wc)return a;if(typeof a==="string"){var b=a.indexOf("/");return b<1?zc.j(null,a):zc.j(a.substring(0,b),a.substring(b+1,a.length))}if(a instanceof D)a=a.za;else throw Error("no conversion to symbol");}};zc.j=function(a,b){var c=a!=null?[Ba(a),"/",Ba(b)].join(""):b;return new wc(a,b,c,null,null)};zc.N=2;function Cc(a){return a!=null?a.R&131072||t===a.je?!0:a.R?!1:w(cc,a):w(cc,a)} -function E(a){if(a==null)return null;if(a!=null&&(a.v&8388608||t===a.Id))return a.ga(null);if(u(xa(a))||typeof a==="string")return a.length===0?null:new G(a,0,null);if(a!=null&&a[Ca]!=null)return a=na(a,Ca).call(a),Dc.i?Dc.i(a):Dc.call(null,a);if(w(Db,a))return Eb(a);throw Error([Ba(a)," is not ISeqable"].join(""));}function H(a){if(a==null)return null;if(a!=null&&(a.v&64||t===a.Hb))return a.qa(null);a=E(a);return a==null?null:Ua(a)} -function Ec(a){return a!=null?a!=null&&(a.v&64||t===a.Hb)?a.ra(null):(a=E(a))?a.ra(null):Fc:Fc}function I(a){return a==null?null:a!=null&&(a.v&128||t===a.Sb)?a.pa():E(Ec(a))}var J=function J(a){switch(arguments.length){case 1:return J.i(arguments[0]);case 2:return J.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=d)return-1;!(c>0)&&c<0&&(c+=d,c=0>c?0:c);for(;;)if(c0?(--d,c=d=0){if(J.j(cd?cd(a,c):dd.call(null,a,c),b))return c;--c}else return-1}function ed(a,b){this.o=a;this.G=b}ed.prototype.na=function(){return this.Ga?0:a};g.Tb=function(){var a=this.ea(null);return a>0?new fd(this,a-1,null):null};g.ba=function(){return Jc(this)};g.Z=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return $c(this.o,b,this.o[this.G],this.G+1)}; -g.ua=function(a,b,c){return $c(this.o,b,c,this.G)};g.qa=function(){return this.o[this.G]};g.ra=function(){return this.G+10?this.G+b0?new fd(this.kc,this.G-1,null):null};g.ea=function(){return this.G+1};g.ba=function(){return Jc(this)}; -g.Z=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd?hd(b,this):id.call(null,b,this)};g.ua=function(a,b,c){return jd?jd(b,c,this):id.call(null,b,c,this)};g.qa=function(){return Ra(this.kc,this.G)};g.ra=function(){return this.G>0?new fd(this.kc,this.G-1,null):Fc};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new fd(this.kc,this.G,b)};g.ka=function(a,b){return P.j?P.j(b,this):P.call(null,b,this)}; -fd.prototype[Ca]=function(){return Hc(this)};function kd(a){return H(I(a))}function ld(a){for(;;){var b=I(a);if(b!=null)a=b;else return H(a)}}Ab._=function(a,b){return a===b};var md=function md(a){switch(arguments.length){case 0:return md.J();case 1:return md.i(arguments[0]);case 2:return md.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.J=function(){return this.P.J?this.P.J():this.P.call(null)};g.i=function(a){return this.P.i?this.P.i(a):this.P.call(null,a)};g.j=function(a,b){return this.P.j?this.P.j(a,b):this.P.call(null,a,b)}; -g.l=function(a,b,c){return this.P.l?this.P.l(a,b,c):this.P.call(null,a,b,c)};g.O=function(a,b,c,d){return this.P.O?this.P.O(a,b,c,d):this.P.call(null,a,b,c,d)};g.$=function(a,b,c,d,e){return this.P.$?this.P.$(a,b,c,d,e):this.P.call(null,a,b,c,d,e)};g.Fa=function(a,b,c,d,e,f){return this.P.Fa?this.P.Fa(a,b,c,d,e,f):this.P.call(null,a,b,c,d,e,f)};g.sb=function(a,b,c,d,e,f,h){return this.P.sb?this.P.sb(a,b,c,d,e,f,h):this.P.call(null,a,b,c,d,e,f,h)}; -g.tb=function(a,b,c,d,e,f,h,k){return this.P.tb?this.P.tb(a,b,c,d,e,f,h,k):this.P.call(null,a,b,c,d,e,f,h,k)};g.ub=function(a,b,c,d,e,f,h,k,l){return this.P.ub?this.P.ub(a,b,c,d,e,f,h,k,l):this.P.call(null,a,b,c,d,e,f,h,k,l)};g.hb=function(a,b,c,d,e,f,h,k,l,m){return this.P.hb?this.P.hb(a,b,c,d,e,f,h,k,l,m):this.P.call(null,a,b,c,d,e,f,h,k,l,m)};g.ib=function(a,b,c,d,e,f,h,k,l,m,n){return this.P.ib?this.P.ib(a,b,c,d,e,f,h,k,l,m,n):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n)}; -g.jb=function(a,b,c,d,e,f,h,k,l,m,n,p){return this.P.jb?this.P.jb(a,b,c,d,e,f,h,k,l,m,n,p):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p)};g.kb=function(a,b,c,d,e,f,h,k,l,m,n,p,q){return this.P.kb?this.P.kb(a,b,c,d,e,f,h,k,l,m,n,p,q):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q)};g.lb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v){return this.P.lb?this.P.lb(a,b,c,d,e,f,h,k,l,m,n,p,q,v):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v)}; -g.mb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A){return this.P.mb?this.P.mb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A)};g.nb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C){return this.P.nb?this.P.nb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C)};g.ob=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F){return this.P.ob?this.P.ob(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F)}; -g.pb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M){return this.P.pb?this.P.pb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M)};g.qb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R){return this.P.qb?this.P.qb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R)}; -g.rb=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V){return this.P.rb?this.P.rb(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V):this.P.call(null,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V)};g.Mc=function(a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V,ia){return vd.Mc?vd.Mc(this.P,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,new G([V,ia],0,null)):vd.call(null,this.P,a,b,c,d,e,f,h,k,l,m,n,p,q,v,A,C,F,M,R,V,ia)};function wd(a,b){return typeof a==="function"?new ud(a,b):a==null?null:tb(a,b)} -function xd(a){var b=a!=null;return(b?a!=null?a.v&131072||t===a.Gd||(a.v?0:w(qb,a)):w(qb,a):b)?rb(a):null}function yd(a){return a==null?null:lb(a)}function zd(a){return a==null?null:mb(a)}var Ad=function Ad(a){switch(arguments.length){case 1:return Ad.i(arguments[0]);case 2:return Ad.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(eb?a:b};ke.u=function(a,b,c){return Ga(ke,ke.j(a,b),c)};ke.K=function(a){var b=H(a),c=I(a);a=H(c);c=I(c);return this.u(b,a,c)};ke.N=2;var le=function le(a){switch(arguments.length){case 1:return le.i(arguments[0]);case 2:return le.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=0?Math.floor(a):Math.ceil(a)}function ne(a){a-=a>>1&1431655765;a=(a&858993459)+(a>>2&858993459);return(a+(a>>4)&252645135)*16843009>>24} -var Ba=function Ba(a,b){if(a==null)return"";if(void 0===b)return[a].join("");for(var d=new ua,e=arguments,f=e.length,h=0;;)if(h20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return B(a,this)};g.j=function(a,b){return yc(a,this,b)}; -g.ba=function(){var a=this.Db;return a!=null?a:this.Db=a=uc(pc(this.name),sc(this.dc))+2654435769|0};g.W=function(a,b){return Mb(b,[":",Ba(this.za)].join(""))};function re(a,b){return a===b?!0:a instanceof D&&b instanceof D?a.za===b.za:!1}function se(a){if(a!=null&&(a.R&4096||t===a.ed))return a.dc;throw Error(["Doesn't support namespace: ",Ba(a)].join(""));} -var te=function te(a){switch(arguments.length){case 1:return te.i(arguments[0]);case 2:return te.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};te.i=function(a){if(a instanceof D)return a;if(a instanceof wc)return new D(se(a),ue.i?ue.i(a):ue.call(null,a),a.eb,null);if(J.j("/",a))return new D(null,a,a,null);if(typeof a==="string"){var b=a.split("/");return b.length===2?new D(b[0],b[1],a,null):new D(null,b[0],a,null)}return null}; -te.j=function(a,b){a=a instanceof D?ue.i?ue.i(a):ue.call(null,a):a instanceof wc?ue.i?ue.i(a):ue.call(null,a):a;b=b instanceof D?ue.i?ue.i(b):ue.call(null,b):b instanceof wc?ue.i?ue.i(b):ue.call(null,b):b;return new D(a,b,[u(a)?[Ba(a),"/"].join(""):null,Ba(b)].join(""),null)};te.N=2;function ve(a,b,c){this.F=a;this.Yb=b;this.s=null;this.I=c;this.v=32374988;this.R=1}g=ve.prototype;g.toString=function(){return hc(this)}; -function we(a){a.Yb!=null&&(a.s=a.Yb.J?a.Yb.J():a.Yb.call(null),a.Yb=null);return a.s}g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){this.ga(null);return this.s==null?null:I(this.s)};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)}; -g.ma=function(){return tb(Fc,this.F)};g.ta=function(a,b){return hd(b,this)};g.ua=function(a,b,c){return jd(b,c,this)};g.qa=function(){this.ga(null);return this.s==null?null:H(this.s)};g.ra=function(){this.ga(null);return this.s!=null?Ec(this.s):Fc};g.ga=function(){we(this);if(this.s==null)return null;for(var a=this.s;;)if(a instanceof ve)a=we(a);else return this.s=a,E(this.s)};g.ha=function(a,b){var c=this;return b===this.F?c:new ve(b,function(){return c.ga(null)},this.I)}; -g.ka=function(a,b){return P(b,this)};ve.prototype[Ca]=function(){return Hc(this)};function xe(a){this.Fc=a;this.end=0;this.v=2;this.R=0}xe.prototype.add=function(a){this.Fc[this.end]=a;return this.end+=1};xe.prototype.da=function(){var a=new ye(this.Fc,0,this.end);this.Fc=null;return a};xe.prototype.ea=function(){return this.end};function ze(a){return new xe(Array(a))}function ye(a,b,c){this.o=a;this.off=b;this.end=c;this.v=524306;this.R=0}g=ye.prototype;g.ea=function(){return this.end-this.off}; -g.Y=function(a,b){return this.o[this.off+b]};g.Da=function(a,b,c){return b>=0&&b1?new Ae(Wb(this.da),this.Pa,null,null):this.Pa==null?null:Eb(this.Pa)};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)}; -g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.qa=function(){return Ra(this.da,0)};g.ra=function(){return La(this.da)>1?new Ae(Wb(this.da),this.Pa,null,null):this.Pa==null?Fc:this.Pa};g.ga=function(){return this};g.Qb=function(){return this.da};g.Za=function(){return this.Pa==null?Fc:this.Pa};g.ha=function(a,b){return b===this.F?this:new Ae(this.da,this.Pa,b,this.I)};g.ka=function(a,b){return P(b,this)};g.lc=function(){return this.Pa==null?null:this.Pa};Ae.prototype[Ca]=function(){return Hc(this)}; -function Be(a,b){return La(a)===0?b:new Ae(a,b,null,null)}function Ce(a,b){a.add(b)}function Ke(a,b){if(ad(b))return N(b);var c=0;for(b=E(b);;)if(b!=null&&c2){p=0;for(var q=Array(arguments.length-2);p2){m=0;for(var n=Array(arguments.length-2);m0)for(var d=0,e=Array(arguments.length-0);d3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q3){C=0;for(var F=Array(arguments.length-3);C3){v=0;for(var A=Array(arguments.length-3);v0){l=0;for(var m=Array(arguments.length-0);l3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q3){C=0;for(var F=Array(arguments.length-3);C3){v=0;for(var A=Array(arguments.length-3);v3){F=0;for(var M=Array(arguments.length-3);F3){A=0;for(var C=Array(arguments.length-3);A0){l=0;for(var m=Array(arguments.length-0);l3){v=0;for(var A=Array(arguments.length-3);v3){p=0;for(var q=Array(arguments.length-3);p3){q=0;for(var v=Array(arguments.length-3);q3){n=0;for(var p=Array(arguments.length-3);n2){q=0;for(var v=Array(arguments.length-2);q2){n=0;for(var p=Array(arguments.length-2);n0){var c=E(b);return c?P(H(c),zf(a-1,Ec(c))):null}return null},null)} -function Af(a,b){if(b!=null&&(b.R&262144||t===b.fe)){var c=a>0?b.Wa(null,Math.ceil(a)):E(b);return u(c)?c:Fc}return new ve(null,function(){a:{var d=a;for(var e=b;;)if(e=E(e),d>0&&e)--d,e=Ec(e);else{d=e;break a}d=void 0}return d},null)}function Bf(a){return yf.l(function(b){return b},a,Af(2,a))}function Cf(a,b){var c=E(b);for(a=E(Af(a,b));;)if(a)c=I(c),a=I(a);else return c}function Df(a,b,c,d){this.F=a;this.count=b;this.U=c;this.next=d;this.I=null;this.v=32374988;this.R=262145}g=Df.prototype; -g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,this.count)}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){return this.next==null?this.count>1?this.next=new Df(null,this.count-1,this.U,null):-1===this.count?this:null:this.next}; -g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){if(this.count===-1)for(var c=b.j?b.j(this.U,this.U):b.call(null,this.U,this.U);;){if(Pc(c))return y(c);c=b.j?b.j(c,this.U):b.call(null,c,this.U)}else for(a=1,c=this.U;;)if(a1?this.next=new Df(null,this.count-1,this.U,null):-1===this.count?this:Fc:this.next};g.ga=function(){return this}; -g.ha=function(a,b){return b===this.F?this:new Df(b,this.count,this.U,this.next)};g.ka=function(a,b){return P(b,this)};g.Wa=function(a,b){if(this.count===-1)return this;a=this.count-b;return a>0?new Df(null,a,this.U,null):null};function Ef(a){return a>0?new Df(null,a,null,null):Fc}function Ff(a,b){return Ue(Me,Ve(yf,a,b))} -function Gf(a){return function(b){return function(){function c(h,k){return u(a.i?a.i(k):a.call(null,k))?b.j?b.j(h,k):b.call(null,h,k):h}function d(h){return b.i?b.i(h):b.call(null,h)}function e(){return b.J?b.J():b.call(null)}var f=null;f=function(h,k){switch(arguments.length){case 0:return e.call(this);case 1:return d.call(this,h);case 2:return c.call(this,h,k)}throw Error("Invalid arity: "+arguments.length);};f.J=e;f.i=d;f.j=c;return f}()}} -function Hf(a,b){return new ve(null,function(){var c=E(b);if(c){if(Rd(c)){for(var d=Yb(c),e=N(d),f=ze(e),h=0;;)if(h>>5<<5}function Xf(a,b,c){for(;;){if(b===0)return c;var d=Uf(a);d.o[0]=c;c=d;b-=5}}var Yf=function Yf(a,b,c,d){var f=Vf(c),h=a.B-1>>>b&31;5===b?f.o[h]=d:(c=c.o[h],c!=null?(b-=5,a=Yf.O?Yf.O(a,b,c,d):Yf.call(null,a,b,c,d)):a=Xf(null,b-5,d),f.o[h]=a);return f};function Zf(a,b){throw Error(["No item ",Ba(a)," in vector of length ",Ba(b)].join(""));} -function $f(a,b){if(b>=Wf(a))return a.tail;var c=a.root;for(a=a.shift;;)if(a>0){var d=a-5;c=c.o[b>>>a&31];a=d}else return c.o}function ag(a,b){return 0<=b&&b>>b&31;b-=5;c=c.o[k];a=bg.$?bg.$(a,b,c,d,e):bg.call(null,a,b,c,d,e);h.o[k]=a}return h},cg=function cg(a,b,c){var e=a.B-2>>>b&31;if(b>5){b-=5;var f=c.o[e];a=cg.l?cg.l(a,b,f):cg.call(null,a,b,f);if(a==null&&e===0)return null;c=Vf(c);c.o[e]=a;return c}if(e===0)return null;c=Vf(c);c.o[e]=null;return c};function dg(a,b,c,d,e,f){this.G=a;this.Ea=b;this.o=c;this.sa=d;this.start=e;this.end=f} -dg.prototype.na=function(){return this.G0?this.Y(null,this.B-1):null}; -g.xb=function(){if(this.B===0)throw Error("Can't pop empty vector");if(1===this.B)return tb(nd,this.F);if(10?new fd(this,this.B-1,null):null}; -g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){if(b instanceof X)if(this.B===N(b))for(a=this.Ca(null),b=b.Ca(null);;){if(a.na()){var c=a.next(),d=b.next();if(J.j(c,d))continue;else return!1}else return!0;break}else return!1;else return gd(this,b)};g.Eb=function(){return new mg(this.B,this.shift,ng.i?ng.i(this.root):ng.call(null,this.root),og.i?og.i(this.tail):og.call(null,this.tail))};g.ma=function(){return tb(nd,this.F)};g.ta=function(a,b){return gg(this,b,0,this.B)}; -g.ua=function(a,b,c){a=0;for(var d=c;;)if(a0)b-=5,a=a.o[0];else{a=a.o;break a}a=void 0}return pg?pg(this,a,0,0):qg.call(null,this,a,0,0)};g.ha=function(a,b){return b===this.F?this:new X(b,this.B,this.shift,this.root,this.tail,this.I)}; -g.ka=function(a,b){if(this.B-Wf(this)<32){a=this.tail.length;for(var c=Array(a+1),d=0;;)if(d>>5>1<20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){if(typeof a==="number")return this.Y(null,a);throw Error("Key must be integer");}; -g.Wa=function(a,b){if(ba?e:a;return zg.$?zg.$(b,c,d,a,null):zg.call(null,b,c,d,a,null)};g.Ca=function(){return this.sa!=null&&t===this.sa.Kc?eg(this.sa,this.start,this.end):new df(this)};g.fa=function(){return this.F};g.ea=function(){return this.end-this.start}; -g.wb=function(){return this.start===this.end?null:Ra(this.sa,this.end-1)};g.xb=function(){if(this.start===this.end)throw Error("Can't pop empty vector");var a=this.F,b=this.sa,c=this.start,d=this.end-1;return zg.$?zg.$(a,b,c,d,null):zg.call(null,a,b,c,d,null)};g.Tb=function(){return this.start!==this.end?new fd(this,this.end-this.start-1,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(nd,this.F)}; -g.ta=function(a,b){return this.sa!=null&&t===this.sa.Kc?gg(this.sa,b,this.start,this.end):Wc(this,b)};g.ua=function(a,b,c){return this.sa!=null&&t===this.sa.Kc?hg(this.sa,b,c,this.start,this.end):Xc(this,b,c)};g.Ia=function(a,b,c){if(typeof b==="number")return this.yb(null,b,c);throw Error("Subvec's key for assoc must be a number.");};g.Va=function(a,b){return Xd(b)?0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.Y(null,a)};g.j=function(a,b){return this.Da(null,a,b)};yg.prototype[Ca]=function(){return Hc(this)}; -function zg(a,b,c,d,e){for(;;)if(b instanceof yg)c=b.start+c,d=b.start+d,b=b.sa;else{if(!Qd(b))throw Error("v must satisfy IVector");if(c<0||dN(b))throw Error("Index out of bounds");return new yg(a,b,c,d,e)}}var Ag=function Ag(a){switch(arguments.length){case 2:return Ag.j(arguments[0],arguments[1]);case 3:return Ag.l(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};Ag.j=function(a,b){return Ag.l(a,b,N(a))}; -Ag.l=function(a,b,c){return zg(null,a,b|0,c|0,null)};Ag.N=3;function Bg(a,b){return a===b.ia?b:new Tf(a,Da(b.o))}function ng(a){return new Tf({},Da(a.o))}function og(a){var b=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];Sd(a,0,b,0,a.length);return b} -var Cg=function Cg(a,b,c,d){c=Bg(a.root.ia,c);var f=a.B-1>>>b&31;if(b===5)a=d;else{var h=c.o[f];h!=null?(b-=5,a=Cg.O?Cg.O(a,b,h,d):Cg.call(null,a,b,h,d)):a=Xf(a.root.ia,b-5,d)}c.o[f]=a;return c};function mg(a,b,c,d){this.B=a;this.shift=b;this.root=c;this.tail=d;this.R=88;this.v=275}g=mg.prototype; -g.Jb=function(a,b){if(this.root.ia){if(this.B-Wf(this)<32)this.tail[this.B&31]=b;else{a=new Tf(this.root.ia,this.tail);var c=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];c[0]=b;this.tail=c;this.B>>>5>1<>>f&31;f=k(f-5,h.o[l]);h.o[l]=f}return h}(a.shift,a.root);a.root=d}return a}if(b===a.B)return a.Jb(null,c);throw Error(["Index ",Ba(b)," out of bounds for TransientVector of length",Ba(a.B)].join(""));}throw Error("assoc! after persistent!");}g.ea=function(){if(this.root.ia)return this.B;throw Error("count after persistent!");}; -g.Y=function(a,b){if(this.root.ia)return ag(this,b)[b&31];throw Error("nth after persistent!");};g.Da=function(a,b,c){return 0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};function Eg(a,b){this.Lb=a;this.ec=b} -Eg.prototype.na=function(){var a=this.Lb!=null&&E(this.Lb);return a?a:(a=this.ec!=null)?this.ec.na():a};Eg.prototype.next=function(){if(this.Lb!=null){var a=H(this.Lb);this.Lb=I(this.Lb);return a}if(this.ec!=null&&this.ec.na())return this.ec.next();throw Error("No such element");};Eg.prototype.remove=function(){return Error("Unsupported operation")};function Fg(a,b,c,d){this.F=a;this.Ha=b;this.Ka=c;this.I=d;this.v=31850700;this.R=0}g=Fg.prototype;g.toString=function(){return hc(this)}; -g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){var a=I(this.Ha);return a?new Fg(this.F,a,this.Ka,null):this.Ka!=null?new Fg(this.F,this.Ka,null,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)}; -g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(Fc,this.F)};g.qa=function(){return H(this.Ha)};g.ra=function(){var a=I(this.Ha);return a?new Fg(this.F,a,this.Ka,null):this.Ka==null?this.ma(null):new Fg(this.F,this.Ka,null,null)};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new Fg(b,this.Ha,this.Ka,this.I)};g.ka=function(a,b){return P(b,this)};Fg.prototype[Ca]=function(){return Hc(this)}; -function Gg(a,b,c,d,e){this.F=a;this.count=b;this.Ha=c;this.Ka=d;this.I=e;this.R=139264;this.v=31858766}g=Gg.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,this.count.i?this.count.i(this):this.count.call(null,this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.Ca=function(){return new Eg(this.Ha,dc(this.Ka))};g.fa=function(){return this.F};g.ea=function(){return this.count};g.wb=function(){return H(this.Ha)}; -g.xb=function(){if(u(this.Ha)){var a=I(this.Ha);return a?new Gg(this.F,this.count-1,a,this.Ka,null):new Gg(this.F,this.count-1,E(this.Ka),nd,null)}return this};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return tb(Hg,this.F)};g.qa=function(){return H(this.Ha)};g.ra=function(){return Ec(E(this))};g.ga=function(){var a=E(this.Ka),b=this.Ha;return u(u(b)?b:a)?new Fg(null,this.Ha,E(a),null):null}; -g.ha=function(a,b){return b===this.F?this:new Gg(b,this.count,this.Ha,this.Ka,this.I)};g.ka=function(a,b){u(this.Ha)?(a=this.Ka,b=new Gg(this.F,this.count+1,this.Ha,md.j(u(a)?a:nd,b),null)):b=new Gg(this.F,this.count+1,md.j(this.Ha,b),nd,null);return b};var Hg=new Gg(null,0,null,nd,Kc);Gg.prototype[Ca]=function(){return Hc(this)};function Ig(){this.v=2097152;this.R=0}Ig.prototype.Z=function(){return!1};var Jg=new Ig; -function Kg(a,b){return Vd(Od(b)&&!Pd(b)?N(a)===N(b)?(a!=null?a.v&1048576||t===a.Bd||(a.v?0:w(yb,a)):w(yb,a))?ee(function(c,d,e){return J.j(yc(b,d,Jg),e)?!0:new Oc},!0,a):ef(function(c){return J.j(yc(b,H(c),Jg),kd(c))},a):null:null)}function Lg(a,b,c,d){this.G=0;this.$d=a;this.Xc=b;this.Rd=c;this.kd=d}Lg.prototype.na=function(){var a=this.G20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.Y(null,a)};g.j=function(a,b){return this.Da(null,a,b)}; -function tg(a){return a!=null?a.v&2048||t===a.le?!0:!1:!1}function Qg(a,b,c){this.o=a;this.G=b;this.Ta=c;this.v=32374990;this.R=262144}g=Qg.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.Ta};g.pa=function(){return this.G=0){a=this.o.length;var c=a-2;if(c===0)return this.ma(null);c=Array(c);for(var d=0,e=0;;){if(d>=a)return new Z(this.F,this.B-1,c,null);J.j(b,this.o[d])?d+=2:(c[e]=this.o[d],c[e+1]=this.o[d+1],e+=2,d+=2)}}else return this}; -g.Ia=function(a,b,c){a=Pg(this.o,b);if(a===-1){if(this.B20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)}; -g.Wa=function(a,b){a=this.ga(null);return a==null?null:ec(a,b)};var W=new Z(null,0,[],Mc),Vg=8;function Xg(a,b){return a===b?!0:re(a,b)?!0:J.j(a,b)}function Yg(a){for(var b=0,c=0;;)if(b=e){if(Xg(c[e],c[h]))return h;h-=2}else return h}(),a[f]=c[e],a[f+1]=c[b+1],b=2+f,e=2+e,f=b);else break;return new Z(null,a.length/2,a,null)}return new Z(null,c.length/2,c,null)}Z.prototype[Ca]=function(){return Hc(this)}; -function Ug(a,b){this.Kb={};this.Ab=a;this.o=b;this.v=259;this.R=56}g=Ug.prototype;g.ea=function(){if(this.Kb)return me(this.Ab);throw Error("count after persistent!");};g.xa=function(a,b){return this.ca(null,b,null)};g.ca=function(a,b,c){if(this.Kb)return a=Pg(this.o,b),a===-1?c:this.o[a+1];throw Error("lookup after persistent!");}; -g.Jb=function(a,b){if(this.Kb){if(tg(b))return this.Ib(null,ug.i?ug.i(b):ug.call(null,b),vg.i?vg.i(b):vg.call(null,b));if(Qd(b))return this.Ib(null,b.i?b.i(0):b.call(null,0),b.i?b.i(1):b.call(null,1));a=E(b);for(b=this;;){var c=H(a);if(u(c))a=I(a),b=Vb(b,ug.i?ug.i(c):ug.call(null,c),vg.i?vg.i(c):vg.call(null,c));else return b}}else throw Error("conj! after persistent!");};g.Ub=function(){if(this.Kb)return this.Kb=!1,new Z(null,me(this.Ab),this.o,null);throw Error("persistent! called twice");}; -g.Ib=function(a,b,c){if(this.Kb){a=Pg(this.o,b);if(a===-1){if(this.Ab+2<=2*Vg)return this.Ab+=2,this.o.push(b),this.o.push(c),this;a=ah.j?ah.j(this.Ab,this.o):ah.call(null,this.Ab,this.o);return Vb(a,b,c)}c!==this.o[a+1]&&(this.o[a+1]=c);return this}throw Error("assoc! after persistent!");};g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}}; -g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.ca(null,a,null)};g.j=function(a,b){return this.ca(null,a,b)};function ah(a,b){for(var c=Sb(Wg),d=0;;)if(d>>a&31);if((this.ja&e)===0)return d;var f=ne(this.ja&e-1);e=this.o[2*f];f=this.o[2*f+1];return e==null?f.bb(a+5,b,c,d):Xg(c,e)?f:d}; -g.Oa=function(a,b,c,d,e,f){var h=1<<(c>>>b&31),k=ne(this.ja&h-1);if((this.ja&h)===0){var l=ne(this.ja);if(2*l=16){k=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];k[c>>>b&31]=nh.Oa(a,b+5,c,d,e,f);for(e=d=0;;)if(d<32)(this.ja>>>d&1)=== -0?d+=1:(k[d]=this.o[e]!=null?nh.Oa(a,b+5,tc(this.o[e]),this.o[e],this.o[e+1],f):this.o[e+1],e+=2,d+=1);else break;return new oh(a,l+1,k)}b=Array(2*(l+4));Sd(this.o,0,b,0,2*k);b[2*k]=d;b[2*k+1]=e;Sd(this.o,2*k,b,2*(k+1),2*(l-k));f.U=!0;a=this.zb(a);a.o=b;a.ja|=h;return a}l=this.o[2*k];h=this.o[2*k+1];if(l==null)return l=h.Oa(a,b+5,c,d,e,f),l===h?this:hh(this,a,2*k+1,l);if(Xg(d,l))return e===h?this:hh(this,a,2*k+1,e);f.U=!0;f=b+5;d=ph?ph(a,f,l,h,c,d,e):qh.call(null,a,f,l,h,c,d,e);e=2*k;k=2*k+1;a=this.zb(a); -a.o[e]=null;a.o[k]=d;return a}; -g.Na=function(a,b,c,d,e){var f=1<<(b>>>a&31),h=ne(this.ja&f-1);if((this.ja&f)===0){var k=ne(this.ja);if(k>=16){h=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];h[b>>>a&31]=nh.Na(a+5,b,c,d,e);for(d=c=0;;)if(c<32)(this.ja>>>c&1)===0?c+=1:(h[c]=this.o[d]!=null?nh.Na(a+5,tc(this.o[d]),this.o[d],this.o[d+1],e):this.o[d+1],d+=2,c+=1);else break;return new oh(null,k+1,h)}a=Array(2*(k+1));Sd(this.o, -0,a,0,2*h);a[2*h]=c;a[2*h+1]=d;Sd(this.o,2*h,a,2*(h+1),2*(k-h));e.U=!0;return new kh(null,this.ja|f,a)}var l=this.o[2*h];f=this.o[2*h+1];if(l==null)return k=f.Na(a+5,b,c,d,e),k===f?this:new kh(null,this.ja,dh(this.o,2*h+1,k));if(Xg(c,l))return d===f?this:new kh(null,this.ja,dh(this.o,2*h+1,d));e.U=!0;e=this.ja;k=this.o;a+=5;a=rh?rh(a,l,f,b,c,d):qh.call(null,a,l,f,b,c,d);c=2*h;h=2*h+1;d=Da(k);d[c]=null;d[h]=a;return new kh(null,e,d)}; -g.Zb=function(a,b,c,d){var e=1<<(b>>>a&31);if((this.ja&e)===0)return d;var f=ne(this.ja&e-1);e=this.o[2*f];f=this.o[2*f+1];return e==null?f.Zb(a+5,b,c,d):Xg(c,e)?new lg(e,f):d}; -g.ac=function(a,b,c){var d=1<<(b>>>a&31);if((this.ja&d)===0)return this;var e=ne(this.ja&d-1),f=this.o[2*e],h=this.o[2*e+1];return f==null?(a=h.ac(a+5,b,c),a===h?this:a!=null?new kh(null,this.ja,dh(this.o,2*e+1,a)):this.ja===d?null:new kh(null,this.ja^d,eh(this.o,e))):Xg(c,f)?this.ja===d?null:new kh(null,this.ja^d,eh(this.o,e)):this};g.Ca=function(){return new jh(this.o)};var nh=new kh(null,0,[]);function sh(a){this.o=a;this.G=0;this.Qa=null} -sh.prototype.na=function(){for(var a=this.o.length;;){if(this.Qa!=null&&this.Qa.na())return!0;if(this.G>>a&31];return e!=null?e.bb(a+5,b,c,d):d};g.Oa=function(a,b,c,d,e,f){var h=c>>>b&31,k=this.o[h];if(k==null)return a=hh(this,a,h,nh.Oa(a,b+5,c,d,e,f)),a.B+=1,a;b=k.Oa(a,b+5,c,d,e,f);return b===k?this:hh(this,a,h,b)}; -g.Na=function(a,b,c,d,e){var f=b>>>a&31,h=this.o[f];if(h==null)return new oh(null,this.B+1,dh(this.o,f,nh.Na(a+5,b,c,d,e)));a=h.Na(a+5,b,c,d,e);return a===h?this:new oh(null,this.B,dh(this.o,f,a))};g.Zb=function(a,b,c,d){var e=this.o[b>>>a&31];return e!=null?e.Zb(a+5,b,c,d):d}; -g.ac=function(a,b,c){var d=b>>>a&31,e=this.o[d];if(e!=null){a=e.ac(a+5,b,c);if(a===e)d=this;else if(a==null)if(this.B<=8)a:{e=this.o;a=e.length;b=Array(2*(this.B-1));c=0;for(var f=1,h=0;;)if(c2*this.B)return b=2*this.B,c=2*this.B+1,a=this.zb(a),a.o[b]=d,a.o[c]=e,f.U=!0,a.B+=1,a;c=this.o.length;b=Array(c+2);Sd(this.o,0,b,0,c);b[c]=d;b[c+1]=e;f.U=!0;d=this.B+1;a===this.ia?(this.o=b,this.B=d,a=this):a=new wh(this.ia,this.Xa,d,b);return a}return this.o[b+1]===e?this:hh(this,a,b+1,e)}return(new kh(a,1<<(this.Xa>>>b&31),[null,this,null,null])).Oa(a,b,c,d,e,f)}; -g.Na=function(a,b,c,d,e){return b===this.Xa?(a=vh(this.o,this.B,c),a===-1?(a=2*this.B,b=Array(a+2),Sd(this.o,0,b,0,a),b[a]=c,b[a+1]=d,e.U=!0,new wh(null,this.Xa,this.B+1,b)):J.j(this.o[a+1],d)?this:new wh(null,this.Xa,this.B,dh(this.o,a+1,d))):(new kh(null,1<<(this.Xa>>>a&31),[null,this])).Na(a,b,c,d,e)};g.Zb=function(a,b,c,d){a=vh(this.o,this.B,c);return a<0?d:new lg(this.o[a],this.o[a+1])}; -g.ac=function(a,b,c){a=vh(this.o,this.B,c);return a===-1?this:this.B===1?null:new wh(null,this.Xa,this.B-1,eh(this.o,me(a)))};g.Ca=function(){return new jh(this.o)};function qh(a){switch(arguments.length){case 6:return rh(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5]);case 7:return ph(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5],arguments[6]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}} -function rh(a,b,c,d,e,f){var h=tc(b);if(h===d)return new wh(null,h,2,[b,c,e,f]);var k=new ch;return nh.Na(a,h,b,c,k).Na(a,d,e,f,k)}function ph(a,b,c,d,e,f,h){var k=tc(c);if(k===e)return new wh(null,k,2,[c,d,f,h]);var l=new ch;return nh.Oa(a,b,k,c,d,l).Oa(a,b,e,f,h,l)}function xh(a,b,c,d,e){this.F=a;this.Ra=b;this.G=c;this.s=d;this.I=e;this.v=32374988;this.R=0}g=xh.prototype;g.toString=function(){return hc(this)}; -g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.fa=function(){return this.F};g.pa=function(){if(this.s==null){var a=this.Ra,b=this.G+2;return yh?yh(a,b,null):mh.call(null,a,b,null)}a=this.Ra;b=this.G;var c=I(this.s);return yh?yh(a,b,c):mh.call(null,a,b,c)}; -g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd(b,this)};g.ua=function(a,b,c){return jd(b,c,this)};g.qa=function(){return this.s==null?new lg(this.Ra[this.G],this.Ra[this.G+1]):H(this.s)}; -g.ra=function(){var a=this,b=a.s==null?function(){var c=a.Ra,d=a.G+2;return yh?yh(c,d,null):mh.call(null,c,d,null)}():function(){var c=a.Ra,d=a.G,e=I(a.s);return yh?yh(c,d,e):mh.call(null,c,d,e)}();return b!=null?b:Fc};g.ga=function(){return this};g.ha=function(a,b){return b===this.F?this:new xh(b,this.Ra,this.G,this.s,this.I)};g.ka=function(a,b){return P(b,this)};xh.prototype[Ca]=function(){return Hc(this)}; -function mh(a){switch(arguments.length){case 1:return lh(arguments[0]);case 3:return yh(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}function lh(a){return yh(a,0,null)}function yh(a,b,c){if(c==null)for(c=a.length;;)if(b0){var a=this.root!=null?this.root.$b():null;return this.Aa?P(new lg(null,this.Ba),a):a}return null}; -g.ha=function(a,b){return b===this.F?this:new Ch(b,this.B,this.root,this.Aa,this.Ba,this.I)};g.ka=function(a,b){if(Qd(b))return this.Ia(null,Ra(b,0),Ra(b,1));a=this;for(b=E(b);;){if(b==null)return a;var c=H(b);if(Qd(c))a=cb(a,Ra(c,0),Ra(c,1)),b=I(b);else throw Error("conj on a map takes map entries or seqables of map entries");}}; -g.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+z.i(arguments.length-1));}};g.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Wg=new Ch(null,0,null,!1,null,Mc); -function Eh(a,b){for(var c=a.length,d=0,e=Sb(Wg);;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Gh=function Gh(a){for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return this.xa(null,a)};g.j=function(a,b){return this.ca(null,a,b)};var Ph=new Nh(null,W,Mc); -function Qh(a){for(var b=a.length,c=Sb(Ph),d=0;;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};g.i=function(a){return Za(this.Bb,a,Td)===Td?null:a};g.j=function(a,b){return Za(this.Bb,a,Td)===Td?b:a}; -function Rh(a){if(Md(a))return wd(a,null);a=E(a);if(a==null)return Ph;if(a instanceof G&&a.G===0)return Qh(a.o);for(var b=Sb(Ph);;)if(a!=null){var c=I(a);b=Tb(b,Ua(a));a=c}else return Ub(b)}function Sh(a){return function e(c,d){return new ve(null,function(){var f;a:{var h=c;for(f=d;;){var k=h;h=S(k,0,null);if(k=E(k))if(Yd(f,h)){h=Ec(k);continue}else{f=P(h,e(Ec(k),md.j(f,h)));break a}else{f=null;break a}break}f=void 0}return f},null)}(a,Ph)} -function Th(a){for(var b=nd;;)if(I(a))b=md.j(b,H(a)),a=I(a);else return E(b)}function ue(a){if(a!=null&&(a.R&4096||t===a.ed))return a.name;if(typeof a==="string")return a;throw Error(["Doesn't support name: ",Ba(a)].join(""));} -var Uh=function Uh(a){switch(arguments.length){case 2:return Uh.j(arguments[0],arguments[1]);case 3:return Uh.l(arguments[0],arguments[1],arguments[2]);default:for(var c=[],d=arguments.length,e=0;;)if(e(a.i?a.i(c):a.call(null,c))?b:c}; -Uh.u=function(a,b,c,d){return Ga(function(e,f){return Uh.l(a,e,f)},Uh.l(a,b,c),d)};Uh.K=function(a){var b=H(a),c=I(a);a=H(c);var d=I(c);c=H(d);d=I(d);return this.u(b,a,c,d)};Uh.N=3;function Vh(a,b){return new ve(null,function(){var c=E(b);if(c){var d=H(c);d=a.i?a.i(d):a.call(null,d);c=u(d)?P(H(c),Vh(a,Ec(c))):null}else c=null;return c},null)}function Wh(a,b,c){this.start=a;this.step=b;this.count=c;this.v=82;this.R=0}g=Wh.prototype;g.ea=function(){return this.count};g.qa=function(){return this.start}; -g.Y=function(a,b){return this.start+b*this.step};g.Da=function(a,b,c){return b>=0&&b0?this.Gthis.end};Xh.prototype.next=function(){var a=this.G;this.G+=this.step;return a}; -function Yh(a,b,c,d,e,f){this.F=a;this.start=b;this.end=c;this.step=d;this.B=e;this.I=f;this.v=32375006;this.R=402944}g=Yh.prototype;g.toString=function(){return hc(this)};g.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); -g.lastIndexOf=function(){function a(c){return O(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return O(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return O(this,c,d)};return b}();g.Y=function(a,b){if(0<=b&&bthis.end&&this.step===0)return this.start;throw Error("Index out of bounds");}; -g.Da=function(a,b,c){return 0<=b&&bthis.end&&this.step===0?this.start:c};g.Ca=function(){return new Xh(this.start,this.end,this.step)};g.fa=function(){return this.F}; -g.pa=function(){return this.step>0?this.start+this.stepthis.end?new Yh(null,this.start+this.step,this.end,this.step,Math.ceil((this.end-(this.start+this.step))/this.step),null):null};g.ea=function(){return this.B};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc}; -g.ta=function(a,b){return Wc(this,b)};g.ua=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Pc(c))return y(c);a+=this.step}else return c};g.qa=function(){return this.start};g.ra=function(){var a=this.pa();return a==null?Fc:a};g.ga=function(){return this};g.Qb=function(){var a=this.B;return new Wh(this.start,this.step,a<32?a:32)}; -g.Za=function(){if(this.B<=32)return Fc;var a=this.start+this.step*32;return this.step>0?this.end<=a?Fc:new Yh(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.step<0?this.end>=a?Fc:new Yh(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.end===a?Fc:new Df(null,-1,a,null)};g.ha=function(a,b){return b===this.F?this:new Yh(b,this.start,this.end,this.step,this.B,this.I)};g.ka=function(a,b){return P(b,this)}; -g.Wa=function(a,b){return b>0?b0?da.end)continue;else{c=a.da=new ye(b,0,c);break a}else{c=d;break a}break}c=void 0}a.da==null&&(a.da=new ye(b,0,32),(a.step>0?ca.end)&&(a.jc=new Zh(null,c,a.end,a.step,null,null,null)))}} -g.Ca=function(){return new Xh(this.start,this.end,this.step)};g.fa=function(){return this.F};g.pa=function(){return this.step>0?this.start+this.stepthis.end?new Zh(null,this.start+this.step,this.end,this.step,null,null,null):null};g.ba=function(){var a=this.I;return a!=null?a:this.I=a=Jc(this)};g.Z=function(a,b){return gd(this,b)};g.ma=function(){return Fc};g.ta=function(a,b){return hd(b,this)}; -g.ua=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Pc(c))return y(c);a+=this.step}else return c};g.qa=function(){return this.start};g.ra=function(){var a=this.pa();return a==null?Fc:a};g.ga=function(){return this};g.Qb=function(){$h(this);return this.da};g.Za=function(){$h(this);return this.jc==null?Fc:this.jc};g.ha=function(a,b){return b===this.F?this:new Zh(b,this.start,this.end,this.step,this.da,this.jc,this.I)}; -g.ka=function(a,b){return P(b,this)};g.lc=function(){return E(this.Za(null))};Zh.prototype[Ca]=function(){return Hc(this)}; -function ai(a,b){return function(){function c(l,m,n){return new X(null,2,5,Y,[a.l?a.l(l,m,n):a.call(null,l,m,n),b.l?b.l(l,m,n):b.call(null,l,m,n)],null)}function d(l,m){return new X(null,2,5,Y,[a.j?a.j(l,m):a.call(null,l,m),b.j?b.j(l,m):b.call(null,l,m)],null)}function e(l){return new X(null,2,5,Y,[a.i?a.i(l):a.call(null,l),b.i?b.i(l):b.call(null,l)],null)}function f(){return new X(null,2,5,Y,[a.J?a.J():a.call(null),b.J?b.J():b.call(null)],null)}var h=null,k=function(){function l(n,p,q,v){var A=null; -if(arguments.length>3){A=0;for(var C=Array(arguments.length-3);A3){q=0;for(var v=Array(arguments.length-3);q>>0),ea=0;function fa(a,b,c){return a.call.apply(a.bind,arguments)} +function ha(a,b,c){if(!a)throw Error();if(arguments.length>2){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}function ia(a,b,c){ia=Function.prototype.bind&&Function.prototype.bind.toString().indexOf("native code")!=-1?fa:ha;return ia.apply(null,arguments)} +function ja(a,b){function c(){}c.prototype=b.prototype;a.Xc=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.Ca=function(d,e,f){for(var g=Array(arguments.length-2),k=2;k>>16&65535)*d+c*(b>>>16&65535)<<16>>>0)|0};function jc(a){a=ic(a|0,-862048943);return ic(a<<15|a>>>-15,461845907)}function kc(a,b){a=(a|0)^(b|0);return ic(a<<13|a>>>-13,5)+-430675100|0}function lc(a,b){a=(a|0)^b;a=ic(a^a>>>16,-2048144789);a=ic(a^a>>>13,-1028477387);return a^a>>>16} +function mc(a){a:{var b=1;for(var c=0;;)if(b1024&&(nc={},oc=0);if(a==null)return 0;var b=nc[a];if(typeof b==="number")a=b;else{a:if(a!=null)if(b=a.length,b>0){for(var c=0,d=0;;)if(c>2)}function wc(a,b){return b instanceof a}function xc(a,b,c,d,e){this.ec=a;this.name=b;this.fb=c;this.Eb=d;this.Ua=e;this.v=2154168321;this.R=4096}h=xc.prototype;h.toString=function(){return this.fb};h.$=function(a,b){return b instanceof xc?this.fb===b.fb:!1}; +h.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+A.i(arguments.length-1));}};h.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return B?B(a,this):yc.call(null,a,this)};h.j=function(a,b){return zc?zc(a,this,b):yc.call(null,a,this,b)};h.fa=function(){return this.Ua}; +h.ha=function(a,b){return new xc(this.ec,this.name,this.fb,this.Eb,b)};h.ca=function(){var a=this.Eb;return a!=null?a:this.Eb=a=rc(mc(this.name),pc(this.ec))};h.Y=function(a,b){return Ib(b,this.fb)};var Ac=function Ac(a){switch(arguments.length){case 1:return Ac.i(arguments[0]);case 2:return Ac.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}; +Ac.i=function(a){for(;;){if(a instanceof xc)return a;if(typeof a==="string"){var b=a.indexOf("/");return b<1?Ac.j(null,a):Ac.j(a.substring(0,b),a.substring(b+1,a.length))}if(a instanceof C)a=a.za;else throw Error("no conversion to symbol");}};Ac.j=function(a,b){var c=a!=null?[Ba(a),"/",Ba(b)].join(""):b;return new xc(a,b,c,null,null)};Ac.N=2;function Bc(a){return a!=null?a.R&131072||t===a.me?!0:a.R?!1:v(ac,a):v(ac,a)} +function E(a){if(a==null)return null;if(a!=null&&(a.v&8388608||t===a.Ld))return a.ga(null);if(u(xa(a))||typeof a==="string")return a.length===0?null:new F(a,0,null);if(a!=null&&a[Ca]!=null)return a=na(a,Ca).call(a),Cc.i?Cc.i(a):Cc.call(null,a);if(v(Bb,a))return Cb(a);throw Error([Ba(a)," is not ISeqable"].join(""));}function H(a){if(a==null)return null;if(a!=null&&(a.v&64||t===a.Ib))return a.qa(null);a=E(a);return a==null?null:Ua(a)} +function Dc(a){return a!=null?a!=null&&(a.v&64||t===a.Ib)?a.ra(null):(a=E(a))?a.ra(null):Ec:Ec}function I(a){return a==null?null:a!=null&&(a.v&128||t===a.Tb)?a.pa():E(Dc(a))}var J=function J(a){switch(arguments.length){case 1:return J.i(arguments[0]);case 2:return J.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=d)return-1;!(c>0)&&c<0&&(c+=d,c=0>c?0:c);for(;;)if(c0?(--d,c=d=0){if(J.j($c?$c(a,c):ad.call(null,a,c),b))return c;--c}else return-1}function bd(a,b){this.u=a;this.G=b}bd.prototype.na=function(){return this.Ga?0:a};h.Ub=function(){var a=this.ea(null);return a>0?new fd(this,a-1,null):null};h.ca=function(){return Ic(this)};h.$=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};h.ma=function(){return Ec};h.ua=function(a,b){return Xc(this.u,b,this.u[this.G],this.G+1)}; +h.va=function(a,b,c){return Xc(this.u,b,c,this.G)};h.qa=function(){return this.u[this.G]};h.ra=function(){return this.G+10?this.G+b0?new fd(this.lc,this.G-1,null):null};h.ea=function(){return this.G+1};h.ca=function(){return Ic(this)}; +h.$=function(a,b){return gd.j?gd.j(this,b):gd.call(null,this,b)};h.ma=function(){return Ec};h.ua=function(a,b){return hd?hd(b,this):id.call(null,b,this)};h.va=function(a,b,c){return jd?jd(b,c,this):id.call(null,b,c,this)};h.qa=function(){return Ra(this.lc,this.G)};h.ra=function(){return this.G>0?new fd(this.lc,this.G-1,null):Ec};h.ga=function(){return this};h.ha=function(a,b){return b===this.F?this:new fd(this.lc,this.G,b)};h.ka=function(a,b){return Q.j?Q.j(b,this):Q.call(null,b,this)}; +fd.prototype[Ca]=function(){return Gc(this)};function kd(a){return H(I(a))}function ld(a){for(;;){var b=I(a);if(b!=null)a=b;else return H(a)}}zb._=function(a,b){return a===b};var md=function md(a){switch(arguments.length){case 0:return md.J();case 1:return md.i(arguments[0]);case 2:return md.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.J=function(){return this.P.J?this.P.J():this.P.call(null)};h.i=function(a){return this.P.i?this.P.i(a):this.P.call(null,a)};h.j=function(a,b){return this.P.j?this.P.j(a,b):this.P.call(null,a,b)}; +h.l=function(a,b,c){return this.P.l?this.P.l(a,b,c):this.P.call(null,a,b,c)};h.O=function(a,b,c,d){return this.P.O?this.P.O(a,b,c,d):this.P.call(null,a,b,c,d)};h.aa=function(a,b,c,d,e){return this.P.aa?this.P.aa(a,b,c,d,e):this.P.call(null,a,b,c,d,e)};h.Fa=function(a,b,c,d,e,f){return this.P.Fa?this.P.Fa(a,b,c,d,e,f):this.P.call(null,a,b,c,d,e,f)};h.tb=function(a,b,c,d,e,f,g){return this.P.tb?this.P.tb(a,b,c,d,e,f,g):this.P.call(null,a,b,c,d,e,f,g)}; +h.ub=function(a,b,c,d,e,f,g,k){return this.P.ub?this.P.ub(a,b,c,d,e,f,g,k):this.P.call(null,a,b,c,d,e,f,g,k)};h.wb=function(a,b,c,d,e,f,g,k,l){return this.P.wb?this.P.wb(a,b,c,d,e,f,g,k,l):this.P.call(null,a,b,c,d,e,f,g,k,l)};h.ib=function(a,b,c,d,e,f,g,k,l,n){return this.P.ib?this.P.ib(a,b,c,d,e,f,g,k,l,n):this.P.call(null,a,b,c,d,e,f,g,k,l,n)};h.jb=function(a,b,c,d,e,f,g,k,l,n,m){return this.P.jb?this.P.jb(a,b,c,d,e,f,g,k,l,n,m):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m)}; +h.kb=function(a,b,c,d,e,f,g,k,l,n,m,p){return this.P.kb?this.P.kb(a,b,c,d,e,f,g,k,l,n,m,p):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p)};h.lb=function(a,b,c,d,e,f,g,k,l,n,m,p,r){return this.P.lb?this.P.lb(a,b,c,d,e,f,g,k,l,n,m,p,r):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r)};h.mb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w){return this.P.mb?this.P.mb(a,b,c,d,e,f,g,k,l,n,m,p,r,w):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w)}; +h.nb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z){return this.P.nb?this.P.nb(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z)};h.ob=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D){return this.P.ob?this.P.ob(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D)};h.pb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G){return this.P.pb?this.P.pb(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G)}; +h.qb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M){return this.P.qb?this.P.qb(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M)};h.rb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O){return this.P.rb?this.P.rb(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O)}; +h.sb=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,V){return this.P.sb?this.P.sb(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,V):this.P.call(null,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,V)};h.Pc=function(a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,V,ka){return zd.Pc?zd.Pc(this.P,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,new F([V,ka],0,null)):zd.call(null,this.P,a,b,c,d,e,f,g,k,l,n,m,p,r,w,z,D,G,M,O,V,ka)};function Ad(a,b){return typeof a==="function"?new yd(a,b):a==null?null:tb(a,b)} +function Bd(a){var b=a!=null;return(b?a!=null?a.v&131072||t===a.Jd||(a.v?0:v(qb,a)):v(qb,a):b)?rb(a):null}function Cd(a){return a==null?null:lb(a)}function Dd(a){return a==null?null:mb(a)}var Ed=function Ed(a){switch(arguments.length){case 1:return Ed.i(arguments[0]);case 2:return Ed.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(eb?a:b};me.o=function(a,b,c){return Ga(me,me.j(a,b),c)};me.K=function(a){var b=H(a),c=I(a);a=H(c);c=I(c);return this.o(b,a,c)};me.N=2;var ne=function ne(a){switch(arguments.length){case 1:return ne.i(arguments[0]);case 2:return ne.j(arguments[0],arguments[1]);default:for(var c=[],d=arguments.length,e=0;;)if(e=0?Math.floor(a):Math.ceil(a)}function pe(a){a-=a>>1&1431655765;a=(a&858993459)+(a>>2&858993459);return(a+(a>>4)&252645135)*16843009>>24} +var Ba=function Ba(a,b){if(a==null)return"";if(void 0===b)return[a].join("");for(var d=new ta,e=arguments,f=e.length,g=0;;)if(g20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return B(a,this)};h.j=function(a,b){return zc(a,this,b)}; +h.ca=function(){var a=this.Eb;return a!=null?a:this.Eb=a=rc(mc(this.name),pc(this.ec))+2654435769|0};h.Y=function(a,b){return Ib(b,[":",Ba(this.za)].join(""))};function te(a,b){return a===b?!0:a instanceof C&&b instanceof C?a.za===b.za:!1}function ue(a){if(a!=null&&(a.R&4096||t===a.hd))return a.ec;throw Error(["Doesn't support namespace: ",Ba(a)].join(""));} +var ve=function ve(a){switch(arguments.length){case 1:return ve.i(arguments[0]);case 2:return ve.j(arguments[0],arguments[1]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};ve.i=function(a){if(a instanceof C)return a;if(a instanceof xc)return new C(ue(a),we.i?we.i(a):we.call(null,a),a.fb,null);if(J.j("/",a))return new C(null,a,a,null);if(typeof a==="string"){var b=a.split("/");return b.length===2?new C(b[0],b[1],a,null):new C(null,b[0],a,null)}return null}; +ve.j=function(a,b){a=a instanceof C?we.i?we.i(a):we.call(null,a):a instanceof xc?we.i?we.i(a):we.call(null,a):a;b=b instanceof C?we.i?we.i(b):we.call(null,b):b instanceof xc?we.i?we.i(b):we.call(null,b):b;return new C(a,b,[u(a)?[Ba(a),"/"].join(""):null,Ba(b)].join(""),null)};ve.N=2;function xe(a,b,c){this.F=a;this.Zb=b;this.s=null;this.I=c;this.v=32374988;this.R=1}h=xe.prototype;h.toString=function(){return hc(this)}; +function ye(a){a.Zb!=null&&(a.s=a.Zb.J?a.Zb.J():a.Zb.call(null),a.Zb=null);return a.s}h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.fa=function(){return this.F};h.pa=function(){this.ga(null);return this.s==null?null:I(this.s)};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)}; +h.ma=function(){return tb(Ec,this.F)};h.ua=function(a,b){return hd(b,this)};h.va=function(a,b,c){return jd(b,c,this)};h.qa=function(){this.ga(null);return this.s==null?null:H(this.s)};h.ra=function(){this.ga(null);return this.s!=null?Dc(this.s):Ec};h.ga=function(){ye(this);if(this.s==null)return null;for(var a=this.s;;)if(a instanceof xe)a=ye(a);else return this.s=a,E(this.s)};h.ha=function(a,b){var c=this;return b===this.F?c:new xe(b,function(){return c.ga(null)},this.I)}; +h.ka=function(a,b){return Q(b,this)};xe.prototype[Ca]=function(){return Gc(this)};function ze(a){this.Ic=a;this.end=0;this.v=2;this.R=0}ze.prototype.add=function(a){this.Ic[this.end]=a;return this.end+=1};ze.prototype.W=function(){var a=new Ae(this.Ic,0,this.end);this.Ic=null;return a};ze.prototype.ea=function(){return this.end};function Be(a){return new ze(Array(a))}function Ae(a,b,c){this.u=a;this.off=b;this.end=c;this.v=524306;this.R=0}h=Ae.prototype;h.ea=function(){return this.end-this.off}; +h.Z=function(a,b){return this.u[this.off+b]};h.Ea=function(a,b,c){return b>=0&&b1?new Ce(Vb(this.W),this.Qa,null,null):this.Qa==null?null:Cb(this.Qa)};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)}; +h.$=function(a,b){return gd(this,b)};h.ma=function(){return Ec};h.qa=function(){return Ra(this.W,0)};h.ra=function(){return La(this.W)>1?new Ce(Vb(this.W),this.Qa,null,null):this.Qa==null?Ec:this.Qa};h.ga=function(){return this};h.Rb=function(){return this.W};h.$a=function(){return this.Qa==null?Ec:this.Qa};h.ha=function(a,b){return b===this.F?this:new Ce(this.W,this.Qa,b,this.I)};h.ka=function(a,b){return Q(b,this)};h.mc=function(){return this.Qa==null?null:this.Qa};Ce.prototype[Ca]=function(){return Gc(this)}; +function De(a,b){return La(a)===0?b:new Ce(a,b,null,null)}function Ee(a,b){a.add(b)}function Fe(a,b){if(Yc(b))return N(b);var c=0;for(b=E(b);;)if(b!=null&&c2){p=0;for(var r=Array(arguments.length-2);p2){n=0;for(var m=Array(arguments.length-2);n0)for(var d=0,e=Array(arguments.length-0);d3){z=0;for(var D=Array(arguments.length-3);z3){r=0;for(var w=Array(arguments.length-3);r3){D=0;for(var G=Array(arguments.length-3);D3){w=0;for(var z=Array(arguments.length-3);w0){l=0;for(var n=Array(arguments.length-0);l3){z=0;for(var D=Array(arguments.length-3);z3){r=0;for(var w=Array(arguments.length-3);r3){D=0;for(var G=Array(arguments.length-3);D3){w=0;for(var z=Array(arguments.length-3);w3){G=0;for(var M=Array(arguments.length-3);G3){z=0;for(var D=Array(arguments.length-3);z0){l=0;for(var n=Array(arguments.length-0);l3){w=0;for(var z=Array(arguments.length-3);w3){p=0;for(var r=Array(arguments.length-3);p3){r=0;for(var w=Array(arguments.length-3);r3){m=0;for(var p=Array(arguments.length-3);m2){r=0;for(var w=Array(arguments.length-2);r2){m=0;for(var p=Array(arguments.length-2);m0){var c=E(b);return c?Q(H(c),Af(a-1,Dc(c))):null}return null},null)} +function Bf(a,b){if(b!=null&&(b.R&262144||t===b.ie)){var c=a>0?b.Xa(null,Math.ceil(a)):E(b);return u(c)?c:Ec}return new xe(null,function(){a:{var d=a;for(var e=b;;)if(e=E(e),d>0&&e)--d,e=Dc(e);else{d=e;break a}d=void 0}return d},null)}function Cf(a){return zf.l(function(b){return b},a,Bf(2,a))}function Df(a,b){var c=E(b);for(a=E(Bf(a,b));;)if(a)c=I(c),a=I(a);else return c}function Ef(a,b,c,d){this.F=a;this.count=b;this.U=c;this.next=d;this.I=null;this.v=32374988;this.R=262145}h=Ef.prototype; +h.toString=function(){return hc(this)};h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,this.count)}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.fa=function(){return this.F};h.pa=function(){return this.next==null?this.count>1?this.next=new Ef(null,this.count-1,this.U,null):-1===this.count?this:null:this.next}; +h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return Ec};h.ua=function(a,b){if(this.count===-1)for(var c=b.j?b.j(this.U,this.U):b.call(null,this.U,this.U);;){if(Rc(c))return y(c);c=b.j?b.j(c,this.U):b.call(null,c,this.U)}else for(a=1,c=this.U;;)if(a1?this.next=new Ef(null,this.count-1,this.U,null):-1===this.count?this:Ec:this.next};h.ga=function(){return this}; +h.ha=function(a,b){return b===this.F?this:new Ef(b,this.count,this.U,this.next)};h.ka=function(a,b){return Q(b,this)};h.Xa=function(a,b){if(this.count===-1)return this;a=this.count-b;return a>0?new Ef(null,a,this.U,null):null};function Ff(a){return a>0?new Ef(null,a,null,null):Ec}function Gf(a,b){return Pe(He,Ue(zf,a,b))} +function Hf(a){return function(b){return function(){function c(g,k){return u(a.i?a.i(k):a.call(null,k))?b.j?b.j(g,k):b.call(null,g,k):g}function d(g){return b.i?b.i(g):b.call(null,g)}function e(){return b.J?b.J():b.call(null)}var f=null;f=function(g,k){switch(arguments.length){case 0:return e.call(this);case 1:return d.call(this,g);case 2:return c.call(this,g,k)}throw Error("Invalid arity: "+arguments.length);};f.J=e;f.i=d;f.j=c;return f}()}} +function If(a,b){return new xe(null,function(){var c=E(b);if(c){if(Md(c)){for(var d=Wb(c),e=N(d),f=Be(e),g=0;;)if(g>>5<<5}function Zf(a,b,c){for(;;){if(b===0)return c;var d=Wf(a);d.u[0]=c;c=d;b-=5}}var $f=function $f(a,b,c,d){var f=Xf(c),g=a.B-1>>>b&31;5===b?f.u[g]=d:(c=c.u[g],c!=null?(b-=5,a=$f.O?$f.O(a,b,c,d):$f.call(null,a,b,c,d)):a=Zf(null,b-5,d),f.u[g]=a);return f};function ag(a,b){throw Error(["No item ",Ba(a)," in vector of length ",Ba(b)].join(""));} +function bg(a,b){if(b>=Yf(a))return a.tail;var c=a.root;for(a=a.shift;;)if(a>0){var d=a-5;c=c.u[b>>>a&31];a=d}else return c.u}function cg(a,b){return 0<=b&&b>>b&31;b-=5;c=c.u[k];a=dg.aa?dg.aa(a,b,c,d,e):dg.call(null,a,b,c,d,e);g.u[k]=a}return g},eg=function eg(a,b,c){var e=a.B-2>>>b&31;if(b>5){b-=5;var f=c.u[e];a=eg.l?eg.l(a,b,f):eg.call(null,a,b,f);if(a==null&&e===0)return null;c=Xf(c);c.u[e]=a;return c}if(e===0)return null;c=Xf(c);c.u[e]=null;return c};function fg(a,b,c,d,e,f){this.G=a;this.Ca=b;this.u=c;this.sa=d;this.start=e;this.end=f} +fg.prototype.na=function(){return this.G0?this.Z(null,this.B-1):null}; +h.yb=function(){if(this.B===0)throw Error("Can't pop empty vector");if(1===this.B)return tb(nd,this.F);if(10?new fd(this,this.B-1,null):null}; +h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){if(b instanceof X)if(this.B===N(b))for(a=this.Da(null),b=b.Da(null);;){if(a.na()){var c=a.next(),d=b.next();if(J.j(c,d))continue;else return!1}else return!0;break}else return!1;else return gd(this,b)};h.Fb=function(){return new kg(this.B,this.shift,lg.i?lg.i(this.root):lg.call(null,this.root),mg.i?mg.i(this.tail):mg.call(null,this.tail))};h.ma=function(){return tb(nd,this.F)};h.ua=function(a,b){return hg(this,b,0,this.B)}; +h.va=function(a,b,c){a=0;for(var d=c;;)if(a0)b-=5,a=a.u[0];else{a=a.u;break a}a=void 0}return ng?ng(this,a,0,0):og.call(null,this,a,0,0)};h.ha=function(a,b){return b===this.F?this:new X(b,this.B,this.shift,this.root,this.tail,this.I)}; +h.ka=function(a,b){if(this.B-Yf(this)<32){a=this.tail.length;for(var c=Array(a+1),d=0;;)if(d>>5>1<20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){if(typeof a==="number")return this.Z(null,a);throw Error("Key must be integer");}; +h.Xa=function(a,b){if(ba?e:a;return Dg.aa?Dg.aa(b,c,d,a,null):Dg.call(null,b,c,d,a,null)};h.Da=function(){return this.sa!=null&&t===this.sa.Nc?gg(this.sa,this.start,this.end):new cf(this)};h.fa=function(){return this.F};h.ea=function(){return this.end-this.start}; +h.xb=function(){return this.start===this.end?null:Ra(this.sa,this.end-1)};h.yb=function(){if(this.start===this.end)throw Error("Can't pop empty vector");var a=this.F,b=this.sa,c=this.start,d=this.end-1;return Dg.aa?Dg.aa(a,b,c,d,null):Dg.call(null,a,b,c,d,null)};h.Ub=function(){return this.start!==this.end?new fd(this,this.end-this.start-1,null):null};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return tb(nd,this.F)}; +h.ua=function(a,b){return this.sa!=null&&t===this.sa.Nc?hg(this.sa,b,this.start,this.end):Tc(this,b)};h.va=function(a,b,c){return this.sa!=null&&t===this.sa.Nc?ig(this.sa,b,c,this.start,this.end):Uc(this,b,c)};h.Ia=function(a,b,c){if(typeof b==="number")return this.zb(null,b,c);throw Error("Subvec's key for assoc must be a number.");};h.Wa=function(a,b){return be(b)?0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.Z(null,a)};h.j=function(a,b){return this.Ea(null,a,b)};Ag.prototype[Ca]=function(){return Gc(this)}; +function Dg(a,b,c,d,e){for(;;)if(b instanceof Ag)c=b.start+c,d=b.start+d,b=b.sa;else{if(!Ld(b))throw Error("v must satisfy IVector");if(c<0||dN(b))throw Error("Index out of bounds");return new Ag(a,b,c,d,e)}}var Eg=function Eg(a){switch(arguments.length){case 2:return Eg.j(arguments[0],arguments[1]);case 3:return Eg.l(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}};Eg.j=function(a,b){return Eg.l(a,b,N(a))}; +Eg.l=function(a,b,c){return Dg(null,a,b|0,c|0,null)};Eg.N=3;function Fg(a,b){return a===b.ia?b:new Vf(a,Da(b.u))}function lg(a){return new Vf({},Da(a.u))}function mg(a){var b=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];Nd(a,0,b,0,a.length);return b} +var Gg=function Gg(a,b,c,d){c=Fg(a.root.ia,c);var f=a.B-1>>>b&31;if(b===5)a=d;else{var g=c.u[f];g!=null?(b-=5,a=Gg.O?Gg.O(a,b,g,d):Gg.call(null,a,b,g,d)):a=Zf(a.root.ia,b-5,d)}c.u[f]=a;return c};function kg(a,b,c,d){this.B=a;this.shift=b;this.root=c;this.tail=d;this.R=88;this.v=275}h=kg.prototype; +h.Kb=function(a,b){if(this.root.ia){if(this.B-Yf(this)<32)this.tail[this.B&31]=b;else{a=new Vf(this.root.ia,this.tail);var c=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];c[0]=b;this.tail=c;this.B>>>5>1<>>f&31;f=k(f-5,g.u[l]);g.u[l]=f}return g}(a.shift,a.root);a.root=d}return a}if(b===a.B)return a.Kb(null,c);throw Error(["Index ",Ba(b)," out of bounds for TransientVector of length",Ba(a.B)].join(""));}throw Error("assoc! after persistent!");}h.ea=function(){if(this.root.ia)return this.B;throw Error("count after persistent!");}; +h.Z=function(a,b){if(this.root.ia)return cg(this,b)[b&31];throw Error("nth after persistent!");};h.Ea=function(a,b,c){return 0<=b&&b20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.xa(null,a)};h.j=function(a,b){return this.da(null,a,b)};function Ig(a,b){this.Mb=a;this.fc=b} +Ig.prototype.na=function(){var a=this.Mb!=null&&E(this.Mb);return a?a:(a=this.fc!=null)?this.fc.na():a};Ig.prototype.next=function(){if(this.Mb!=null){var a=H(this.Mb);this.Mb=I(this.Mb);return a}if(this.fc!=null&&this.fc.na())return this.fc.next();throw Error("No such element");};Ig.prototype.remove=function(){return Error("Unsupported operation")};function Jg(a,b,c,d){this.F=a;this.Ha=b;this.Ka=c;this.I=d;this.v=31850700;this.R=0}h=Jg.prototype;h.toString=function(){return hc(this)}; +h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.fa=function(){return this.F};h.pa=function(){var a=I(this.Ha);return a?new Jg(this.F,a,this.Ka,null):this.Ka!=null?new Jg(this.F,this.Ka,null,null):null};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)}; +h.$=function(a,b){return gd(this,b)};h.ma=function(){return tb(Ec,this.F)};h.qa=function(){return H(this.Ha)};h.ra=function(){var a=I(this.Ha);return a?new Jg(this.F,a,this.Ka,null):this.Ka==null?this.ma(null):new Jg(this.F,this.Ka,null,null)};h.ga=function(){return this};h.ha=function(a,b){return b===this.F?this:new Jg(b,this.Ha,this.Ka,this.I)};h.ka=function(a,b){return Q(b,this)};Jg.prototype[Ca]=function(){return Gc(this)}; +function Kg(a,b,c,d,e){this.F=a;this.count=b;this.Ha=c;this.Ka=d;this.I=e;this.R=139264;this.v=31858766}h=Kg.prototype;h.toString=function(){return hc(this)};h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,this.count.i?this.count.i(this):this.count.call(null,this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.Da=function(){return new Ig(this.Ha,bc(this.Ka))};h.fa=function(){return this.F};h.ea=function(){return this.count};h.xb=function(){return H(this.Ha)}; +h.yb=function(){if(u(this.Ha)){var a=I(this.Ha);return a?new Kg(this.F,this.count-1,a,this.Ka,null):new Kg(this.F,this.count-1,E(this.Ka),nd,null)}return this};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return tb(Lg,this.F)};h.qa=function(){return H(this.Ha)};h.ra=function(){return Dc(E(this))};h.ga=function(){var a=E(this.Ka),b=this.Ha;return u(u(b)?b:a)?new Jg(null,this.Ha,E(a),null):null}; +h.ha=function(a,b){return b===this.F?this:new Kg(b,this.count,this.Ha,this.Ka,this.I)};h.ka=function(a,b){u(this.Ha)?(a=this.Ka,b=new Kg(this.F,this.count+1,this.Ha,md.j(u(a)?a:nd,b),null)):b=new Kg(this.F,this.count+1,md.j(this.Ha,b),nd,null);return b};var Lg=new Kg(null,0,null,nd,Jc);Kg.prototype[Ca]=function(){return Gc(this)};function Mg(){this.v=2097152;this.R=0}Mg.prototype.$=function(){return!1};var Ng=new Mg; +function Og(a,b){return $d(Jd(b)&&!Kd(b)?N(a)===N(b)?(a!=null?a.v&1048576||t===a.Ed||(a.v?0:v(xb,a)):v(xb,a))?ge(function(c,d,e){return J.j(zc(b,d,Ng),e)?!0:new Qc},!0,a):df(function(c){return J.j(zc(b,H(c),Ng),kd(c))},a):null:null)}function Pg(a,b,c,d){this.G=0;this.ce=a;this.$c=b;this.Ud=c;this.nd=d}Pg.prototype.na=function(){var a=this.G20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.Z(null,a)};h.j=function(a,b){return this.Ea(null,a,b)}; +function rg(a){return a!=null?a.v&2048||t===a.oe?!0:!1:!1}function Ug(a,b,c){this.u=a;this.G=b;this.Ua=c;this.v=32374990;this.R=262144}h=Ug.prototype;h.toString=function(){return hc(this)};h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.fa=function(){return this.Ua};h.pa=function(){return this.G=0){a=this.u.length;var c=a-2;if(c===0)return this.ma(null);c=Array(c);for(var d=0,e=0;;){if(d>=a)return new Z(this.F,this.B-1,c,null);J.j(b,this.u[d])?d+=2:(c[e]=this.u[d],c[e+1]=this.u[d+1],e+=2,d+=2)}}else return this}; +h.Ia=function(a,b,c){a=Tg(this.u,b);if(a===-1){if(this.B20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.xa(null,a)};h.j=function(a,b){return this.da(null,a,b)}; +h.Xa=function(a,b){a=this.ga(null);return a==null?null:ec(a,b)};var W=new Z(null,0,[],Lc),Zg=8;function ah(a,b){return a===b?!0:te(a,b)?!0:J.j(a,b)}function bh(a){for(var b=0,c=0;;)if(b=e){if(ah(c[e],c[g]))return g;g-=2}else return g}(),a[f]=c[e],a[f+1]=c[b+1],b=2+f,e=2+e,f=b);else break;return new Z(null,a.length/2,a,null)}return new Z(null,c.length/2,c,null)}Z.prototype[Ca]=function(){return Gc(this)}; +function Yg(a,b){this.Lb={};this.Bb=a;this.u=b;this.v=259;this.R=56}h=Yg.prototype;h.ea=function(){if(this.Lb)return oe(this.Bb);throw Error("count after persistent!");};h.xa=function(a,b){return this.da(null,b,null)};h.da=function(a,b,c){if(this.Lb)return a=Tg(this.u,b),a===-1?c:this.u[a+1];throw Error("lookup after persistent!");}; +h.Kb=function(a,b){if(this.Lb){if(rg(b))return this.Jb(null,sg.i?sg.i(b):sg.call(null,b),tg.i?tg.i(b):tg.call(null,b));if(Ld(b))return this.Jb(null,b.i?b.i(0):b.call(null,0),b.i?b.i(1):b.call(null,1));a=E(b);for(b=this;;){var c=H(a);if(u(c))a=I(a),b=Ub(b,sg.i?sg.i(c):sg.call(null,c),tg.i?tg.i(c):tg.call(null,c));else return b}}else throw Error("conj! after persistent!");};h.Vb=function(){if(this.Lb)return this.Lb=!1,new Z(null,oe(this.Bb),this.u,null);throw Error("persistent! called twice");}; +h.Jb=function(a,b,c){if(this.Lb){a=Tg(this.u,b);if(a===-1){if(this.Bb+2<=2*Zg)return this.Bb+=2,this.u.push(b),this.u.push(c),this;a=eh.j?eh.j(this.Bb,this.u):eh.call(null,this.Bb,this.u);return Ub(a,b,c)}c!==this.u[a+1]&&(this.u[a+1]=c);return this}throw Error("assoc! after persistent!");};h.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+A.i(arguments.length-1));}}; +h.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.da(null,a,null)};h.j=function(a,b){return this.da(null,a,b)};function eh(a,b){for(var c=Rb($g),d=0;;)if(d>>a&31);if((this.ja&e)===0)return d;var f=pe(this.ja&e-1);e=this.u[2*f];f=this.u[2*f+1];return e==null?f.cb(a+5,b,c,d):ah(c,e)?f:d}; +h.Pa=function(a,b,c,d,e,f){var g=1<<(c>>>b&31),k=pe(this.ja&g-1);if((this.ja&g)===0){var l=pe(this.ja);if(2*l=16){k=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];k[c>>>b&31]=oh.Pa(a,b+5,c,d,e,f);for(e=d=0;;)if(d<32)(this.ja>>>d&1)=== +0?d+=1:(k[d]=this.u[e]!=null?oh.Pa(a,b+5,qc(this.u[e]),this.u[e],this.u[e+1],f):this.u[e+1],e+=2,d+=1);else break;return new ph(a,l+1,k)}b=Array(2*(l+4));Nd(this.u,0,b,0,2*k);b[2*k]=d;b[2*k+1]=e;Nd(this.u,2*k,b,2*(k+1),2*(l-k));f.U=!0;a=this.Ab(a);a.u=b;a.ja|=g;return a}l=this.u[2*k];g=this.u[2*k+1];if(l==null)return l=g.Pa(a,b+5,c,d,e,f),l===g?this:ih(this,a,2*k+1,l);if(ah(d,l))return e===g?this:ih(this,a,2*k+1,e);f.U=!0;f=b+5;d=qh?qh(a,f,l,g,c,d,e):rh.call(null,a,f,l,g,c,d,e);e=2*k;k=2*k+1;a=this.Ab(a); +a.u[e]=null;a.u[k]=d;return a}; +h.Oa=function(a,b,c,d,e){var f=1<<(b>>>a&31),g=pe(this.ja&f-1);if((this.ja&f)===0){var k=pe(this.ja);if(k>=16){g=[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null];g[b>>>a&31]=oh.Oa(a+5,b,c,d,e);for(d=c=0;;)if(c<32)(this.ja>>>c&1)===0?c+=1:(g[c]=this.u[d]!=null?oh.Oa(a+5,qc(this.u[d]),this.u[d],this.u[d+1],e):this.u[d+1],d+=2,c+=1);else break;return new ph(null,k+1,g)}a=Array(2*(k+1));Nd(this.u, +0,a,0,2*g);a[2*g]=c;a[2*g+1]=d;Nd(this.u,2*g,a,2*(g+1),2*(k-g));e.U=!0;return new lh(null,this.ja|f,a)}var l=this.u[2*g];f=this.u[2*g+1];if(l==null)return k=f.Oa(a+5,b,c,d,e),k===f?this:new lh(null,this.ja,gh(this.u,2*g+1,k));if(ah(c,l))return d===f?this:new lh(null,this.ja,gh(this.u,2*g+1,d));e.U=!0;e=this.ja;k=this.u;a+=5;a=sh?sh(a,l,f,b,c,d):rh.call(null,a,l,f,b,c,d);c=2*g;g=2*g+1;d=Da(k);d[c]=null;d[g]=a;return new lh(null,e,d)}; +h.$b=function(a,b,c,d){var e=1<<(b>>>a&31);if((this.ja&e)===0)return d;var f=pe(this.ja&e-1);e=this.u[2*f];f=this.u[2*f+1];return e==null?f.$b(a+5,b,c,d):ah(c,e)?new jg(e,f):d}; +h.bc=function(a,b,c){var d=1<<(b>>>a&31);if((this.ja&d)===0)return this;var e=pe(this.ja&d-1),f=this.u[2*e],g=this.u[2*e+1];return f==null?(a=g.bc(a+5,b,c),a===g?this:a!=null?new lh(null,this.ja,gh(this.u,2*e+1,a)):this.ja===d?null:new lh(null,this.ja^d,hh(this.u,e))):ah(c,f)?this.ja===d?null:new lh(null,this.ja^d,hh(this.u,e)):this};h.Da=function(){return new kh(this.u)};var oh=new lh(null,0,[]);function th(a){this.u=a;this.G=0;this.Ra=null} +th.prototype.na=function(){for(var a=this.u.length;;){if(this.Ra!=null&&this.Ra.na())return!0;if(this.G>>a&31];return e!=null?e.cb(a+5,b,c,d):d};h.Pa=function(a,b,c,d,e,f){var g=c>>>b&31,k=this.u[g];if(k==null)return a=ih(this,a,g,oh.Pa(a,b+5,c,d,e,f)),a.B+=1,a;b=k.Pa(a,b+5,c,d,e,f);return b===k?this:ih(this,a,g,b)}; +h.Oa=function(a,b,c,d,e){var f=b>>>a&31,g=this.u[f];if(g==null)return new ph(null,this.B+1,gh(this.u,f,oh.Oa(a+5,b,c,d,e)));a=g.Oa(a+5,b,c,d,e);return a===g?this:new ph(null,this.B,gh(this.u,f,a))};h.$b=function(a,b,c,d){var e=this.u[b>>>a&31];return e!=null?e.$b(a+5,b,c,d):d}; +h.bc=function(a,b,c){var d=b>>>a&31,e=this.u[d];if(e!=null){a=e.bc(a+5,b,c);if(a===e)d=this;else if(a==null)if(this.B<=8)a:{e=this.u;a=e.length;b=Array(2*(this.B-1));c=0;for(var f=1,g=0;;)if(c2*this.B)return b=2*this.B,c=2*this.B+1,a=this.Ab(a),a.u[b]=d,a.u[c]=e,f.U=!0,a.B+=1,a;c=this.u.length;b=Array(c+2);Nd(this.u,0,b,0,c);b[c]=d;b[c+1]=e;f.U=!0;d=this.B+1;a===this.ia?(this.u=b,this.B=d,a=this):a=new zh(this.ia,this.Ya,d,b);return a}return this.u[b+1]===e?this:ih(this,a,b+1,e)}return(new lh(a,1<<(this.Ya>>>b&31),[null,this,null,null])).Pa(a,b,c,d,e,f)}; +h.Oa=function(a,b,c,d,e){return b===this.Ya?(a=yh(this.u,this.B,c),a===-1?(a=2*this.B,b=Array(a+2),Nd(this.u,0,b,0,a),b[a]=c,b[a+1]=d,e.U=!0,new zh(null,this.Ya,this.B+1,b)):J.j(this.u[a+1],d)?this:new zh(null,this.Ya,this.B,gh(this.u,a+1,d))):(new lh(null,1<<(this.Ya>>>a&31),[null,this])).Oa(a,b,c,d,e)};h.$b=function(a,b,c,d){a=yh(this.u,this.B,c);return a<0?d:new jg(this.u[a],this.u[a+1])}; +h.bc=function(a,b,c){a=yh(this.u,this.B,c);return a===-1?this:this.B===1?null:new zh(null,this.Ya,this.B-1,hh(this.u,oe(a)))};h.Da=function(){return new kh(this.u)};function rh(a){switch(arguments.length){case 6:return sh(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5]);case 7:return qh(arguments[0],arguments[1],arguments[2],arguments[3],arguments[4],arguments[5],arguments[6]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}} +function sh(a,b,c,d,e,f){var g=qc(b);if(g===d)return new zh(null,g,2,[b,c,e,f]);var k=new fh;return oh.Oa(a,g,b,c,k).Oa(a,d,e,f,k)}function qh(a,b,c,d,e,f,g){var k=qc(c);if(k===e)return new zh(null,k,2,[c,d,f,g]);var l=new fh;return oh.Pa(a,b,k,c,d,l).Pa(a,b,e,f,g,l)}function Ah(a,b,c,d,e){this.F=a;this.Sa=b;this.G=c;this.s=d;this.I=e;this.v=32374988;this.R=0}h=Ah.prototype;h.toString=function(){return hc(this)}; +h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.fa=function(){return this.F};h.pa=function(){if(this.s==null){var a=this.Sa,b=this.G+2;return Bh?Bh(a,b,null):nh.call(null,a,b,null)}a=this.Sa;b=this.G;var c=I(this.s);return Bh?Bh(a,b,c):nh.call(null,a,b,c)}; +h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return Ec};h.ua=function(a,b){return hd(b,this)};h.va=function(a,b,c){return jd(b,c,this)};h.qa=function(){return this.s==null?new jg(this.Sa[this.G],this.Sa[this.G+1]):H(this.s)}; +h.ra=function(){var a=this,b=a.s==null?function(){var c=a.Sa,d=a.G+2;return Bh?Bh(c,d,null):nh.call(null,c,d,null)}():function(){var c=a.Sa,d=a.G,e=I(a.s);return Bh?Bh(c,d,e):nh.call(null,c,d,e)}();return b!=null?b:Ec};h.ga=function(){return this};h.ha=function(a,b){return b===this.F?this:new Ah(b,this.Sa,this.G,this.s,this.I)};h.ka=function(a,b){return Q(b,this)};Ah.prototype[Ca]=function(){return Gc(this)}; +function nh(a){switch(arguments.length){case 1:return mh(arguments[0]);case 3:return Bh(arguments[0],arguments[1],arguments[2]);default:throw Error(["Invalid arity: ",arguments.length].join(""));}}function mh(a){return Bh(a,0,null)}function Bh(a,b,c){if(c==null)for(c=a.length;;)if(b0){var a=this.root!=null?this.root.ac():null;return this.Aa?Q(new jg(null,this.Ba),a):a}return null}; +h.ha=function(a,b){return b===this.F?this:new Fh(b,this.B,this.root,this.Aa,this.Ba,this.I)};h.ka=function(a,b){if(Ld(b))return this.Ia(null,Ra(b,0),Ra(b,1));a=this;for(b=E(b);;){if(b==null)return a;var c=H(b);if(Ld(c))a=cb(a,Ra(c,0),Ra(c,1)),b=I(b);else throw Error("conj on a map takes map entries or seqables of map entries");}}; +h.call=function(a){switch(arguments.length-1){case 1:return this.i(arguments[1]);case 2:return this.j(arguments[1],arguments[2]);default:throw Error("Invalid arity: "+A.i(arguments.length-1));}};h.apply=function(a,b){var c=Da(b);a=this.call;b=a.apply;var d=[this],e=d.concat;if(c.length>20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.xa(null,a)};h.j=function(a,b){return this.da(null,a,b)};var $g=new Fh(null,0,null,!1,null,Lc); +function Hh(a,b){for(var c=a.length,d=0,e=Rb($g);;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.xa(null,a)};h.j=function(a,b){return this.da(null,a,b)};var Jh=function Jh(a){for(var c=[],d=arguments.length,e=0;;)if(e20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return this.xa(null,a)};h.j=function(a,b){return this.da(null,a,b)};var Sh=new Qh(null,W,Lc); +function Th(a){for(var b=a.length,c=Rb(Sh),d=0;;)if(d20){var f=c.slice(0,20);f.push(c.slice(20));c=f}return b.call(a,this,e.call(d,c))};h.i=function(a){return Za(this.Cb,a,Od)===Od?null:a};h.j=function(a,b){return Za(this.Cb,a,Od)===Od?b:a}; +function Uh(a){if(Hd(a))return Ad(a,null);a=E(a);if(a==null)return Sh;if(a instanceof F&&a.G===0)return Th(a.u);for(var b=Rb(Sh);;)if(a!=null){var c=I(a);b=Sb(b,Ua(a));a=c}else return Tb(b)}function Vh(a){return function e(c,d){return new xe(null,function(){var f;a:{var g=c;for(f=d;;){var k=g;g=S(k,0,null);if(k=E(k))if(ce(f,g)){g=Dc(k);continue}else{f=Q(g,e(Dc(k),md.j(f,g)));break a}else{f=null;break a}break}f=void 0}return f},null)}(a,Sh)} +function Wh(a){for(var b=nd;;)if(I(a))b=md.j(b,H(a)),a=I(a);else return E(b)}function we(a){if(a!=null&&(a.R&4096||t===a.hd))return a.name;if(typeof a==="string")return a;throw Error(["Doesn't support name: ",Ba(a)].join(""));} +var Xh=function Xh(a){switch(arguments.length){case 2:return Xh.j(arguments[0],arguments[1]);case 3:return Xh.l(arguments[0],arguments[1],arguments[2]);default:for(var c=[],d=arguments.length,e=0;;)if(e(a.i?a.i(c):a.call(null,c))?b:c}; +Xh.o=function(a,b,c,d){return Ga(function(e,f){return Xh.l(a,e,f)},Xh.l(a,b,c),d)};Xh.K=function(a){var b=H(a),c=I(a);a=H(c);var d=I(c);c=H(d);d=I(d);return this.o(b,a,c,d)};Xh.N=3;function Yh(a,b){return new xe(null,function(){var c=E(b);if(c){var d=H(c);d=a.i?a.i(d):a.call(null,d);c=u(d)?Q(H(c),Yh(a,Dc(c))):null}else c=null;return c},null)}function Zh(a,b,c){this.start=a;this.step=b;this.count=c;this.v=82;this.R=0}h=Zh.prototype;h.ea=function(){return this.count};h.qa=function(){return this.start}; +h.Z=function(a,b){return this.start+b*this.step};h.Ea=function(a,b,c){return b>=0&&b0?this.Gthis.end};$h.prototype.next=function(){var a=this.G;this.G+=this.step;return a}; +function ai(a,b,c,d,e,f){this.F=a;this.start=b;this.end=c;this.step=d;this.B=e;this.I=f;this.v=32375006;this.R=402944}h=ai.prototype;h.toString=function(){return hc(this)};h.indexOf=function(){var a=null;a=function(b,c){switch(arguments.length){case 1:return K(this,b,0);case 2:return K(this,b,c)}throw Error("Invalid arity: "+arguments.length);};a.i=function(b){return K(this,b,0)};a.j=function(b,c){return K(this,b,c)};return a}(); +h.lastIndexOf=function(){function a(c){return P(this,c,N(this))}var b=null;b=function(c,d){switch(arguments.length){case 1:return a.call(this,c);case 2:return P(this,c,d)}throw Error("Invalid arity: "+arguments.length);};b.i=a;b.j=function(c,d){return P(this,c,d)};return b}();h.Z=function(a,b){if(0<=b&&bthis.end&&this.step===0)return this.start;throw Error("Index out of bounds");}; +h.Ea=function(a,b,c){return 0<=b&&bthis.end&&this.step===0?this.start:c};h.Da=function(){return new $h(this.start,this.end,this.step)};h.fa=function(){return this.F}; +h.pa=function(){return this.step>0?this.start+this.stepthis.end?new ai(null,this.start+this.step,this.end,this.step,Math.ceil((this.end-(this.start+this.step))/this.step),null):null};h.ea=function(){return this.B};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return Ec}; +h.ua=function(a,b){return Tc(this,b)};h.va=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Rc(c))return y(c);a+=this.step}else return c};h.qa=function(){return this.start};h.ra=function(){var a=this.pa();return a==null?Ec:a};h.ga=function(){return this};h.Rb=function(){var a=this.B;return new Zh(this.start,this.step,a<32?a:32)}; +h.$a=function(){if(this.B<=32)return Ec;var a=this.start+this.step*32;return this.step>0?this.end<=a?Ec:new ai(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.step<0?this.end>=a?Ec:new ai(null,a,this.end,this.step,Math.ceil((this.end-a)/this.step),null):this.end===a?Ec:new Ef(null,-1,a,null)};h.ha=function(a,b){return b===this.F?this:new ai(b,this.start,this.end,this.step,this.B,this.I)};h.ka=function(a,b){return Q(b,this)}; +h.Xa=function(a,b){return b>0?b0?da.end)continue;else{c=a.W=new Ae(b,0,c);break a}else{c=d;break a}break}c=void 0}a.W==null&&(a.W=new Ae(b,0,32),(a.step>0?ca.end)&&(a.kc=new bi(null,c,a.end,a.step,null,null,null)))}} +h.Da=function(){return new $h(this.start,this.end,this.step)};h.fa=function(){return this.F};h.pa=function(){return this.step>0?this.start+this.stepthis.end?new bi(null,this.start+this.step,this.end,this.step,null,null,null):null};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=Ic(this)};h.$=function(a,b){return gd(this,b)};h.ma=function(){return Ec};h.ua=function(a,b){return hd(b,this)}; +h.va=function(a,b,c){for(a=this.start;;)if(this.step>0?athis.end){c=b.j?b.j(c,a):b.call(null,c,a);if(Rc(c))return y(c);a+=this.step}else return c};h.qa=function(){return this.start};h.ra=function(){var a=this.pa();return a==null?Ec:a};h.ga=function(){return this};h.Rb=function(){ci(this);return this.W};h.$a=function(){ci(this);return this.kc==null?Ec:this.kc};h.ha=function(a,b){return b===this.F?this:new bi(b,this.start,this.end,this.step,this.W,this.kc,this.I)}; +h.ka=function(a,b){return Q(b,this)};h.mc=function(){return E(this.$a(null))};bi.prototype[Ca]=function(){return Gc(this)}; +function di(a,b){return function(){function c(l,n,m){return new X(null,2,5,Y,[a.l?a.l(l,n,m):a.call(null,l,n,m),b.l?b.l(l,n,m):b.call(null,l,n,m)],null)}function d(l,n){return new X(null,2,5,Y,[a.j?a.j(l,n):a.call(null,l,n),b.j?b.j(l,n):b.call(null,l,n)],null)}function e(l){return new X(null,2,5,Y,[a.i?a.i(l):a.call(null,l),b.i?b.i(l):b.call(null,l)],null)}function f(){return new X(null,2,5,Y,[a.J?a.J():a.call(null),b.J?b.J():b.call(null)],null)}var g=null,k=function(){function l(m,p,r,w){var z=null; +if(arguments.length>3){z=0;for(var D=Array(arguments.length-3);z3){r=0;for(var w=Array(arguments.length-3);r0){e=0;for(var f=Array(arguments.length-0);e0){q=0;for(var v=Array(arguments.length- -0);q0){A=0;for(var C=Array(arguments.length-0);A0){e=0;for(var f=Array(arguments.length-0);e0){e=0;for(var f=Array(arguments.length-0);e=e&&f.push(xo(a,k));return f},[c,d],b))}}function Jp(a,b,c,d){this.tag=a;this.id=b;this.className=c;this.Qd=d}function Kp(a,b,c){var d=na(a,vo(c));d==null?Zo(a)?a=op(c,a,a):(d=xd(a),d=T.l(d,im,a),d=np(d,c),a=op(c,a,d)):a=d;c={};c.argv=b;b=io(b);b!=null&&(c.key=b);return Tn.createElement(a,c)} -function Lp(a,b,c,d){var e={};e.cb=a;e.argv=Ag.j(b,c);b=io(b);b!=null&&(e.key=b);return Tn.createElement(sp(d,a),e)}function Mp(a,b,c){var d;if(d=td(a))d=a==null?null:a.prototype,d=(d==null?null:d.render)!=null;return d?Kp(a,b,c):Lp(a,b,1,c)}var Np={}; -function Op(a,b,c,d){var e=a.tag,f=S(b,c,null),h=f==null||Od(f),k=function(){var n=h?f:null,p=xj.i(n);p=u(p)?T.l(n,xj,go.i(p)):n;var q=a.id;n=a.className;q=q!=null&&gl.i(p)==null?T.l(p,gl,q):p;if(u(n)){var v=T.l,A=go.j,C=xj.i(p);p=u(C)?C:Pl.i(p);n=v.call(T,q,xj,A.call(go,n,p))}else n=q;n=u(a.Qd)?Hp(n):Ep(n);u(u(n)?n.dangerouslySetInnerHTML:n)&&delete n.dangerouslySetInnerHTML;return u(n)?n:{}}();c+=h?1:0;a:switch(e){case "input":case "textarea":var l=!0;break a;default:l=!1}if(l){l=ho(f);var m=function(){var n= -d.Zd;if(u(n))return n;n=np(zp,d);return d.Zd=n}();return xo(d,wd(new X(null,6,5,Y,[m,b,e,k,c,d],null),Jh.u(Q([u(l)?new Z(null,1,[An,l],null):null,xd(b)]))))}l=ho(xd(b));l!=null&&(k.key=l);return yo(d,b,e,k,c)}function Pp(a,b){return Ea(yf.j(function(c){return xo(b,c)},a))} -function Qp(a,b,c){b==null&&console.error("vec-to-elem",ri(Q([a])));var d=S(a,0,null);switch(d instanceof D?d.za:null){case "\x3e":return d=S(a,1,null),Op(new Jp(d,null,null,null),a,2,b);case "r\x3e":d=S(a,1,null);c=S(a,2,null);c=u(c)?c:{};var e=ho(xd(a));e!=null&&(c.key=e);return yo(b,a,d,c,3);case "f\x3e":return Lp(S(a,1,null),a,2,b);case "\x3c\x3e":return c=S(a,1,null),d=c==null||Od(c),c=Ep(d?c:null),c=u(c)?c:{},d=1+(d?1:0),e=io(a),e!=null&&(c.key=e),yo(b,a,Tn.Fragment,c,d);default:if(d.se)b={}, -b.argv=Ag.j(a,1),a=io(a),a!=null&&(b.key=a),a=Tn.createElement(d,b);else if(fo(d)||typeof d==="string")a:{for(;;){d=S(a,0,null);c=ue(d);e=c.indexOf("\x3e");switch(e){case -1:a=Op(wo(b,c,d),a,1,b);break a;case 0:a=null;break a;default:a=wd(new X(null,2,5,Y,[c.substring(0,e),T.l(wd(a,null),0,c.substring(e+1))],null),xd(a));continue}break}a=void 0}else a=c.l?c.l(d,a,b):c.call(null,d,a,b);return a}}function Rp(a,b,c,d,e){this.Wd=a;this.id=b;this.ld=c;this.zc=d;this.Ud=e;this.v=393216;this.R=0}g=Rp.prototype; -g.ha=function(a,b){return new Rp(this.Wd,this.id,this.ld,this.zc,b)};g.fa=function(){return this.Ud};g.Yd=function(){return this.id};g.sd=function(a,b,c){return this.zc.l?this.zc.l(this,b,c):this.zc.call(null,this,b,c)};g.qd=function(a,b){return"object"!==r(b)?b:Qd(b)?Qp(b,this,this.ld):Ud(b)?Pp(b,this):fo(b)?ue(b):(b!=null?b.v&2147483648||t===b.la||(b.v?0:w(Nb,b)):w(Nb,b))?ri(Q([b])):b};g.rd=function(a,b,c,d,e){return Ip(this,b,c,d,e)}; -var Sp,Wp=W,Ps=xi("reagent-compiler"),Qs=u(Gk.i(Wp))?Mp:Kp,Rs=yc(Wp,kj,function(a,b){a=Cp(Np,b);if(a==null){a=ue(b);if(typeof a==="string"){var c=Ap.exec(a);a=c!=null&&J.j(c[0],a)?c.length===1?c[0]:sg(c):null}else throw new TypeError("re-matches must match against a string.");var d=I(a);a=S(d,0,null);c=S(d,1,null);d=S(d,2,null);d=d==null?null:Xn(d,/\./," ");var e=Ze(-1,a.indexOf("-"));a=new Jp(a,c,d,e);return Np[b]=a}return a});Sp=new Rp(Wp,Ps,Qs,Rs,W);function qt(a){Tn.useEffect(function(){var c=rs;rs=!1;try{to.flush_after_render()}finally{rs=c}});var b=rs;rs=!0;try{return Tn.createElement(a.comp)}finally{rs=b}};function Xp(){return ra("iPhone")&&!ra("iPod")&&!ra("iPad")};ra("Opera");ra("Trident")||ra("MSIE");ra("Edge");var Yp=ra("Gecko")&&!(qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge"))&&!(ra("Trident")||ra("MSIE"))&&!ra("Edge"),Zp=qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge");Zp&&ra("Mobile");ra("Macintosh");ra("Windows");ra("Linux")||ra("CrOS");ra("Android");Xp();ra("iPad");ra("iPod");Xp()||ra("iPad")||ra("iPod");qa().toLowerCase().indexOf("kaios");function $p(){var a=document;var b="IFRAME";a.contentType==="application/xhtml+xml"&&(b=b.toLowerCase());return a.createElement(b)};function aq(a,b,c){var d=a;b&&(d=ja(a,b));d=aq.ce(d);typeof aa.setImmediate==="function"&&(c||aq.be())?aa.setImmediate(d):(aq.md||(aq.md=aq.Sd()),aq.md(d))}aq.be=function(){return aa.Window&&aa.Window.prototype&&!ra("Edge")&&aa.Window.prototype.setImmediate==aa.setImmediate?!1:!0}; -aq.Sd=function(){var a=aa.MessageChannel;typeof a==="undefined"&&typeof window!=="undefined"&&window.postMessage&&window.addEventListener&&!ra("Presto")&&(a=function(){var e=$p();e.style.display="none";document.documentElement.appendChild(e);var f=e.contentWindow;e=f.document;e.open();e.close();var h="callImmediate"+Math.random(),k=f.location.protocol=="file:"?"*":f.location.protocol+"//"+f.location.host;e=ja(function(l){if((k=="*"||l.origin==k)&&l.data==h)this.port1.onmessage()},this);f.addEventListener("message", -e,!1);this.port1={};this.port2={postMessage:function(){f.postMessage(h,k)}}});if(typeof a!=="undefined"&&!ra("Trident")&&!ra("MSIE")){var b=new a,c={},d=c;b.port1.onmessage=function(){if(c.next!==void 0){c=c.next;var e=c.Yc;c.Yc=null;e()}};return function(e){d.next={Yc:e};d=d.next;b.port2.postMessage(0)}}return function(e){aa.setTimeout(e,0)}};aq.ce=function(a){return a};function bq(a,b){this.type=a;this.currentTarget=this.target=b;this.defaultPrevented=!1}bq.prototype.stopPropagation=function(){};bq.prototype.preventDefault=function(){this.defaultPrevented=!0};var cq=function(){if(!aa.addEventListener||!Object.defineProperty)return!1;var a=!1,b=Object.defineProperty({},"passive",{get:function(){a=!0}});try{let c=()=>{};aa.addEventListener("test",c,b);aa.removeEventListener("test",c,b)}catch(c){}return a}();function dq(a,b){bq.call(this,a?a.type:"");this.relatedTarget=this.currentTarget=this.target=null;this.button=this.screenY=this.screenX=this.clientY=this.clientX=this.offsetY=this.offsetX=0;this.key="";this.charCode=this.keyCode=0;this.metaKey=this.shiftKey=this.altKey=this.ctrlKey=!1;this.state=null;this.pointerId=0;this.pointerType="";this.Xb=null;a&&this.init(a,b)}ka(dq,bq);var eq={2:"touch",3:"pen",4:"mouse"}; -dq.prototype.init=function(a,b){var c=this.type=a.type,d=a.changedTouches&&a.changedTouches.length?a.changedTouches[0]:null;this.target=a.target||a.srcElement;this.currentTarget=b;if(b=a.relatedTarget){if(Yp){a:{try{la(b.nodeName);var e=!0;break a}catch(f){}e=!1}e||(b=null)}}else c=="mouseover"?b=a.fromElement:c=="mouseout"&&(b=a.toElement);this.relatedTarget=b;d?(this.clientX=d.clientX!==void 0?d.clientX:d.pageX,this.clientY=d.clientY!==void 0?d.clientY:d.pageY,this.screenX=d.screenX||0,this.screenY= -d.screenY||0):(this.offsetX=Zp||a.offsetX!==void 0?a.offsetX:a.layerX,this.offsetY=Zp||a.offsetY!==void 0?a.offsetY:a.layerY,this.clientX=a.clientX!==void 0?a.clientX:a.pageX,this.clientY=a.clientY!==void 0?a.clientY:a.pageY,this.screenX=a.screenX||0,this.screenY=a.screenY||0);this.button=a.button;this.keyCode=a.keyCode||0;this.key=a.key||"";this.charCode=a.charCode||(c=="keypress"?a.keyCode:0);this.ctrlKey=a.ctrlKey;this.altKey=a.altKey;this.shiftKey=a.shiftKey;this.metaKey=a.metaKey;this.pointerId= -a.pointerId||0;this.pointerType=typeof a.pointerType==="string"?a.pointerType:eq[a.pointerType]||"";this.state=a.state;this.Xb=a;a.defaultPrevented&&dq.Uc.preventDefault.call(this)};dq.prototype.stopPropagation=function(){dq.Uc.stopPropagation.call(this);this.Xb.stopPropagation?this.Xb.stopPropagation():this.Xb.cancelBubble=!0};dq.prototype.preventDefault=function(){dq.Uc.preventDefault.call(this);var a=this.Xb;a.preventDefault?a.preventDefault():a.returnValue=!1};var fq="closure_listenable_"+(Math.random()*1E6|0);var gq=0;function hq(a,b,c,d,e){this.listener=a;this.proxy=null;this.src=b;this.type=c;this.capture=!!d;this.handler=e;this.key=++gq;this.Bc=this.Gc=!1}function iq(a){a.Bc=!0;a.listener=null;a.proxy=null;a.src=null;a.handler=null};function jq(a){this.src=a;this.listeners={};this.Dc=0}jq.prototype.add=function(a,b,c,d,e){var f=a.toString();a=this.listeners[f];a||(a=this.listeners[f]=[],this.Dc++);var h=kq(a,b,d,e);h>-1?(b=a[h],c||(b.Gc=!1)):(b=new hq(b,this.src,f,!!d,e),b.Gc=c,a.push(b));return b}; -jq.prototype.remove=function(a,b,c,d){a=a.toString();if(!(a in this.listeners))return!1;var e=this.listeners[a];b=kq(e,b,c,d);return b>-1?(iq(e[b]),Array.prototype.splice.call(e,b,1),e.length==0&&(delete this.listeners[a],this.Dc--),!0):!1};function kq(a,b,c,d){for(var e=0;e=0)&&Array.prototype.splice.call(k,l,1);k=m}k&&(iq(a),f.listeners[h].length== -0&&(delete f.listeners[h],f.Dc--));f.Dc==0&&(f.src=null,e[lq]=null)}else iq(a)}}a=c.call(d,b)}return a}function sq(a){a=a[lq];return a instanceof jq?a:null}var wq="__closure_events_fn_"+(Math.random()*1E9>>>0);function qq(a){if(typeof a==="function")return a;a[wq]||(a[wq]=function(b){return a.handleEvent(b)});return a[wq]};function xq(a){return to.add_after_render(a)};var yq=Lf.j(Hg,nd);function zq(a){return a!=null?a.v&32768||t===a.bd?!0:a.v?!1:w(pb,a):w(pb,a)}function Aq(a,b){Ro(a,b)}function Bq(a,b){return setTimeout(a,b)}function Cq(a){return a!=null&&t===a.Ac?""+z.i(u(vc.j?vc.j(Ko,a):vc.call(null,Ko,a))?"ra":u(vc.j?vc.j(Po,a):vc.call(null,Po,a))?"rc":u(vc.j?vc.j(So,a):vc.call(null,So,a))?"rx":u(vc.j?vc.j(Oo,a):vc.call(null,Oo,a))?"tr":"other")+z.i(tc(a)):null};var Dq=Lo.i(W);var Eq=sf(new Z(null,6,[xm,console.log.bind(console),dm,console.warn.bind(console),ok,console.error.bind(console),al,console.debug.bind(console),wk,u(console.group)?console.group.bind(console):console.log.bind(console),ej,u(console.groupEnd)?console.groupEnd.bind(console):function(){return Fc}],null)),Fq=function Fq(a){for(var c=[],d=arguments.length,e=0;;)if(e0){f=0;for(var h=Array(arguments.length-0);f0)?T.l(d,Vk,a("scale")):d}();b=new Z(null,1,[on,b],null);return u(a("iter"))?T.l(b,Vp,ke.j(1,Math.round(a("iter")))):b};var Gs=Eh([$m,xl,Yk,ql,rm,Qj,dj,Kk,Vp,hj,on,Lk,yk,Si],[nd,W,null,!1,new X(null,2,5,Y,[0,0],null),W,null,1,256,W,xs,!1,!1,null]),Hs=sf(null),Is=sf(W),Js=function Js(a,b){if(u(Pf(y(Is),new X(null,2,5,Y,[a,Wj],null))))return uf.O(Is,Qf,new X(null,2,5,Y,[a,zj],null),b);var d=U(b),e=B(d,kn);b=B(d,on);var f=B(d,rm),h=B(d,jl),k=B(d,Kk);d=B(d,Vp);S(f,0,null);var l=S(f,1,null),m=S(h,0,null),n=S(h,1,null),p=Cs(b,f,h,k);uf.O(Is,T,a,new Z(null,1,[Wj,!0],null));return vs(e,p,d).then(function(q){var v=U(q);q=B(v, -Kj);var A=B(v,Ak);v=B(v,Dm);var C=Fl.i(p),F=Xl.i(p),M=y(Hs);if(u(M)){var R=document.createElement("canvas");M=M.getContext("2d");R.width=C;R.height=F;R.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(q),C,F),0,0);M.imageSmoothingEnabled=!1;M.drawImage(R,0,0,C,F,m,0,n,l)}return gs(new X(null,3,5,Y,[aj,a,new Z(null,5,[kn,e,Ak,A,Dm,v,Fl,Fl.i(p),Xl,Xl.i(p)],null)],null))}).catch(function(q){return ur(new X(null,4,5,Y,[Mj,a,e,q.message],null))}).finally(function(){var q=Pf(y(Is),new X(null, -2,5,Y,[a,zj],null));uf.O(Is,T,a,new Z(null,1,[Wj,!1],null));return u(q)?Js.j?Js.j(a,q):Js.call(null,a,q):null})};Pq(Pi,pn,function(a){var b=U(a),c=B(b,on),d=B(b,rm);a=B(b,ql);var e=B(b,Si),f=B(b,dj),h=B(b,Kk),k=B(b,Vp);b=S(d,0,null);var l=S(d,1,null);var m=b>=8;l=m?(l=l>=8)?e:l:m;return u(l)?(l=u(a)?Math.floor(b/2):b,c=new Z(null,4,[on,c,rm,d,Kk,h,Vp,k],null),Js(em,T.u(c,kn,e,Q([jl,new X(null,2,5,Y,[0,l],null)]))),u(u(a)?f:a)?Js(Wl,T.u(c,kn,f,Q([jl,new X(null,2,5,Y,[l,b-l],null)]))):null):null}); -Pq(Pi,Kn,function(a){var b=S(a,0,null),c=S(a,1,null);a=history;var d=a.replaceState,e=U(b);b=B(e,Ej);var f=B(e,Aj);e=B(e,Vk);c=""+z.i("#")+z.i(new URLSearchParams({cx:b,cy:f,scale:e,iter:c}));return d.call(a,null,"",c)});function Ks(a){return new Z(null,2,[Hj,a,Pi,new X(null,2,5,Y,[new X(null,2,5,Y,[pn,Kh(a,new X(null,7,5,Y,[on,rm,ql,Si,dj,Kk,Vp],null))],null),new X(null,2,5,Y,[Kn,new X(null,2,5,Y,[on.i(a),Vp.i(a)],null)],null)],null)],null)}is.j(nj,function(){return Gs}); -js(el,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=sg(b);return Ks(T.u(a,$m,c,Q([Lk,!0,Si,H(c),dj,function(){var d=H(Hf(function(e){return e.startsWith("server-")},c));return u(d)?d:H(c)}()])))});js(Up,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,rm,b))}); -js(Ik,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=Jh.u(Q([on.i(a),Kh(b,new X(null,3,5,Y,[Ej,Aj,Vk],null))]));var d=U(c);var e=B(d,Ej),f=B(d,Aj);d=B(d,Vk);e=Number.isFinite(e);u(e)?(f=Number.isFinite(f),u(f)?(f=Number.isFinite(d),d=u(f)?d>0:f):d=f):d=e;a=u(d)?T.l(a,on,c):a;a=u(Vp.i(b))?T.l(a,Vp,ke.j(1,Math.round(Vp.i(b)))):a;return Ks(a)}); -js(tj,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return Ks(Sf.Fa(a,on,zs,rm.i(a),new X(null,2,5,Y,[c,d],null),b))});js(Vi,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);var c=rm.i(a),d=S(c,0,null);c=S(c,1,null);return Ks(Sf.Fa(a,on,zs,rm.i(a),new X(null,2,5,Y,[d/2,c/2],null),b))});js(Ti,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return Ks(Sf.O(a,on,As,new X(null,2,5,Y,[c,b],null)))}); -js(Yi,function(a){a=U(a);a=B(a,Hj);return Ks(T.u(a,on,xs,Q([yk,!1])))});js(ek,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,Vp,b))});js(Fm,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);b=S(b,1,null);return Ks(T.l(a,Kk,b))});js(vn,function(a){a=U(a);a=B(a,Hj);return Ks(Sf.l(a,ql,ya))});js($l,function(a,b){a=U(a);a=B(a,Hj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return Ks(T.l(a,c,b))}); -is.j(aj,function(a,b){S(b,0,null);var c=S(b,1,null);b=S(b,2,null);b=U(b);var d=B(b,kn),e=B(b,Ak),f=B(b,Dm);return Rf.l(Rf.l(Qf(a,new X(null,2,5,Y,[hj,c],null),b),new X(null,2,5,Y,[Qj,d],null),mf(function(h){var k=S(h,0,null),l=S(h,1,null);h=S(h,2,null);return new X(null,3,5,Y,[k+e,l+f,h+1],null)},new X(null,3,5,Y,[0,0,0],null))),new X(null,2,5,Y,[xl,d],null),function(h){return sg(Cf(120,md.j(u(h)?h:nd,e)))})}); -is.j(Mj,function(a,b){S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return Qf(a,new X(null,2,5,Y,[hj,c],null),new Z(null,2,[kn,d,ok,b],null))});var Ls=sf(null);Pq(Pi,gk,function(a){var b=y(Ls);u(b)&&(clearInterval(b),tf(Ls,null));return u(a)?tf(Ls,setInterval(function(){return ur(new X(null,1,5,Y,[Ol],null))},50)):null});js(uj,function(a){a=U(a);a=B(a,Hj);var b=ya(yk.i(a));return new Z(null,2,[Hj,T.l(a,yk,b),Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[gk,b],null)],null)],null)}); -js(nn,function(a){a=U(a);a=B(a,Hj);return new Z(null,2,[Hj,T.l(a,yk,!1),Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[gk,!1],null)],null)],null)});js(Ol,function(a){a=U(a);var b=B(a,Hj);a=Ds(on.i(b));var c=T.u;var d=Vp.i(b);d=le.j(4096,ke.j(d,Math.round(200+60*Math.log2(Bs(a)))));b=c.call(T,b,on,a,Q([Vp,d]));return Vk.i(a)<1E-13?Sf.O(Ks(T.l(b,yk,!1)),Pi,md,new X(null,2,5,Y,[gk,!1],null)):Ks(b)}); -var Ms=new X(null,4,5,Y,[new X(null,5,5,Y,["full set",-.6,0,3/256,256],null),new X(null,5,5,Y,["seahorse valley",-.745,.112,3.125E-5,512],null),new X(null,5,5,Y,["spiral",-.10109636384562,.95628651080914,1.953125E-6,1024],null),new X(null,5,5,Y,["deep pinch",-1.768778833,.001738996,3.90625E-9,1024],null)],null); -function Ns(a,b){return ef(function(c){var d=S(c,0,null);c=Hf(function(f){var h=J.j(d,on.i(f));return h?(h=Om.i(f),u(h)?ya(ok.i(f)):h):h},a);var e=E(c);return e?Ue(J,yf.j(ai(Dm,Zk),c)):e},b)} -function Os(a){var b=U(a),c=B(b,$m),d=yc(b,Fl,256),e=yc(b,Xl,192),f=yc(b,Fj,Ms);ur(new X(null,2,5,Y,[Rm,new Z(null,4,[Fl,d,Xl,e,$m,c,Fj,f],null)],null));var h=function(){return function m(l){return new ve(null,function(){for(var n=l;;){var p=E(n);if(p){var q=p,v=H(q),A=S(v,0,null),C=S(v,1,null),F=S(v,2,null),M=S(v,3,null),R=S(v,4,null);if(p=E(function(V,ia,ta,Fb,ic,Rc,Cd,Dd,jc,Ed,bh,De,Fd,Sc,Ac){return function Gd(fg){return new ve(null,function(Hd,$d,wb,Xb,Bb,kc,Ya,ae,Id,Ee,Fe,be,Ge,He){return function(){for(;;){var Tc= -E(fg);if(Tc){if(Rd(Tc)){var Jb=Yb(Tc),Jd=N(Jb),Uc=ze(Jd);a:{for(var Vc,Ie=0;;)if(Ie0?gs(new X(null,4,5,Y,[tj,(H(n)+H(p))/2,(kd(n)+kd(p))/2,v/q],null)):null;uf.O(c,T,n.pointerId,p);return gs(new X(null,3,5,Y,[Ti,q-H(p),v-kd(p)],null))}return null});for(var d=E(new X(null,2,5,Y,["pointerup","pointercancel"],null)),e=null,f=0,h=0;;)if(h1){e.strokeStyle=bt(b,p);e.beginPath();p=E(nf(n));n=null;for(var q=0,v=0;;)if(v1){e.strokeStyle=bt(b,p);e.beginPath();k=E(nf(n));l=null;for(p=m=0;;)if(p0){e=0;for(var f=Array(arguments.length-0);e0){r=0;for(var w=Array(arguments.length- +0);r0){z=0;for(var D=Array(arguments.length-0);z0){e=0;for(var f=Array(arguments.length-0);e0){e=0;for(var f=Array(arguments.length-0);e=e&&f.push(Vo(a,k));return f},[c,d],b))}}function gq(a,b,c,d){this.tag=a;this.id=b;this.className=c;this.Td=d}function hq(a,b,c){var d=na(a,To(c));d==null?wp(a)?a=Jp(c,a,a):(d=Bd(a),d=T.l(d,wm,a),d=Ip(d,c),a=Jp(c,a,d)):a=d;c={};c.argv=b;b=Go(b);b!=null&&(c.key=b);return po.createElement(a,c)} +function iq(a,b,c,d){var e={};e.eb=a;e.argv=Eg.j(b,c);b=Go(b);b!=null&&(e.key=b);return po.createElement(Np(d,a),e)}function jq(a,b,c){var d;if(d=td(a))d=a==null?null:a.prototype,d=(d==null?null:d.render)!=null;return d?hq(a,b,c):iq(a,b,1,c)}var kq={}; +function lq(a,b,c,d){var e=a.tag,f=S(b,c,null),g=f==null||Jd(f),k=function(){var m=g?f:null,p=Ej.i(m);p=u(p)?T.l(m,Ej,Do.i(p)):m;var r=a.id;m=a.className;r=r!=null&&pl.i(p)==null?T.l(p,pl,r):p;if(u(m)){var w=T.l,z=Do.j,D=Ej.i(p);p=u(D)?D:bm.i(p);m=w.call(T,r,Ej,z.call(Do,m,p))}else m=r;m=u(a.Td)?eq(m):bq(m);u(u(m)?m.dangerouslySetInnerHTML:m)&&delete m.dangerouslySetInnerHTML;return u(m)?m:{}}();c+=g?1:0;a:switch(e){case "input":case "textarea":var l=!0;break a;default:l=!1}if(l){l=Fo(f);var n=function(){var m= +d.be;if(u(m))return m;m=Ip(Xp,d);return d.be=m}();return Vo(d,Ad(new X(null,6,5,Y,[n,b,e,k,c,d],null),Mh.o(R([u(l)?new Z(null,1,[Wn,l],null):null,Bd(b)]))))}l=Fo(Bd(b));l!=null&&(k.key=l);return Wo(d,b,e,k,c)}function mq(a,b){return Ea(zf.j(function(c){return Vo(b,c)},a))} +function nq(a,b,c){b==null&&console.error("vec-to-elem",ti(R([a])));var d=S(a,0,null);switch(d instanceof C?d.za:null){case "\x3e":return d=S(a,1,null),lq(new gq(d,null,null,null),a,2,b);case "r\x3e":d=S(a,1,null);c=S(a,2,null);c=u(c)?c:{};var e=Fo(Bd(a));e!=null&&(c.key=e);return Wo(b,a,d,c,3);case "f\x3e":return iq(S(a,1,null),a,2,b);case "\x3c\x3e":return c=S(a,1,null),d=c==null||Jd(c),c=bq(d?c:null),c=u(c)?c:{},d=1+(d?1:0),e=Go(a),e!=null&&(c.key=e),Wo(b,a,po.Fragment,c,d);default:if(d.ve)b={}, +b.argv=Eg.j(a,1),a=Go(a),a!=null&&(b.key=a),a=po.createElement(d,b);else if(Co(d)||typeof d==="string")a:{for(;;){d=S(a,0,null);c=we(d);e=c.indexOf("\x3e");switch(e){case -1:a=lq(Uo(b,c,d),a,1,b);break a;case 0:a=null;break a;default:a=Ad(new X(null,2,5,Y,[c.substring(0,e),T.l(Ad(a,null),0,c.substring(e+1))],null),Bd(a));continue}break}a=void 0}else a=c.l?c.l(d,a,b):c.call(null,d,a,b);return a}}function oq(a,b,c,d,e){this.Zd=a;this.id=b;this.od=c;this.Cc=d;this.Xd=e;this.v=393216;this.R=0}h=oq.prototype; +h.ha=function(a,b){return new oq(this.Zd,this.id,this.od,this.Cc,b)};h.fa=function(){return this.Xd};h.ae=function(){return this.id};h.vd=function(a,b,c){return this.Cc.l?this.Cc.l(this,b,c):this.Cc.call(null,this,b,c)};h.td=function(a,b){return"object"!==q(b)?b:Ld(b)?nq(b,this,this.od):Zd(b)?mq(b,this):Co(b)?we(b):(b!=null?b.v&2147483648||t===b.la||(b.v?0:v(Jb,b)):v(Jb,b))?ti(R([b])):b};h.ud=function(a,b,c,d,e){return fq(this,b,c,d,e)}; +var pq,qq=W,rq=zi("reagent-compiler"),sq=u(Qk.i(qq))?jq:hq,tq=zc(qq,pj,function(a,b){a=$p(kq,b);if(a==null){a=we(b);if(typeof a==="string"){var c=Yp.exec(a);a=c!=null&&J.j(c[0],a)?c.length===1?c[0]:qg(c):null}else throw new TypeError("re-matches must match against a string.");var d=I(a);a=S(d,0,null);c=S(d,1,null);d=S(d,2,null);d=d==null?null:uo(d,/\./," ");var e=Ye(-1,a.indexOf("-"));a=new gq(a,c,d,e);return kq[b]=a}return a});pq=new oq(qq,rq,sq,tq,W);function uq(a){po.useEffect(function(){var c=Eo;Eo=!1;try{Ro.flush_after_render()}finally{Eo=c}});var b=Eo;Eo=!0;try{return po.createElement(a.comp)}finally{Eo=b}};function vq(){return ra("iPhone")&&!ra("iPod")&&!ra("iPad")};ra("Opera");ra("Trident")||ra("MSIE");ra("Edge");var wq=ra("Gecko")&&!(qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge"))&&!(ra("Trident")||ra("MSIE"))&&!ra("Edge"),xq=qa().toLowerCase().indexOf("webkit")!=-1&&!ra("Edge");xq&&ra("Mobile");ra("Macintosh");ra("Windows");ra("Linux")||ra("CrOS");ra("Android");vq();ra("iPad");ra("iPod");vq()||ra("iPad")||ra("iPod");qa().toLowerCase().indexOf("kaios");function yq(){var a=document;var b="IFRAME";a.contentType==="application/xhtml+xml"&&(b=b.toLowerCase());return a.createElement(b)};function zq(a,b,c){var d=a;b&&(d=ia(a,b));d=zq.fe(d);typeof aa.setImmediate==="function"&&(c||zq.ee())?aa.setImmediate(d):(zq.pd||(zq.pd=zq.Vd()),zq.pd(d))}zq.ee=function(){return aa.Window&&aa.Window.prototype&&!ra("Edge")&&aa.Window.prototype.setImmediate==aa.setImmediate?!1:!0}; +zq.Vd=function(){var a=aa.MessageChannel;typeof a==="undefined"&&typeof window!=="undefined"&&window.postMessage&&window.addEventListener&&!ra("Presto")&&(a=function(){var e=yq();e.style.display="none";document.documentElement.appendChild(e);var f=e.contentWindow;e=f.document;e.open();e.close();var g="callImmediate"+Math.random(),k=f.location.protocol=="file:"?"*":f.location.protocol+"//"+f.location.host;e=ia(function(l){if((k=="*"||l.origin==k)&&l.data==g)this.port1.onmessage()},this);f.addEventListener("message", +e,!1);this.port1={};this.port2={postMessage:function(){f.postMessage(g,k)}}});if(typeof a!=="undefined"&&!ra("Trident")&&!ra("MSIE")){var b=new a,c={},d=c;b.port1.onmessage=function(){if(c.next!==void 0){c=c.next;var e=c.ad;c.ad=null;e()}};return function(e){d.next={ad:e};d=d.next;b.port2.postMessage(0)}}return function(e){aa.setTimeout(e,0)}};zq.fe=function(a){return a};function Aq(a,b){this.type=a;this.currentTarget=this.target=b;this.defaultPrevented=!1}Aq.prototype.stopPropagation=function(){};Aq.prototype.preventDefault=function(){this.defaultPrevented=!0};var Bq=function(){if(!aa.addEventListener||!Object.defineProperty)return!1;var a=!1,b=Object.defineProperty({},"passive",{get:function(){a=!0}});try{let c=()=>{};aa.addEventListener("test",c,b);aa.removeEventListener("test",c,b)}catch(c){}return a}();function Cq(a,b){Aq.call(this,a?a.type:"");this.relatedTarget=this.currentTarget=this.target=null;this.button=this.screenY=this.screenX=this.clientY=this.clientX=this.offsetY=this.offsetX=0;this.key="";this.charCode=this.keyCode=0;this.metaKey=this.shiftKey=this.altKey=this.ctrlKey=!1;this.state=null;this.pointerId=0;this.pointerType="";this.Yb=null;a&&this.init(a,b)}ja(Cq,Aq);var Dq={2:"touch",3:"pen",4:"mouse"}; +Cq.prototype.init=function(a,b){var c=this.type=a.type,d=a.changedTouches&&a.changedTouches.length?a.changedTouches[0]:null;this.target=a.target||a.srcElement;this.currentTarget=b;if(b=a.relatedTarget){if(wq){a:{try{la(b.nodeName);var e=!0;break a}catch(f){}e=!1}e||(b=null)}}else c=="mouseover"?b=a.fromElement:c=="mouseout"&&(b=a.toElement);this.relatedTarget=b;d?(this.clientX=d.clientX!==void 0?d.clientX:d.pageX,this.clientY=d.clientY!==void 0?d.clientY:d.pageY,this.screenX=d.screenX||0,this.screenY= +d.screenY||0):(this.offsetX=xq||a.offsetX!==void 0?a.offsetX:a.layerX,this.offsetY=xq||a.offsetY!==void 0?a.offsetY:a.layerY,this.clientX=a.clientX!==void 0?a.clientX:a.pageX,this.clientY=a.clientY!==void 0?a.clientY:a.pageY,this.screenX=a.screenX||0,this.screenY=a.screenY||0);this.button=a.button;this.keyCode=a.keyCode||0;this.key=a.key||"";this.charCode=a.charCode||(c=="keypress"?a.keyCode:0);this.ctrlKey=a.ctrlKey;this.altKey=a.altKey;this.shiftKey=a.shiftKey;this.metaKey=a.metaKey;this.pointerId= +a.pointerId||0;this.pointerType=typeof a.pointerType==="string"?a.pointerType:Dq[a.pointerType]||"";this.state=a.state;this.Yb=a;a.defaultPrevented&&Cq.Xc.preventDefault.call(this)};Cq.prototype.stopPropagation=function(){Cq.Xc.stopPropagation.call(this);this.Yb.stopPropagation?this.Yb.stopPropagation():this.Yb.cancelBubble=!0};Cq.prototype.preventDefault=function(){Cq.Xc.preventDefault.call(this);var a=this.Yb;a.preventDefault?a.preventDefault():a.returnValue=!1};var Eq="closure_listenable_"+(Math.random()*1E6|0);var Fq=0;function Gq(a,b,c,d,e){this.listener=a;this.proxy=null;this.src=b;this.type=c;this.capture=!!d;this.handler=e;this.key=++Fq;this.Ec=this.Jc=!1}function Hq(a){a.Ec=!0;a.listener=null;a.proxy=null;a.src=null;a.handler=null};function Iq(a){this.src=a;this.listeners={};this.Gc=0}Iq.prototype.add=function(a,b,c,d,e){var f=a.toString();a=this.listeners[f];a||(a=this.listeners[f]=[],this.Gc++);var g=Jq(a,b,d,e);g>-1?(b=a[g],c||(b.Jc=!1)):(b=new Gq(b,this.src,f,!!d,e),b.Jc=c,a.push(b));return b}; +Iq.prototype.remove=function(a,b,c,d){a=a.toString();if(!(a in this.listeners))return!1;var e=this.listeners[a];b=Jq(e,b,c,d);return b>-1?(Hq(e[b]),Array.prototype.splice.call(e,b,1),e.length==0&&(delete this.listeners[a],this.Gc--),!0):!1};function Jq(a,b,c,d){for(var e=0;e=0)&&Array.prototype.splice.call(k,l,1);k=n}k&&(Hq(a),f.listeners[g].length== +0&&(delete f.listeners[g],f.Gc--));f.Gc==0&&(f.src=null,e[Kq]=null)}else Hq(a)}}a=c.call(d,b)}return a}function Rq(a){a=a[Kq];return a instanceof Iq?a:null}var Vq="__closure_events_fn_"+(Math.random()*1E9>>>0);function Pq(a){if(typeof a==="function")return a;a[Vq]||(a[Vq]=function(b){return a.handleEvent(b)});return a[Vq]};function Wq(a){return Ro.add_after_render(a)};var Xq=Mf.j(Lg,nd);function Yq(a){return a!=null?a.v&32768||t===a.ed?!0:a.v?!1:v(pb,a):v(pb,a)}function Zq(a,b){op(a,b)}function $q(a,b){return setTimeout(a,b)}function ar(a){return a!=null&&t===a.Dc?""+A.i(u(wc.j?wc.j(hp,a):wc.call(null,hp,a))?"ra":u(wc.j?wc.j(mp,a):wc.call(null,mp,a))?"rc":u(wc.j?wc.j(pp,a):wc.call(null,pp,a))?"rx":u(wc.j?wc.j(lp,a):wc.call(null,lp,a))?"tr":"other")+A.i(qc(a)):null};var br=ip.i(W);var cr=qf(new Z(null,6,[Mm,console.log.bind(console),rm,console.warn.bind(console),xk,console.error.bind(console),jl,console.debug.bind(console),Gk,u(console.group)?console.group.bind(console):console.log.bind(console),jj,u(console.groupEnd)?console.groupEnd.bind(console):function(){return Ec}],null)),dr=function dr(a){for(var c=[],d=arguments.length,e=0;;)if(e0){f=0;for(var g=Array(arguments.length-0);f0)?T.l(f,cl,a("scale")):f}();b=u(function(){var f=a("jr");return u(f)?a("ji"):f}())?new X(null,2,5,Y,[a("jr"),a("ji")],null):null;var e=E(c.get("formula"))?c.get("formula"):null;d=new Z(null,1,[vn,d],null);d=u(a("iter"))?T.l(d,ko,me.j(1,Math.round(a("iter")))): +d;return u(u(e)?e:b)?T.l(d,Qj,new Z(null,2,[Sk,u(e)?e:Sk.i(Ms),tj,b],null)):d};function Ws(a){if(a!=null&&a.zc!=null)a=a.zc(a);else{var b=Ws[q(a==null?null:a)];if(b!=null)a=b.i?b.i(a):b.call(null,a);else if(b=Ws._,b!=null)a=b.i?b.i(a):b.call(null,a);else throw x("IRenderer.id",a);}return a}function Xs(a){if(a!=null&&a.yc!=null)a=a.yc(a);else{var b=Xs[q(a==null?null:a)];if(b!=null)a=b.i?b.i(a):b.call(null,a);else if(b=Xs._,b!=null)a=b.i?b.i(a):b.call(null,a);else throw x("IRenderer.exact?",a);}return a} +function Ys(a){if(a!=null&&a.xc!=null)a=a.xc(a);else{var b=Ys[q(a==null?null:a)];if(b!=null)a=b.i?b.i(a):b.call(null,a);else if(b=Ys._,b!=null)a=b.i?b.i(a):b.call(null,a);else throw x("IRenderer.caps",a);}return a}function Zs(a,b,c){if(a!=null&&a.Ac!=null)a=a.Ac(a,b,c);else{var d=Zs[q(a==null?null:a)];if(d!=null)a=d.l?d.l(a,b,c):d.call(null,a,b,c);else if(d=Zs._,d!=null)a=d.l?d.l(a,b,c):d.call(null,a,b,c);else throw x("IRenderer.render",a);}return a} +var $s=new Z(null,2,[Ti,new Qh(null,new Z(null,1,["mandelbrot",null],null),null),rn,!1],null);function at(a,b){var c=U(a);a=B(c,Ti);c=B(c,rn);var d=U(b);b=B(d,Sk);d=B(d,tj);return ce(a,u(b)?b:"mandelbrot")&&(d==null||$d(c))}function bt(a,b){return at(Ys(a),b)}function ct(a,b,c,d,e,f){this.rid=a;this.exports=b;this.Ta=c;this.ta=d;this.V=e;this.I=f;this.v=2230716170;this.R=139264}h=ct.prototype;h.xa=function(a,b){return this.da(null,b,null)}; +h.da=function(a,b,c){switch(b instanceof C?b.za:null){case "rid":return this.rid;case "exports":return this.exports;case "simd?":return this.Ta;default:return zc(this.V,b,c)}};h.ab=function(a,b,c){return Ga(function(d,e){var f=S(e,0,null);e=S(e,1,null);return b.l?b.l(d,f,e):b.call(null,d,f,e)},c,this)}; +h.Y=function(a,b,c){return fi(b,function(d){return fi(b,oi,""," ","",c,d)},"#fractal.engines.WasmKernel{",", ","}",c,He.j(new X(null,3,5,Y,[new X(null,2,5,Y,[Ek,this.rid],null),new X(null,2,5,Y,[Xk,this.exports],null),new X(null,2,5,Y,[ho,this.Ta],null)],null),this.V))};h.Da=function(){return new Pg(this,3,new X(null,3,5,Y,[Ek,Xk,ho],null),u(this.V)?bc(this.V):new $e(W))};h.fa=function(){return this.ta};h.ea=function(){return 3+N(this.V)}; +h.ca=function(){var a=this.I;return a!=null?a:this.I=a=1152868295^Kc(this)};h.$=function(a,b){return b!=null&&this.constructor===b.constructor&&J.j(this.rid,b.rid)&&J.j(this.exports,b.exports)&&J.j(this.Ta,b.Ta)&&J.j(this.V,b.V)};h.zc=function(){return this.rid};h.yc=function(){return!0};h.xc=function(){return $s}; +h.Ac=function(a,b,c){b=U(b);var d=B(b,Lj),e=B(b,Hj),f=B(b,cl);a=B(b,Rl);b=B(b,km);var g=u(this.Ta)?this.exports.render_simd:this.exports.render,k=performance.now();this.exports.set_view(d,e,f);c=Number(g.l?g.l(a,b,c):g.call(null,a,b,c));d=performance.now()-k;return Promise.resolve(new Z(null,3,[Sj,new Uint8ClampedArray(this.exports.memory.buffer,this.exports.fb_ptr(),a*b*4),Kk,d,Sm,c],null))}; +h.Hb=function(a,b){return ce(new Qh(null,new Z(null,3,[ho,null,Xk,null,Ek,null],null),null),b)?sd.j(tb(Mf.j(W,this),this.ta),b):new ct(this.rid,this.exports,this.Ta,this.ta,Ze(sd.j(this.V,b)),null)};h.Wa=function(a,b){switch(b instanceof C?b.za:null){case "rid":case "exports":case "simd?":return!0;default:return ce(this.V,b)}}; +h.Ia=function(a,b,c){return u(te.j?te.j(Ek,b):te.call(null,Ek,b))?new ct(c,this.exports,this.Ta,this.ta,this.V,null):u(te.j?te.j(Xk,b):te.call(null,Xk,b))?new ct(this.rid,c,this.Ta,this.ta,this.V,null):u(te.j?te.j(ho,b):te.call(null,ho,b))?new ct(this.rid,this.exports,c,this.ta,this.V,null):new ct(this.rid,this.exports,this.Ta,this.ta,T.l(this.V,b,c),null)};h.ga=function(){return E(He.j(new X(null,3,5,Y,[new jg(Ek,this.rid),new jg(Xk,this.exports),new jg(ho,this.Ta)],null),this.V))}; +h.ha=function(a,b){return new ct(this.rid,this.exports,this.Ta,b,this.V,this.I)};h.ka=function(a,b){return Ld(b)?this.Ia(null,Ra(b,0),Ra(b,1)):Ga(Pa,this,b)};var dt=qf(new Uint8ClampedArray(0));function et(a,b,c){this.ta=a;this.V=b;this.I=c;this.v=2230716170;this.R=139264}h=et.prototype;h.xa=function(a,b){return this.da(null,b,null)};h.da=function(a,b,c){return zc(this.V,b,c)}; +h.ab=function(a,b,c){return Ga(function(d,e){var f=S(e,0,null);e=S(e,1,null);return b.l?b.l(d,f,e):b.call(null,d,f,e)},c,this)};h.Y=function(a,b,c){return fi(b,function(d){return fi(b,oi,""," ","",c,d)},"#fractal.engines.JsRaster{",", ","}",c,He.j(nd,this.V))};h.Da=function(){return new Pg(this,0,nd,u(this.V)?bc(this.V):new $e(W))};h.fa=function(){return this.ta};h.ea=function(){return 0+N(this.V)};h.ca=function(){var a=this.I;return a!=null?a:this.I=a=-2062414691^Kc(this)}; +h.$=function(a,b){return b!=null&&this.constructor===b.constructor&&J.j(this.V,b.V)};h.zc=function(){return"browser-js"};h.yc=function(){return!0};h.xc=function(){return $s}; +h.Ac=function(a,b,c){var d=U(b);a=B(d,Lj);b=B(d,Hj);var e=B(d,cl),f=B(d,Rl),g=B(d,km);d=performance.now();y(dt).length=8;n=m?(n=n>=8)?e:n:m;return u(n)?(n=u(a)?Math.floor(b/2):b,c=new Z(null,5,[vn,c,Dm,d,$k,g,ko,k,Qj,l],null),xt(nm,T.o(c,Fn,e,R([yl,new X(null,2,5,Y,[0,n],null)]))),u(u(a)?f:a)?xt(gm,T.o(c,Fn,f,R([yl,new X(null,2,5,Y,[n,b-n],null)]))):null):null}); +nr(Pi,Yn,function(a){var b=S(a,0,null),c=S(a,1,null),d=S(a,2,null);a=history;var e=a.replaceState,f=U(b);b=B(f,Lj);var g=B(f,Hj);f=B(f,cl);var k=U(d);d=B(k,Sk);k=B(k,tj);c=new URLSearchParams({cx:b,cy:g,scale:f,iter:c});u(u(d)?Ye(d,Sk.i(Ms)):d)&&c.set("formula",d);u(k)&&(b=S(k,0,null),g=S(k,1,null),c.set("jr",b),c.set("ji",g));c=""+A.i("#")+A.i(c);return e.call(a,null,"",c)}); +function yt(a){return new Z(null,2,[Oj,a,Pi,new X(null,2,5,Y,[new X(null,2,5,Y,[An,Nh(a,new X(null,8,5,Y,[vn,Dm,Fl,Si,ij,$k,ko,Qj],null))],null),new X(null,2,5,Y,[Yn,new X(null,3,5,Y,[vn.i(a),ko.i(a),Qj.i(a)],null)],null)],null)],null)}Hs.j(sj,function(){return qt});Is(xl,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);var c=U(b);b=B(c,tn);var d=B(c,bn);c=B(c,Wi);return yt(st(T.o(a,jn,b,R([bn,d,Wi,c,fl,!0]))))}); +Is(go,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);return yt(T.l(a,Dm,b))});Is(Wk,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);var c=Mh.o(R([vn.i(a),Nh(b,new X(null,3,5,Y,[Lj,Hj,cl],null))]));var d=U(c);var e=B(d,Lj),f=B(d,Hj);d=B(d,cl);e=Number.isFinite(e);u(e)?(f=Number.isFinite(f),u(f)?(f=Number.isFinite(d),d=u(f)?d>0:f):d=f):d=e;a=u(d)?T.l(a,vn,c):a;a=u(ko.i(b))?T.l(a,ko,me.j(1,Math.round(ko.i(b)))):a;return yt(a)}); +Is(Cj,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);return yt(st(T.l(a,Qj,Mh.o(R([Ms,b])))))});Is(kk,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);return yt(tt(a,new Z(null,2,[Sk,b,tj,null],null)))});Is(Gl,function(a){a=U(a);a=B(a,Oj);var b=Qj.i(a);b=U(b);var c=B(b,Sk);b=B(b,tj);var d=tj.i(H(If(function(e){return J.j(c,pl.i(e))},Wi.i(a))));return yt(tt(a,new Z(null,2,[Sk,c,tj,u(b)?null:u(d)?d:new X(null,2,5,Y,[0,0],null)],null)))}); +Is(lk,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);var c=S(b,1,null),d=S(b,2,null);if(u(tj.i(Qj.i(a))))return new Z(null,1,[Oj,a],null);var e=vn.i(a);b=Dm.i(a);c=new X(null,2,5,Y,[c,d],null);var f=U(e);e=B(f,Lj);d=B(f,Hj);f=B(f,cl);var g=S(b,0,null);b=S(b,1,null);var k=S(c,0,null);c=S(c,1,null);b=new X(null,2,5,Y,[e+(k-g/2)*f,d+(c-b/2)*f],null);return yt(tt(a,T.l(Qj.i(a),tj,b)))}); +Is(zj,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return yt(Uf.Fa(a,vn,Ps,Dm.i(a),new X(null,2,5,Y,[c,d],null),b))});Is(Zi,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);var c=Dm.i(a),d=S(c,0,null);c=S(c,1,null);return yt(Uf.Fa(a,vn,Ps,Dm.i(a),new X(null,2,5,Y,[d/2,c/2],null),b))});Is(Ui,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return yt(Uf.O(a,vn,Qs,new X(null,2,5,Y,[c,b],null)))}); +Is(cj,function(a){a=U(a);a=B(a,Oj);return yt(T.o(a,Rk,!1,R([vn,J.j(Ms,Qj.i(a))?Ks:Os(Wi.i(a),Qj.i(a),H(Dm.i(a)))])))});Is(pk,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);return yt(T.l(a,ko,b))});Is(Om,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);b=S(b,1,null);return yt(T.l(a,$k,b))});Is(Mn,function(a){a=U(a);a=B(a,Oj);return yt(Uf.l(a,Fl,ya))});Is(jm,function(a,b){a=U(a);a=B(a,Oj);S(b,0,null);var c=S(b,1,null);b=S(b,2,null);return yt(T.l(a,c,b))}); +Hs.j(fj,function(a,b){S(b,0,null);var c=S(b,1,null);b=S(b,2,null);b=U(b);var d=B(b,Fn),e=B(b,Kk),f=B(b,Sm);return Tf.l(Tf.l(Sf(a,new X(null,2,5,Y,[mj,c],null),b),new X(null,2,5,Y,[Yj,d],null),kf(function(g){var k=S(g,0,null),l=S(g,1,null);g=S(g,2,null);return new X(null,3,5,Y,[k+e,l+f,g+1],null)},new X(null,3,5,Y,[0,0,0],null))),new X(null,2,5,Y,[Nl,d],null),function(g){return qg(Df(120,md.j(u(g)?g:nd,e)))})}); +Hs.j(Uj,function(a,b){S(b,0,null);var c=S(b,1,null),d=S(b,2,null);b=S(b,3,null);return Sf(a,new X(null,2,5,Y,[mj,c],null),new Z(null,2,[Fn,d,xk,b],null))});var zt=qf(null);nr(Pi,Ak,function(a){var b=y(zt);u(b)&&(clearInterval(b),rf(zt,null));return u(a)?rf(zt,setInterval(function(){return Tr(new X(null,1,5,Y,[am],null))},50)):null}); +Is(Aj,function(a){a=U(a);var b=B(a,Oj);a=ya(Rk.i(b));b=T.l(b,Rk,a);b=a?st(T.l(b,Qj,Ms)):b;return new Z(null,2,[Oj,b,Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[Ak,a],null)],null)],null)});Is(qn,function(a){a=U(a);a=B(a,Oj);return new Z(null,2,[Oj,T.l(a,Rk,!1),Pi,new X(null,1,5,Y,[new X(null,2,5,Y,[Ak,!1],null)],null)],null)}); +Is(am,function(a){a=U(a);var b=B(a,Oj);a=Ts(vn.i(b));var c=T.o;var d=ko.i(b);d=ne.j(4096,me.j(d,Math.round(200+60*Math.log2(Rs(a)))));b=c.call(T,b,vn,a,R([ko,d]));return cl.i(a)<1E-13?Uf.O(yt(T.l(b,Rk,!1)),Pi,md,new X(null,2,5,Y,[Ak,!1],null)):yt(b)}); +var At=new X(null,6,5,Y,[new X(null,6,5,Y,["full set",-.6,0,3/256,256,Ms],null),new X(null,6,5,Y,["seahorse valley",-.745,.112,3.125E-5,512,Ms],null),new X(null,6,5,Y,["spiral",-.10109636384562,.95628651080914,1.953125E-6,1024,Ms],null),new X(null,6,5,Y,["deep pinch",-1.768778833,.001738996,3.90625E-9,1024,Ms],null),new X(null,6,5,Y,["burning ship",-1.762,-.028,2.34375E-4,512,new Z(null,2,[Sk,"burning-ship",tj,null],null)],null),new X(null,6,5,Y,["multibrot3 julia",0,0,3.4/256,512,new Z(null,2,[Sk, +"multibrot3",tj,new X(null,2,5,Y,[-.2,.75],null)],null)],null)],null);function Bt(a,b){return df(function(c){var d=S(c,0,null);c=If(function(f){var g=J.j(d,vn.i(f));return g?(g=Vm.i(f),u(g)?ya(xk.i(f))&&ya(In.i(f)):g):g},a);var e=Fd(c);return e?e:Pe(J,zf.j(di(Sm,nl),c))},b)} +function Ct(a){var b=U(a),c=B(b,jn),d=zc(b,Rl,256),e=zc(b,km,192),f=zc(b,Mj,At);Tr(new X(null,2,5,Y,[fn,new Z(null,4,[Rl,d,km,e,jn,c,Mj,f],null)],null));var g=function(){return function n(l){return new xe(null,function(){for(var m=l;;){var p=E(m);if(p){var r=p,w=H(r),z=S(w,0,null),D=S(w,1,null),G=S(w,2,null),M=S(w,3,null),O=S(w,4,null),V=S(w,5,null);if(p=E(function(ka,va,Pb,sc,cd,Pd,Qd,tc,Rd,uh,Qe,Sd,dd,Mc,Re,vg){return function ud(vd){return new xe(null,function(Kb,Nc,Lb,uc,Wa,wd,ed,Td,Se,Ud,wg, +xg,yg,cc,Vd){return function(){for(;;){var Oc=E(vd);if(Oc){if(Md(Oc)){var dc=Wb(Oc),vf=N(dc),Wd=Be(vf);a:{for(var Xd,Yd=0;;)if(Yd0?Fs(new X(null,4,5,Y,[zj,(H(m)+H(p))/2,(kd(m)+kd(p))/2,w/r],null)):null;sf.O(c,T,m.pointerId,p);return Fs(new X(null,3,5,Y,[Ui,r-H(p),w-kd(p)],null))}return null});for(var d=E(new X(null,2,5,Y,["pointerup","pointercancel"],null)),e=null,f=0,g=0;;)if(g1){e.strokeStyle=Qt(b,p);e.beginPath();p=E(lf(m));m=null;for(var r=0,w=0;;)if(w1){e.strokeStyle=Qt(b,p);e.beginPath();k=E(lf(m));l=null;for(p=n=0;;)if(p v iter (assoc :iter iter))]))) (defn- test-surface [] #js {:view (fn [] (clj->js (assoc (:view @db/app-db) :iter (:iter @db/app-db)))) :engines (fn [] (clj->js (:engines @db/app-db))) + :usable (fn [] (clj->js (ev/usable @db/app-db))) + :fractal (fn [] (clj->js (:fractal @db/app-db))) + :formulas (fn [] (clj->js (map :id (:catalog @db/app-db)))) :ready (fn [] (boolean (:ready? @db/app-db))) :panes (fn [] (clj->js (:panes @db/app-db))) :setView (fn [v] (rf/dispatch-sync [::ev/set-view (js->clj v :keywordize-keys true)])) + :setFormula (fn [id] (rf/dispatch-sync [::ev/set-formula id])) :zoomAt (fn [px py f] (rf/dispatch-sync [::ev/zoom-at px py f])) :pan (fn [dx dy] (rf/dispatch-sync [::ev/pan dx dy])) - :render (fn [engine cx cy scale w h iter] - (.then (engines/render-on engine {:cx cx :cy cy :scale scale :w w :h h} iter) + ;; formula, jr and ji are optional: absent, the tile is plain Mandelbrot. + :render (fn [engine cx cy scale w h iter formula jr ji] + (.then (engines/render-on engine (cond-> {:cx cx :cy cy :scale scale :w w :h h} + formula (assoc :formula formula) + (some? jr) (assoc :julia [jr ji])) + iter) (fn [{:keys [ms iters pixels]}] #js {:ms ms :iters iters :checksum (engines/checksum pixels)}))) :bench (fn [opts] diff --git a/docs/examples/fractal/web/src/fractal/engines.cljs b/docs/examples/fractal/web/src/fractal/engines.cljs index 35c66fe11..983247fc2 100644 --- a/docs/examples/fractal/web/src/fractal/engines.cljs +++ b/docs/examples/fractal/web/src/fractal/engines.cljs @@ -5,13 +5,32 @@ browser-js the same pixel math in plain JavaScript (JsRaster) server- cljw renders natively, zwasm or plain Clojure (RemoteTiles) the JVM server (jvm/): raster, CUDA (RemoteTiles) - Discovery (Collect) fills `registry`; nothing parses an engine name.") + Discovery (Collect) fills `registry`; nothing parses an engine name." + (:require [fractal.view :as view])) ;; --- the port ----------------------------------------------------------------- (defprotocol IRenderer (id [r]) (exact? [r] "True when held to golden.edn: fp64, kernel operation order. False for fp32.") - (render [r tile iter] "Promise of {:pixels :ms :iters}; tile is {:cx :cy :scale :w :h}.")) + (caps [r] "{:formulas #{id} :julia? bool}: the fractals r renders.") + (render [r tile iter] "Promise of {:pixels :ms :iters}; tile is {:cx :cy :scale :w :h + :formula :julia}, the last two optional.")) + +(def mandelbrot-only + "The caps of a renderer that names nothing more: Mandelbrot on the c-plane, + the same default the JVM port applies." + {:formulas #{"mandelbrot"} :julia? false}) + +(defn caps-render? + "Whether caps cover fractal f ({:formula id :julia k-or-nil})." + [{:keys [formulas julia?]} {:keys [formula julia]}] + (and (contains? formulas (or formula "mandelbrot")) + (or (nil? julia) (boolean julia?)))) + +(defn renders? + "Whether r can render fractal f." + [r f] + (caps-render? (caps r) f)) (defn- now [] (js/performance.now)) @@ -20,6 +39,7 @@ IRenderer (id [_] rid) (exact? [_] true) + (caps [_] mandelbrot-only) (render [_ {:keys [cx cy scale w h]} iter] (let [f (if simd? (.-render_simd exports) (.-render exports)) t0 (now) @@ -69,20 +89,31 @@ IRenderer (id [_] "browser-js") (exact? [_] true) + (caps [_] mandelbrot-only) (render [_ {:keys [cx cy scale w h]} iter] (let [t0 (now) r (js-raster cx cy scale w h iter)] (js/Promise.resolve (assoc r :ms (- (now) t0)))))) ;; A tile server speaking the shared protocol: X-Render-Ms is the guest render ;; time, so network time is excluded from :ms. -(defrecord RemoteTiles [rid base engine exact] +(defn tile-query + "The /tile query of tile at iter on engine: the formula and k only when + they differ from plain Mandelbrot, so a Mandelbrot-only server never sees + a parameter it does not know." + [{:keys [cx cy scale w h formula julia]} iter engine] + (let [q (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :w w :h h :iter iter :engine engine})] + (when (and formula (not= formula "mandelbrot")) (.set q "formula" formula)) + (when-let [[kr ki] julia] (.set q "jr" kr) (.set q "ji" ki)) + q)) + +(defrecord RemoteTiles [rid base engine exact capabilities] IRenderer (id [_] rid) (exact? [_] exact) - (render [_ {:keys [cx cy scale w h]} iter] - (let [q (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :w w :h h :iter iter :engine engine}) - t0 (now)] - (-> (js/fetch (str base "/tile?" q)) + (caps [_] capabilities) + (render [_ tile iter] + (let [t0 (now)] + (-> (js/fetch (str base "/tile?" (tile-query tile iter engine))) (.then (fn [^js resp] (if (.-ok resp) (.then (.arrayBuffer resp) @@ -94,24 +125,37 @@ (.then (.text resp) #(throw (js/Error. (str "tile: HTTP " (.-status resp) " " %))))))))))) ;; --- Collect: discovery ---------------------------------------------------------- -(def jvm-url +(defn jvm-url "The JVM engines' server (jvm/, raster and CUDA). Default: port 8081 on this - host; ?jvm= overrides." + host; ?jvm= overrides. A fn, so loading this namespace needs no page." + [] (or (.get (js/URLSearchParams. js/location.search) "jvm") (str js/location.protocol "//" js/location.hostname ":8081"))) +(defn- caps-of + "Promote a server's renders entry ({formulas: [...], julia: bool}) to caps; + a server that sends none (the cljw server) is Mandelbrot only." + [^js entry] + (if entry + {:formulas (set (.-formulas entry)) :julia? (boolean (.-julia entry))} + mandelbrot-only)) + (defn- fetch-engines - "Promise of the server at base's {:engines [...] :kernels [...]}, or empty - when it does not answer within ms." + "Promise of the server at base's {:engines :kernels :inexact :caps + :formulas}, or empty when it does not answer within ms." [base ms] (let [ctl (js/AbortController.)] (js/setTimeout #(.abort ctl) ms) (-> (js/fetch (str base "/engines") #js {:signal (.-signal ctl)}) (.then #(.json %)) - (.then (fn [^js d] {:engines (vec (.-engines d)) - :kernels (vec (or (.-kernels d) #js [])) - :inexact (set (or (.-inexact d) #js []))})) - (.catch (constantly {:engines [] :kernels [] :inexact #{}}))))) + (.then (fn [^js d] + (let [renders (.-renders d)] + {:engines (vec (.-engines d)) + :kernels (vec (or (.-kernels d) #js [])) + :inexact (set (or (.-inexact d) #js [])) + :caps (into {} (for [e (.-engines d)] [e (caps-of (some-> renders (aget e)))])) + :formulas (js->clj (or (.-formulas d) #js []) :keywordize-keys true)}))) + (.catch (constantly {:engines [] :kernels [] :inexact #{} :caps {} :formulas []}))))) (defn- wasm-kernel "Promise of a WasmKernel for kernel k, or nil when this browser cannot run it." @@ -124,41 +168,62 @@ #(->WasmKernel (str "browser-" k) (.. % -instance -exports) (= k "zig-simd")))))) (.catch (fn [e] (js/console.warn "kernel" k e) nil)))) +(defn catalog + "The formulas the page can offer: the built-in Mandelbrot, then each + server's, first entry per id wins, in order." + [& server-lists] + (vals (reduce (fn [acc f] (if (contains? acc (:id f)) acc (assoc acc (:id f) f))) + (array-map) + (apply concat view/builtin-catalog server-lists)))) + (defn discover - "Promise of every renderer this page can reach, in display order." + "Promise of {:renderers [...] :catalog [...]}: every renderer this page can + reach, in display order, and the formulas they can render between them." [] - (-> (js/Promise.all #js [(fetch-engines "" 5000) (fetch-engines jvm-url 1500)]) - (.then (fn [[cljw jvm]] - (.then (js/Promise.all (clj->js (map wasm-kernel (:kernels cljw)))) - (fn [kernels] - (vec (concat (remove nil? kernels) - [(->JsRaster)] - (for [e (:engines cljw)] - (->RemoteTiles (str "server-" e) "" e (not ((:inexact cljw) e)))) - (for [e (:engines jvm)] - (->RemoteTiles e jvm-url e (not ((:inexact jvm) e)))))))))))) + (let [jvm-base (jvm-url)] + (-> (js/Promise.all #js [(fetch-engines "" 5000) (fetch-engines jvm-base 1500)]) + (.then (fn [[cljw jvm]] + (.then (js/Promise.all (clj->js (map wasm-kernel (:kernels cljw)))) + (fn [kernels] + {:renderers + (vec (concat (remove nil? kernels) + [(->JsRaster)] + (for [e (:engines cljw)] + (->RemoteTiles (str "server-" e) "" e (not ((:inexact cljw) e)) + (get-in cljw [:caps e] mandelbrot-only))) + (for [e (:engines jvm)] + (->RemoteTiles e jvm-base e (not ((:inexact jvm) e)) + (get-in jvm [:caps e] mandelbrot-only))))) + :catalog (vec (catalog (:formulas cljw) (:formulas jvm)))}))))))) ;; --- the registry the page renders through ---------------------------------------- (defonce registry (atom (array-map))) (defn load! - "Discover and register every renderer; resolves to their ids in order." + "Discover and register every renderer; resolves to {:ids [...] :caps {id + caps} :catalog [...]}." [] (.then (discover) - (fn [rs] - (reset! registry (into (array-map) (map (juxt id identity)) rs)) - (mapv id rs)))) + (fn [{:keys [renderers catalog]}] + (reset! registry (into (array-map) (map (juxt id identity)) renderers)) + {:ids (mapv id renderers) + :caps (into {} (map (juxt id caps)) renderers) + :catalog catalog}))) (defn names [ids] (vec ids)) (defn exact-engine? [engine] (boolean (some-> (get @registry engine) exact?))) (defn render-on - "Render a tile on the registered renderer named engine." + "Render a tile on the registered renderer named engine; rejects a tile + whose fractal that renderer does not render." [engine tile iter] - (if-let [r (get @registry engine)] - (render r tile iter) - (js/Promise.reject (js/Error. (str "unknown engine " engine))))) + (let [r (get @registry engine)] + (cond + (nil? r) (js/Promise.reject (js/Error. (str "unknown engine " engine))) + (not (renders? r tile)) (js/Promise.reject (js/Error. (str engine " does not render " (:formula tile) + (when (:julia tile) " (Julia)")))) + :else (render r tile iter)))) (defn checksum "The golden checksum, identical to golden.edn's." diff --git a/docs/examples/fractal/web/src/fractal/events.cljs b/docs/examples/fractal/web/src/fractal/events.cljs index 55fdef342..afc2d8a01 100644 --- a/docs/examples/fractal/web/src/fractal/events.cljs +++ b/docs/examples/fractal/web/src/fractal/events.cljs @@ -1,18 +1,51 @@ (ns fractal.events "re-frame state: events, effects and subscriptions. The canvas paint is an - effect (:fractal/paint), latest-wins with one frame in flight per pane." + effect (:fractal/paint), latest-wins with one frame in flight per pane. + The fractal on show ({:formula :julia}) is state like the view; which + engines may draw it is derived from each engine's caps, never from its + name." (:require [re-frame.core :as rf] [fractal.engines :as engines] [fractal.view :as view])) (def initial-db {:view view/home :iter 256 :res 1 :split? false - :engines [] :left nil :right nil :canvas [0 0] + :fractal view/default-fractal :catalog view/builtin-catalog + :engines [] :caps {} :left nil :right nil :canvas [0 0] :panes {} :totals {} :samples {} :bench nil :tour? false :ready? false}) +;; --- which engines can draw the current fractal (pure) -------------------------- +(defn usable + "The engines of db that render its fractal, in display order." + [{:keys [engines caps fractal]}] + (filterv #(engines/caps-render? (get caps % engines/mandelbrot-only) fractal) engines)) + +(defn fit-engines + "Keep left and right when they render the fractal; otherwise the first + usable engine on the left and, on the right, a cljw server engine when one + can (so a split compares two runtimes), else the next usable one." + [db] + (let [ok (usable db) + ok? (set ok) + left (if (ok? (:left db)) (:left db) (first ok)) + right-pick (or (first (filter #(and (.startsWith % "server-") (not= % left)) ok)) + (first (remove #{left} ok)) + left)] + (cond-> (assoc db :left left) + (not (ok? (:right db))) (assoc :right right-pick)))) + +(defn with-fractal + "db showing fractal f, framed at its home view, engines refitted." + [db f] + (let [[w] (:canvas db)] + (-> db + (assoc :fractal f :view (view/home-of (:catalog db) f w) :tour? false) + fit-engines))) + ;; --- painting ------------------------------------------------------------------- (defonce canvas-el (atom nil)) +(defonce hover (atom nil)) (defonce ^:private pane-state (atom {})) (defn- blit! [pixels w h dx dw H] @@ -28,12 +61,12 @@ (defn- paint-pane! [id job] (if (get-in @pane-state [id :busy?]) (swap! pane-state assoc-in [id :pending] job) - (let [{:keys [engine view canvas slice res iter]} job + (let [{:keys [engine view canvas slice res iter fractal]} job [W H] canvas [dx dw] slice pv (view/pane-view view canvas slice res)] (swap! pane-state assoc id {:busy? true}) - (-> (engines/render-on engine pv iter) + (-> (engines/render-on engine (merge pv fractal) iter) (.then (fn [{:keys [pixels ms iters]}] (blit! pixels (:w pv) (:h pv) dx dw H) (rf/dispatch [::frame-done id {:engine engine :ms ms :iters iters :w (:w pv) :h (:h pv)}]))) @@ -45,34 +78,33 @@ (rf/reg-fx :fractal/paint - (fn [{:keys [view canvas split? left right res iter]}] + (fn [{:keys [view canvas split? left right res iter fractal]}] (let [[W H] canvas] (when (and (>= W 8) (>= H 8) left) (let [lw (if split? (js/Math.floor (/ W 2)) W) - job {:view view :canvas canvas :res res :iter iter}] + job {:view view :canvas canvas :res res :iter iter :fractal fractal}] (paint-pane! :L (assoc job :engine left :slice [0 lw])) (when (and split? right) (paint-pane! :R (assoc job :engine right :slice [lw (- W lw)])))))))) (rf/reg-fx :fractal/hash - (fn [[v iter]] (js/history.replaceState nil "" (view/->hash v iter)))) + (fn [[v iter f]] (js/history.replaceState nil "" (view/->hash v iter f)))) (defn- repaint [db] {:db db - :fx [[:fractal/paint (select-keys db [:view :canvas :split? :left :right :res :iter])] - [:fractal/hash [(:view db) (:iter db)]]]}) + :fx [[:fractal/paint (select-keys db [:view :canvas :split? :left :right :res :iter :fractal])] + [:fractal/hash [(:view db) (:iter db) (:fractal db)]]]}) ;; --- events ---------------------------------------------------------------------- (rf/reg-event-db ::init (fn [_ _] initial-db)) (rf/reg-event-fx ::engines-loaded - (fn [{:keys [db]} [_ loaded]] - (let [names (engines/names loaded)] - (repaint (assoc db :engines names :ready? true - :left (first names) - :right (or (first (filter #(.startsWith % "server-") names)) (first names))))))) + (fn [{:keys [db]} [_ {:keys [ids caps catalog]}]] + (repaint (-> db + (assoc :engines ids :caps caps :catalog catalog :ready? true) + fit-engines)))) (rf/reg-event-fx ::canvas-size (fn [{:keys [db]} [_ wh]] (repaint (assoc db :canvas wh)))) @@ -84,6 +116,33 @@ (view/valid? merged) (assoc :view merged) (:iter v) (assoc :iter (max 1 (js/Math.round (:iter v))))))))) +;; A fractal named by the URL keeps the URL's view; picking one in the page +;; frames it at its home (::set-formula, ::toggle-julia). +(rf/reg-event-fx + ::set-fractal + (fn [{:keys [db]} [_ f]] + (repaint (fit-engines (assoc db :fractal (merge view/default-fractal f)))))) + +(rf/reg-event-fx + ::set-formula + (fn [{:keys [db]} [_ id]] + (repaint (with-fractal db {:formula id :julia nil})))) + +(rf/reg-event-fx + ::toggle-julia + (fn [{:keys [db]} _] + (let [{:keys [formula julia]} (:fractal db) + k (:julia (first (filter #(= formula (:id %)) (:catalog db))))] + (repaint (with-fractal db {:formula formula :julia (when-not julia (or k [0 0]))}))))) + +(rf/reg-event-fx + ::julia-at + (fn [{:keys [db]} [_ px py]] + (if (:julia (:fractal db)) + {:db db} + (let [k (view/plane-point (:view db) (:canvas db) [px py])] + (repaint (with-fractal db (assoc (:fractal db) :julia k))))))) + (rf/reg-event-fx ::zoom-at (fn [{:keys [db]} [_ px py factor]] @@ -97,7 +156,12 @@ (rf/reg-event-fx ::pan (fn [{:keys [db]} [_ dx dy]] (repaint (update db :view view/pan [dx dy])))) -(rf/reg-event-fx ::reset (fn [{:keys [db]} _] (repaint (assoc db :view view/home :tour? false)))) +(rf/reg-event-fx + ::reset + (fn [{:keys [db]} _] + (repaint (assoc db :tour? false :view (if (= view/default-fractal (:fractal db)) + view/home + (view/home-of (:catalog db) (:fractal db) (first (:canvas db)))))))) (rf/reg-event-fx ::set-iter (fn [{:keys [db]} [_ n]] (repaint (assoc db :iter n)))) (rf/reg-event-fx ::set-res (fn [{:keys [db]} [_ r]] (repaint (assoc db :res r)))) @@ -117,6 +181,7 @@ (fn [db [_ id engine msg]] (assoc-in db [:panes id] {:engine engine :error msg}))) ;; Tour: a timer drives ::tour-tick until the user stops it or scale bottoms out. +;; The tour dives into seahorse valley, so it shows the Mandelbrot set. (defonce ^:private tour-timer (atom nil)) (rf/reg-fx @@ -129,7 +194,9 @@ ::toggle-tour (fn [{:keys [db]} _] (let [on? (not (:tour? db))] - {:db (assoc db :tour? on?) :fx [[:fractal/tour on?]]}))) + {:db (cond-> (assoc db :tour? on?) + on? (-> (assoc :fractal view/default-fractal) fit-engines)) + :fx [[:fractal/tour on?]]}))) (rf/reg-event-fx ::stop-tour (fn [{:keys [db]} _] {:db (assoc db :tour? false) :fx [[:fractal/tour false]]})) @@ -144,38 +211,47 @@ ;; --- bench ------------------------------------------------------------------------- (def bench-views - [["full set" -0.6 0.0 (/ 3.0 256) 256] - ["seahorse valley" -0.745 0.112 (/ 0.008 256) 512] - ["spiral" -0.10109636384562 0.95628651080914 (/ 0.0005 256) 1024] - ["deep pinch" -1.768778833 0.001738996 (/ 1e-6 256) 1024]]) + "[name cx cy scale-per-256px iter fractal]. A view runs on the engines that + render its fractal; the others show n/a." + [["full set" -0.6 0.0 (/ 3.0 256) 256 view/default-fractal] + ["seahorse valley" -0.745 0.112 (/ 0.008 256) 512 view/default-fractal] + ["spiral" -0.10109636384562 0.95628651080914 (/ 0.0005 256) 1024 view/default-fractal] + ["deep pinch" -1.768778833 0.001738996 (/ 1e-6 256) 1024 view/default-fractal] + ["burning ship" -1.762 -0.028 (/ 0.06 256) 512 {:formula "burning-ship" :julia nil}] + ["multibrot3 julia" 0.0 0.0 (/ 3.4 256) 512 {:formula "multibrot3" :julia [-0.2 0.75]}]]) (defn parity? - "Every exact engine gave the same iteration total and checksum on each view." + "On each view, every exact engine that rendered it gave the same iteration + total and checksum. A view no exact engine renders is vacuously in parity." [rows views] (every? (fn [[name]] - (let [rs (filter #(and (= name (:view %)) (:exact? %) (not (:error %))) rows)] - (and (seq rs) (apply = (map (juxt :iters :checksum) rs))))) + (let [rs (filter #(and (= name (:view %)) (:exact? %) (not (:error %)) (not (:skipped %))) rows)] + (or (empty? rs) (apply = (map (juxt :iters :checksum) rs))))) views)) (defn run-bench! - "Render every view on every engine, one at a time; dispatch each row as it - lands. Resolves to {:w :h :engines :rows :parity}." + "Render every view on every engine that renders it, one at a time; dispatch + each row as it lands. Resolves to {:w :h :engines :views :rows :parity}." [{:keys [engines w h views] :or {w 256 h 192 views bench-views}}] (rf/dispatch [::bench-start {:w w :h h :engines engines :views views}]) - (let [jobs (for [[name cx cy sc it] views, e engines] [name e {:cx cx :cy cy :scale sc :w w :h h} it])] - (-> (reduce (fn [p [name e pv it]] + (let [jobs (for [[name cx cy sc it f] views, e engines] + [name e (merge {:cx cx :cy cy :scale (* sc (/ 256 w)) :w w :h h} f) it])] + (-> (reduce (fn [p [name e tile it]] (.then p (fn [rows] - (-> (engines/render-on e pv it) - (.then (fn [{:keys [pixels ms iters]}] - {:view name :engine e :ms ms :iters iters - :exact? (engines/exact-engine? e) - :mits (/ iters ms 1000) :checksum (engines/checksum pixels)})) - (.catch (fn [err] {:view name :engine e :error (.-message err)})) + (-> (if (engines/renders? (get @engines/registry e) tile) + (-> (engines/render-on e tile it) + (.then (fn [{:keys [pixels ms iters]}] + {:view name :engine e :ms ms :iters iters + :exact? (engines/exact-engine? e) + :mits (/ iters ms 1000) :checksum (engines/checksum pixels)})) + (.catch (fn [err] {:view name :engine e :error (.-message err)}))) + (js/Promise.resolve {:view name :engine e :skipped true})) (.then (fn [row] (rf/dispatch [::bench-row row]) (conj rows row))))))) (js/Promise.resolve []) jobs) (.then (fn [rows] - (let [result {:w w :h h :engines engines :rows rows :parity (parity? rows views)}] + (let [result {:w w :h h :engines engines :views (mapv first views) :rows rows + :parity (parity? rows views)}] (rf/dispatch [::bench-done (:parity result)]) result)))))) @@ -187,5 +263,6 @@ (rf/reg-event-db ::bench-done (fn [db [_ p]] (assoc-in db [:bench :parity] p))) ;; --- subscriptions --------------------------------------------------------------- -(doseq [k [:view :iter :res :split? :engines :left :right :canvas :panes :totals :samples :bench :tour? :ready?]] +(doseq [k [:view :iter :res :split? :engines :caps :left :right :canvas :panes :totals :samples + :bench :tour? :ready? :fractal :catalog]] (rf/reg-sub k (fn [db _] (get db k)))) diff --git a/docs/examples/fractal/web/src/fractal/ui.cljs b/docs/examples/fractal/web/src/fractal/ui.cljs index 7eb9e7cbe..7f99ab978 100644 --- a/docs/examples/fractal/web/src/fractal/ui.cljs +++ b/docs/examples/fractal/web/src/fractal/ui.cljs @@ -17,12 +17,31 @@ (defn- fmt [n d] (.toFixed n d)) ;; --- header ----------------------------------------------------------------------- -(defn- engine-select [side] +(defn- engine-select + "Every engine, the ones that cannot draw the current fractal disabled." + [side] (let [engines @(rf/subscribe [:engines]) + ok? (set (ev/usable {:engines engines :caps @(rf/subscribe [:caps]) :fractal @(rf/subscribe [:fractal])})) cur @(rf/subscribe [side])] [:select {:id (if (= side :left) "engL" "engR") :value (or cur "") :on-change #(rf/dispatch [::ev/set-engine side (.. % -target -value)])} - (for [e engines] ^{:key e} [:option e])])) + (for [e engines] ^{:key e} [:option {:value e :disabled (not (ok? e))} e])])) + +(defn- fractal-controls + "The formula on show and the seed: the c-plane set, or its Julia set." + [] + (let [catalog @(rf/subscribe [:catalog]) + {:keys [formula julia]} @(rf/subscribe [:fractal])] + [:div.ctl + [:label "fractal " + [:select {:id "formula" :value formula + :on-change #(rf/dispatch [::ev/set-formula (.. % -target -value)])} + (for [{:keys [id what]} catalog] ^{:key id} [:option {:value id :title what} (str id " " what)])]] + [:button {:id "julia" :class (when julia "active") :title "Julia set of this formula (j: k under the cursor)" + :on-click #(rf/dispatch [::ev/toggle-julia])} + (if julia (str "Julia k=" (.toPrecision (first julia) 4) (if (neg? (second julia)) "" "+") + (.toPrecision (second julia) 4) "i") + "Julia")]])) (defn- coord-inputs [] (let [draft (r/atom {})] @@ -47,6 +66,7 @@ tour? @(rf/subscribe [:tour?])] [:header [:h1 "fractal zoom"] + [fractal-controls] [:div.ctl [:label "left " [engine-select :left]] [:label "right " [engine-select :right]] @@ -90,6 +110,7 @@ (rf/dispatch [::ev/stop-tour]))) (.addEventListener c "pointermove" (fn [e] + (reset! ev/hover (canvas-point c e)) (when-let [[px py] (get @pointers (.-pointerId e))] (let [p (canvas-point c e)] (if (= 2 (count @pointers)) @@ -116,10 +137,11 @@ (.observe (js/ResizeObserver. fit!) c) (fit!)))) -(defn- hud-text [{:keys [engine ms iters w h error]} v iter] +(defn- hud-text [{:keys [engine ms iters w h error]} v iter {:keys [formula julia]}] (if error (str engine ": " error) (str engine " " (fmt ms 1) " ms " (fmt (/ iters ms 1000) 1) " Miter/s\n" + formula (when julia (str " Julia k=" (.toPrecision (first julia) 6) "," (.toPrecision (second julia) 6))) "\n" "x " (.toPrecision (:cx v) 15) "\ny " (.toPrecision (:cy v) 15) "\n" "scale " (.toExponential (:scale v) 3) " zoom " (.toExponential (view/zoom-level v) 2) "x\n" "iter " iter " " w "x" h))) @@ -137,13 +159,14 @@ (let [panes @(rf/subscribe [:panes]) split? @(rf/subscribe [:split?]) v @(rf/subscribe [:view]) - iter @(rf/subscribe [:iter])] + iter @(rf/subscribe [:iter]) + f @(rf/subscribe [:fractal])] [:div#wrap [:canvas#view {:ref mount-canvas!}] [:div#divider {:style {:display (if split? "block" "none")}}] - [:div#hudL.hud (when-let [p (:L panes)] (hud-text p v iter))] + [:div#hudL.hud (when-let [p (:L panes)] (hud-text p v iter f))] [:div#hudR.hud {:style {:display (if split? "block" "none")}} - (when-let [p (:R panes)] (hud-text p v iter))]]))})) + (when-let [p (:R panes)] (hud-text p v iter f))]]))})) ;; --- stats -------------------------------------------------------------------------- (defn- draw-chart! [^js c samples engines] @@ -180,7 +203,9 @@ [:td (fmt (/ iters ms 1000) 2)] [:td frames]])]] [chart samples engines] [:p.note "Wheel or " [:kbd "+"] "/" [:kbd "-"] " zoom at the cursor, drag or arrows/" [:kbd "WASD"] - " slide x and y, double-click zooms in (shift: out), two fingers pinch. The URL hash keeps the view."]])) + " slide x and y, double-click zooms in (shift: out), two fingers pinch. " [:kbd "j"] + " opens the Julia set of the point under the cursor. The URL hash keeps the view and the fractal." + " Formulas other than Mandelbrot run on the engines whose caps list them (raster and CUDA on the JVM server)."]])) (defn bench-table [] (let [engines @(rf/subscribe [:engines]) @@ -188,8 +213,8 @@ [:div.scroll [:div#benchmeta.note (if-not bench - "Bench renders a fixed view list on every engine: ms, Miter/s, iteration total (must match) and checksum." - (str w "x" h " per cell. Server ms = guest render only. Green = iteration total and checksum equal the first engine's." + "Bench renders a fixed view list on every engine that renders it: ms, Miter/s, iteration total (must match) and checksum." + (str w "x" h " per cell. Server ms = guest render only. Green = iteration total and checksum equal the first engine's. n/a = the engine does not render that fractal." (case parity nil " Running..." true " Parity: all engines agree." " Parity: MISMATCH.")))] (when bench [:table#benchtab @@ -200,10 +225,11 @@ [:tr [:td [swatch engines e] e] (for [[n] views :let [r (first (filter #(and (= n (:view %)) (= e (:engine %))) rows)) - ref (first (filter #(and (= n (:view %)) (not (:error %))) rows))]] + ref (first (filter #(and (= n (:view %)) (not (:error %)) (not (:skipped %))) rows))]] ^{:key n} [:td (cond (nil? r) "…" + (:skipped r) [:span.note "n/a"] (:error r) [:span.bad {:title (:error r)} "error"] :else [:span {:title (str "iters " (:iters r) " checksum " (:checksum r))} (fmt (:ms r) 1) " ms " diff --git a/docs/examples/fractal/web/src/fractal/view.cljs b/docs/examples/fractal/web/src/fractal/view.cljs index 2d4151ecc..ae4c02297 100644 --- a/docs/examples/fractal/web/src/fractal/view.cljs +++ b/docs/examples/fractal/web/src/fractal/view.cljs @@ -6,6 +6,30 @@ (def tour-target {:cx -0.743643887037151 :cy 0.13182590420533}) +(def default-fractal + "Which set the page shows: a formula id of the JVM's fractal.formula + registry, and k when the orbit starts on the z-plane (a Julia set)." + {:formula "mandelbrot" :julia nil}) + +(def builtin-catalog + "The formula every engine renders. Servers add the rest (GET /engines)." + [{:id "mandelbrot" :what "z^2 + c" :home {:cx -0.5 :cy 0.0 :scale 0.0068359375} :julia [-0.8 0.156]}]) + +(defn plane-point + "The complex point [re im] under canvas pixel (px, py) of a w x h canvas." + [{:keys [cx cy scale]} [w h] [px py]] + [(+ cx (* (- px (/ w 2)) scale)) (+ cy (* (- py (/ h 2)) scale))]) + +(defn home-of + "The view that frames fractal f on a canvas w pixels wide. Catalog homes + are for 512 pixels; a Julia set is framed as the disk of radius 1.7." + [catalog {:keys [formula julia]} w] + (let [k (/ 512 (max 1 w)) + entry (or (first (filter #(= formula (:id %)) catalog)) (first builtin-catalog))] + (if julia + {:cx 0 :cy 0 :scale (* k (/ 3.4 512))} + (update (:home entry) :scale * k)))) + (defn zoom-at "Zoom by factor (>1 in) keeping the plane point under canvas pixel (px, py) fixed, on a canvas of w x h pixels." @@ -53,18 +77,30 @@ (defn valid? [{:keys [cx cy scale]}] (and (js/Number.isFinite cx) (js/Number.isFinite cy) (js/Number.isFinite scale) (pos? scale))) -(defn ->hash [{:keys [cx cy scale]} iter] - (str \# (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :iter iter}))) +(defn ->hash + "The URL hash of view v at iteration cap iter for fractal f; the formula + and k are written only when they differ from plain Mandelbrot." + ([v iter] (->hash v iter default-fractal)) + ([{:keys [cx cy scale]} iter {:keys [formula julia]}] + (let [p (js/URLSearchParams. #js {:cx cx :cy cy :scale scale :iter iter})] + (when (and formula (not= formula (:formula default-fractal))) (.set p "formula" formula)) + (when-let [[kr ki] julia] (.set p "jr" kr) (.set p "ji" ki)) + (str \# p)))) (defn <-hash - "Parse a location hash into {:view {...} :iter n}; keys absent or invalid - in the hash are absent from the result." + "Parse a location hash into {:view {...} :iter n :fractal {...}}; keys + absent or invalid in the hash are absent from the result. :fractal is + present when the hash names a formula or a Julia k." [hash] (let [p (js/URLSearchParams. (subs (or hash "") 1)) n #(when (.has p %) (finite-num (.get p %))) v (cond-> {} (n "cx") (assoc :cx (n "cx")) (n "cy") (assoc :cy (n "cy")) - (some-> (n "scale") pos?) (assoc :scale (n "scale")))] + (some-> (n "scale") pos?) (assoc :scale (n "scale"))) + julia (when (and (n "jr") (n "ji")) [(n "jr") (n "ji")]) + formula (when (seq (.get p "formula")) (.get p "formula"))] (cond-> {:view v} - (n "iter") (assoc :iter (max 1 (js/Math.round (n "iter"))))))) + (n "iter") (assoc :iter (max 1 (js/Math.round (n "iter")))) + (or formula julia) (assoc :fractal {:formula (or formula (:formula default-fractal)) + :julia julia})))) diff --git a/docs/examples/fractal/web/test/fractal/view_test.cljs b/docs/examples/fractal/web/test/fractal/view_test.cljs index 57ea8ebc3..01dd7792c 100644 --- a/docs/examples/fractal/web/test/fractal/view_test.cljs +++ b/docs/examples/fractal/web/test/fractal/view_test.cljs @@ -6,7 +6,9 @@ [clojure.test.check :as tc] [clojure.test.check.generators :as gen] [clojure.test.check.properties :as prop] - [fractal.view :as view])) + [fractal.view :as view] + [fractal.engines :as engines] + [fractal.events :as ev])) (def gen-view (gen/let [cx (gen/double* {:min -2.0 :max 1.0 :NaN? false :infinite? false}) @@ -18,8 +20,7 @@ (defn- close? [a b tol] (<= (js/Math.abs (- a b)) (* tol (max 1 (js/Math.abs a) (js/Math.abs b))))) -(defn- plane-point [{:keys [cx cy scale]} [w h] [px py]] - [(+ cx (* (- px (/ w 2)) scale)) (+ cy (* (- py (/ h 2)) scale))]) +(def ^:private plane-point view/plane-point) (defn- check [n p] (let [r (tc/quick-check n p)] @@ -45,9 +46,22 @@ (let [back (view/pan (view/pan v [dx dy]) [(- dx) (- dy)])] (and (close? (:cx v) (:cx back) 1e-9) (close? (:cy v) (:cy back) 1e-9)))))) +(def gen-fractal + (gen/let [formula (gen/elements ["mandelbrot" "burning-ship" "tricorn" "multibrot3"]) + julia (gen/one-of [(gen/return nil) + (gen/tuple (gen/double* {:min -2 :max 2 :NaN? false}) + (gen/double* {:min -2 :max 2 :NaN? false}))])] + {:formula formula :julia julia})) + (deftest hash-round-trips - (check 300 (prop/for-all [v gen-view iter (gen/choose 1 4096)] - (= {:view v :iter iter} (view/<-hash (view/->hash v iter)))))) + (check 300 (prop/for-all [v gen-view iter (gen/choose 1 4096) f gen-fractal] + (= (cond-> {:view v :iter iter} + (not= f view/default-fractal) (assoc :fractal f)) + (view/<-hash (view/->hash v iter f)))))) + +(deftest a-mandelbrot-hash-is-unchanged + (check 100 (prop/for-all [v gen-view iter (gen/choose 1 4096)] + (= (view/->hash v iter) (view/->hash v iter view/default-fractal))))) (deftest hash-ignores-garbage (is (= {:view {}} (view/<-hash "#cx=abc&scale=-1&iter="))) @@ -63,3 +77,53 @@ right-edge (+ (:cx right) (* (- 0 (/ (:w right) 2)) (:scale right)))] (and (= (+ (:w left) (:w right)) w) (close? left-edge right-edge 1e-9)))))) + +;; --- the fractal and the engines that may draw it -------------------------------- + +(def engine-ids ["browser-zig" "browser-js" "server-zig-jit" "raster-jvm" "cuda-f64" "cuda-f32"]) + +(def gen-caps + "Each engine Mandelbrot-only or a random subset of the formulas, with or + without Julia." + (gen/fmap #(zipmap engine-ids %) + (gen/vector (gen/one-of [(gen/return engines/mandelbrot-only) + (gen/let [fs (gen/set (gen/elements ["burning-ship" "tricorn" "multibrot3"])) + j gen/boolean] + {:formulas (conj fs "mandelbrot") :julia? j})]) + (count engine-ids)))) + +(deftest fit-engines-picks-only-engines-that-render + (check 300 (prop/for-all [caps gen-caps f gen-fractal + left (gen/elements engine-ids) right (gen/elements engine-ids)] + (let [db {:engines engine-ids :caps caps :fractal f :left left :right right} + ok (set (ev/usable db)) + fit (ev/fit-engines db)] + (and (= ok (set (filter #(engines/caps-render? (caps %) f) engine-ids))) + (if (empty? ok) + (and (nil? (:left fit)) (nil? (:right fit))) + (and (ok (:left fit)) (ok (:right fit)) + (or (not (ok left)) (= left (:left fit))) + (or (not (ok right)) (= right (:right fit)))))))))) + +(deftest mandelbrot-only-renders-plain-mandelbrot-only + (check 200 (prop/for-all [f gen-fractal] + (= (engines/caps-render? engines/mandelbrot-only f) + (= f view/default-fractal))))) + +(deftest a-plain-mandelbrot-tile-query-names-no-formula + (check 200 (prop/for-all [v gen-view f gen-fractal] + (let [q (engines/tile-query (merge v {:w 8 :h 8} f) 64 "e")] + (and (= (.has q "formula") (not= "mandelbrot" (:formula f))) + (= (.has q "jr") (some? (:julia f)))))))) + +(deftest julia-k-is-the-point-under-the-cursor + (check 200 (prop/for-all [v gen-view [w h] gen-canvas + fx (gen/double* {:min 0 :max 1 :NaN? false}) + fy (gen/double* {:min 0 :max 1 :NaN? false})] + (let [px (* fx w) py (* fy h) + db (assoc ev/initial-db :view v :canvas [w h] + :engines engine-ids :caps (zipmap engine-ids (repeat {:formulas #{"mandelbrot"} :julia? true}))) + {db' :db} ((fn [db] (let [[kr ki] (plane-point v [w h] [px py])] + {:db (ev/with-fractal db {:formula "mandelbrot" :julia [kr ki]})})) db)] + (and (= (plane-point v [w h] [px py]) (:julia (:fractal db'))) + (zero? (:cx (:view db'))) (zero? (:cy (:view db')))))))) From 1d7f382eaeda5e1dcf2153427c000bb2b7321341 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 22:35:43 -0300 Subject: [PATCH 48/51] fix(examples): keep the fractal chart out of the header; stop the gate's server The bare canvas rule made the rolling ms/frame chart absolutely positioned over the page, hiding the header controls (seen live on cljw.buddhilw.com/fractal/). Scope it to the fractal view, show '-' for a frame under the timer resolution, and let the bench table scroll. phase16_fractal_server backgrounded a shell function, so $! named the subshell and the EXIT trap left timeout and cljw holding port 8159. Exec the bounding process in the subshell instead. --- CHANGELOG.md | 8 ++++++++ docs/examples/fractal/index.html | 12 ++++++------ test/e2e/phase16_fractal_server.sh | 7 ++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552eb8cbc..2f7e9a9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Fixed + +- The fractal example page no longer hides its header controls: a bare + `canvas` rule positioned the rolling ms/frame chart over the top of the + page. A frame faster than the timer resolution shows `-` Miter/s instead of + `Infinity`. The `phase16_fractal_server` gate now stops its server for real + on exit, instead of leaving it holding port 8159 for up to 120 s. + ## [1.15.6] - 2026-11-08 ### Added diff --git a/docs/examples/fractal/index.html b/docs/examples/fractal/index.html index 8cde2b9d7..c56a94224 100644 --- a/docs/examples/fractal/index.html +++ b/docs/examples/fractal/index.html @@ -30,8 +30,8 @@ .ctl { display: flex; align-items: center; gap: 6px; color: var(--dim); } input[type=range] { accent-color: var(--accent); width: 110px; } #wrap { position: relative; flex: 1; min-height: 50vh; } - canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: grab; } - canvas:active { cursor: grabbing; } + #view { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: grab; } + #view:active { cursor: grabbing; } #hud { position: absolute; left: 8px; top: 8px; padding: 6px 9px; border-radius: 6px; background: rgba(13,17,23,.8); border: 1px solid var(--border); @@ -47,8 +47,8 @@ th, td { text-align: right; padding: 3px 8px; border-bottom: 1px solid var(--border); } th:first-child, td:first-child { text-align: left; } th { color: var(--dim); font-weight: 500; } - #chart { width: 100%; height: 90px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; } - #benchout { white-space: pre; font: 12px/1.5 ui-monospace, monospace; color: var(--dim); + #chart { display: block; width: 100%; height: 90px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; } + #benchout { white-space: pre; overflow-x: auto; font: 12px/1.5 ui-monospace, monospace; color: var(--dim); max-height: 180px; overflow: auto; } .legend { font: 11px ui-monospace, monospace; color: var(--dim); } .legend b { font-weight: 400; } @@ -265,7 +265,7 @@

fractal zoom

const tr = document.createElement("tr"); tr.innerHTML = `▬ ${eng}` + `${(ms / frames).toFixed(1)}` + - `${(iters / ms / 1000).toFixed(2)}${frames}`; + `${ms > 0 ? (iters / ms / 1000).toFixed(2) : "-"}${frames}`; liveBody.appendChild(tr); } const c = document.getElementById("chart"); @@ -310,7 +310,7 @@

fractal zoom

const r = eng === "browser" ? browserRender(cx, cy, sc, w, h, it) : await serverRender(eng.slice(7), cx, cy, sc, w, h, it); - const mits = (r.iters / r.ms / 1000).toFixed(2); + const mits = r.ms > 0 ? (r.iters / r.ms / 1000).toFixed(2) : "-"; cells.push(`${r.ms.toFixed(1)}ms ${mits}Mi/s`.padStart(22)); } catch (e) { cells.push("error".padStart(22)); } } diff --git a/test/e2e/phase16_fractal_server.sh b/test/e2e/phase16_fractal_server.sh index 3a617cd16..2a156d25e 100755 --- a/test/e2e/phase16_fractal_server.sh +++ b/test/e2e/phase16_fractal_server.sh @@ -25,7 +25,12 @@ fi DEMO="docs/examples/fractal" PORT=8159 scratch="$(mktemp -d "${TMPDIR:-/tmp}/cljw_fractal_srv_XXXXXX")" -run_bounded 120 "$BIN" "$DEMO/server.clj" "$PORT" >"$scratch/server.log" 2>&1 & +# A backgrounded function runs in a subshell, so $! would be that subshell and +# a kill would leave the server holding the port. Exec makes $! the server's +# own bounding process. +(if command -v timeout >/dev/null 2>&1; then exec timeout 120 "$BIN" "$DEMO/server.clj" "$PORT" + elif command -v gtimeout >/dev/null 2>&1; then exec gtimeout 120 "$BIN" "$DEMO/server.clj" "$PORT" + else exec "$BIN" "$DEMO/server.clj" "$PORT"; fi) >"$scratch/server.log" 2>&1 & SRV=$! trap 'kill "$SRV" 2>/dev/null || true; rm -rf "$scratch"' EXIT From f25fae9c3b594410c57a6fe0d3fd544d743bc609 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sat, 7 Nov 2026 23:09:20 -0300 Subject: [PATCH 49/51] ci: bump the remaining actions/checkout pins to 7.0.1 The dependabot bump predates bench-vs-bb.yml, mutation.yml and two of the release.yml jobs, which still pinned 7.0.0. Every workflow now uses the same checkout pin. --- .github/workflows/bench-vs-bb.yml | 2 +- .github/workflows/mutation.yml | 2 +- .github/workflows/release.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bench-vs-bb.yml b/.github/workflows/bench-vs-bb.yml index 615135841..ce6e1b37f 100644 --- a/.github/workflows/bench-vs-bb.yml +++ b/.github/workflows/bench-vs-bb.yml @@ -40,7 +40,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index e1d58e350..7eb771d70 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -45,7 +45,7 @@ jobs: timeout-minutes: 340 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The harness builds a git worktree at a revision, so it needs history. fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b27c649e..f3a2a77a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: run: echo "present=$PRESENT" >> "$GITHUB_OUTPUT" - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The relevance check diffs against the previous release tag, and the # bump commit is pushed from here. @@ -353,7 +353,7 @@ jobs: } >> "$GITHUB_ENV" - name: Check out the tap - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository_owner }}/homebrew-tap ssh-key: ${{ secrets.TAP_DEPLOY_KEY }} From 897db301ca2f29bab62aa9edfdbba4c7e41ad594 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Branquinho Date: Sun, 8 Nov 2026 00:50:46 -0300 Subject: [PATCH 50/51] docs(changelog): the fractal demo's engines and formulas under Unreleased PR #111 merged without a CHANGELOG entry; the release stamps only what [Unreleased] holds. --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce2d1d85..58493667b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +### Added + +- `docs/examples/fractal/` grows into nineteen engines behind one renderer + port: Zig, C and Rust wasm kernels (on zwasm and in the browser), plain + Clojure on cljw and the JVM, replikativ/raster on the JVM and CUDA through + clojurecuda. The page is ClojureScript (re-frame and reagent) with zoom, pan, + split comparison and a bench. Fractals are data: Mandelbrot, Burning Ship, + Tricorn and the cubic Multibrot, each also as a Julia set, compiled from one + expression to the oracle, raster and CUDA, which agree bit for bit. + `golden.edn` pins every exact engine; property, mutation and hive-cljs page + tests guard it. + ### Changed - `reduce-kv` on vectors and subvecs now indexes in Zig instead of interpreting From 706d327a94cf85ebe8d47a12ddf14e8b7c0ca1e6 Mon Sep 17 00:00:00 2001 From: clojurewasm-release Date: Sun, 8 Nov 2026 04:12:37 +0000 Subject: [PATCH 51/51] chore(release): v1.15.7 --- CHANGELOG.md | 2 ++ build.zig.zon | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58493667b..815f77a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +## [1.15.7] - 2026-11-08 + ### Added - `docs/examples/fractal/` grows into nineteen engines behind one renderer diff --git a/build.zig.zon b/build.zig.zon index 74c0bd2f7..b1475f28c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cljw, - .version = "1.15.6", + .version = "1.15.7", .fingerprint = 0x1869d207073beffa, .minimum_zig_version = "0.16.0", .dependencies = .{