diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml index c0b0c3c03..134dca2a8 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: |- @@ -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.' 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/.dev/debt.yaml b/.dev/debt.yaml index ab816d4c2..5f22629cf 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 @@ -7348,7 +7348,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/.dev/optimizations.md b/.dev/optimizations.md index fcbafc728..87d55d4cd 100644 --- a/.dev/optimizations.md +++ b/.dev/optimizations.md @@ -103,8 +103,10 @@ | 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-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` | @@ -127,6 +129,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/.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/.github/workflows/bench-vs-bb.yml b/.github/workflows/bench-vs-bb.yml index b01908e27..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 }} @@ -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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13384442e..2c7c9807c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,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 }} # scripts/check_changelog_reachability.sh lists release tags with 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 4abad4d14..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. @@ -223,7 +223,7 @@ jobs: artifact: cljw-linux-x86_64 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The bump commit, when preflight made one — the run's own SHA still # carries the PREVIOUS `.version`, and that is what the binary reports. @@ -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 }} 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/CHANGELOG.md b/CHANGELOG.md index 0301a48e1..815f77a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,136 @@ 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 + 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 + 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. + +### 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. The rule is scoped to the fractal view and the chart is a block of + the stats panel. A frame faster than the timer resolution shows `-` + Miter/s (HUD, live table and bench) 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. +- `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. +- 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. + +## [1.15.6] - 2026-11-08 + +### 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 + +- 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 + `.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 + +- 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. +- `(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 + 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). + +- 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. + +- **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%. + +- **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. + +- **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/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" 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} 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))) 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/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/build.zig.zon b/build.zig.zon index 7da3d294a..b1475f28c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cljw, - .version = "1.15.4", + .version = "1.15.7", .fingerprint = 0x1869d207073beffa, .minimum_zig_version = "0.16.0", .dependencies = .{ @@ -14,6 +14,27 @@ // 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 + // (`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 +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=main#e9d74934ec3212e502b101feab7673bf11c910ba", - .hash = "zwasm-2.7.0-FT1Fv3v5ngAavfQgV-JntjzckyYqMLUUCfYssfSrFN9S", + .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, }, }, 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/data/placement.yaml b/data/placement.yaml index 64ce45bfc..f4168bbe1 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: 1469 + zig: 436 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: "-" } @@ -118,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: "-" } @@ -134,6 +138,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/docs/examples/fractal/README.md b/docs/examples/fractal/README.md new file mode 100644 index 000000000..0d71cd0b2 --- /dev/null +++ b/docs/examples/fractal/README.md @@ -0,0 +1,211 @@ +# Fractal zoom: four formulas, nineteen engines + +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. 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/) + ├── 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 +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 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 - ...`). + +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 | +| 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 | +| 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. + +## 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: + +| export | signature | meaning | +|---|---|---| +| `memory` | | linear memory holding the framebuffer | +| `fb_ptr` | `() -> i32` | byte address of the RGBA8 framebuffer | +| `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` | + +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. + +| 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 | + +## Tests + +| suite | what | run | +|---|---|---| +| 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 +|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 new file mode 100644 index 000000000..113ca1381 --- /dev/null +++ b/docs/examples/fractal/bench.clj @@ -0,0 +1,57 @@ +;; Run from the repository root: zig-out/bin/cljw docs/examples/fractal/bench.clj +;; 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] + (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) + {: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 (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..6a01bb2e1 --- /dev/null +++ b/docs/examples/fractal/cljw/renderer.clj @@ -0,0 +1,29 @@ +;; 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 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] + (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/golden.edn b/docs/examples/fractal/golden.edn new file mode 100644 index 000000000..d5876a85e --- /dev/null +++ b/docs/examples/fractal/golden.edn @@ -0,0 +1,39 @@ +;; 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. :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} + {: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} + ;; 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} + {: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 new file mode 100644 index 000000000..32538e26c --- /dev/null +++ b/docs/examples/fractal/hive-cljs.edn @@ -0,0 +1,157 @@ +;; 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.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 + :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.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] + :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.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.fractalApi&&window.fractalApi.ready()){clearInterval(t);ok(true);}},100);})"] + [:click "#zin"] + [:wait-ms 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.fractalApi&&window.fractalApi.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.fractalApi.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.fractalApi.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.fractalApi.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.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===r.views.length*window.fractalApi.engines().length && r.rows.every(function(x){return !x.error;});})"] + [:wait-for "#benchtab"]]} + + {: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.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);})"]]} + + {: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/.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..f6b693017 --- /dev/null +++ b/docs/examples/fractal/jvm/deps.edn @@ -0,0 +1,23 @@ +;; 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"} + org.clojure/data.json {:mvn/version "2.5.1"}} + :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/src/fractal/bench.clj b/docs/examples/fractal/jvm/src/fractal/bench.clj new file mode 100644 index 000000000..04ed09857 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/bench.clj @@ -0,0 +1,60 @@ +(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: it renders the view's formula and seed, + and heavy views only on :heavy? renderers." + [rr v] + (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)] + (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/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/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/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/http.clj b/docs/examples/fractal/jvm/src/fractal/http.clj new file mode 100644 index 000000000..57e4626bd --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/http.clj @@ -0,0 +1,89 @@ +(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] + [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))) + +(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 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), + the unavailable ones, the formula catalog, and what each engine renders." + [registry 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}." + [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))) + 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 + (text 400 (str "tile error: " (.getMessage e)))))) + +(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 new file mode 100644 index 000000000..73939dfd6 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/palette.clj @@ -0,0 +1,75 @@ +(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) + +(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 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: `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." + ^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..dadc01343 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer.clj @@ -0,0 +1,61 @@ +(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] + [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 :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." + [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..e434a782e --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/cuda.clj @@ -0,0 +1,98 @@ +(ns fractal.renderer.cuda + "CUDA renderers through uncomplicate/clojurecuda: one GPU thread per pixel. + 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.renderer.cuda-source :as src]) + (: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)] + (str "-arch=sm_" compute-capability-major compute-capability-minor))) + +(defn- open-gpu + "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 (src/program)) ["--fmad=false" (sm-arch dev)]) + m (cu/module (cubin prog))] + {:ctx ctx + :device (:name (cc/info dev)) + :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 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 kr) (double ki))) + (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) :heavy? true :julia? true + :formulas (set (map first (keys (:kernels gpu)))) + :what (str "CUDA " (name precision) " on " (:device gpu))}) + (escape! [_ 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 + (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/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 new file mode 100644 index 000000000..43d20c182 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/oracle.clj @@ -0,0 +1,53 @@ +(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] + [fractal.formula :as formula])) + +(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)) + (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: 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 orb step-of] + r/IRenderer + (id [_] rid) + (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" orbit (steps))) 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..9758e7181 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/renderer/raster.clj @@ -0,0 +1,94 @@ +(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] + [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 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)] + (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)))) + +(defrecord Raster [rid threads] + r/IRenderer + (id [_] rid) + (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))) + +(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..e8ab4d72f --- /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 :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/src/fractal/tile.clj b/docs/examples/fractal/jvm/src/fractal/tile.clj new file mode 100644 index 000000000..20855a108 --- /dev/null +++ b/docs/examples/fractal/jvm/src/fractal/tile.clj @@ -0,0 +1,76 @@ +(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." + (:require [fractal.formula :as formula])) + +(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 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") + (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. + :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})) + (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)) + +(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 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] + {: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. 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 (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/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..dcbc8dcc1 --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/mutation_test.clj @@ -0,0 +1,102 @@ +(ns fractal.mutation-test + "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.oracle :as oracle] + [fractal.tile :as tile])) + +;; --- 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) (double zr') (double zi'))) + [n (+ (* zr zr) (* zi zi))])))) + +(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)}) + +(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) + :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])}) + +;; --- 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 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)] + (oracle/fill! orb step-of t field) + (golden/verdict v (tile/total-iters field) (palette/checksum (palette/paint pal field t))))) + (cond->> @views id (filter #(= id (:formula (golden/view->tile %)))))))) + +;; --- the suite -------------------------------------------------------------------- + +(deftest the-real-rules-survive + (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-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? 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 new file mode 100644 index 000000000..a6904033c --- /dev/null +++ b/docs/examples/fractal/jvm/test/fractal/property_test.clj @@ -0,0 +1,127 @@ +(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 testing]] + [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] + [fractal.formula :as formula])) + +(def gen-tile + "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 (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 %))) + (: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] + (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] + (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 + 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 [{: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/kernel.c b/docs/examples/fractal/kernel.c new file mode 100644 index 000000000..d6766db30 --- /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 (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 + +#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..84ec18adb --- /dev/null +++ b/docs/examples/fractal/kernel.rs @@ -0,0 +1,146 @@ +//! 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); the golden views show it +//! paints the same bytes as kernel.zig. +#![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 { + 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.wasm b/docs/examples/fractal/kernel.wasm new file mode 100755 index 000000000..46041eb1b Binary files /dev/null and b/docs/examples/fractal/kernel.wasm differ 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_c.wasm b/docs/examples/fractal/kernel_c.wasm new file mode 100755 index 000000000..ad1dacef7 Binary files /dev/null and b/docs/examples/fractal/kernel_c.wasm differ diff --git a/docs/examples/fractal/kernel_rust.wasm b/docs/examples/fractal/kernel_rust.wasm new file mode 100755 index 000000000..a11a7c826 Binary files /dev/null and b/docs/examples/fractal/kernel_rust.wasm differ diff --git a/docs/examples/fractal/kernel_simd.wasm b/docs/examples/fractal/kernel_simd.wasm new file mode 100755 index 000000000..86a89f80e Binary files /dev/null and b/docs/examples/fractal/kernel_simd.wasm differ 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 new file mode 100644 index 000000000..86b26c2aa --- /dev/null +++ b/docs/examples/fractal/server.clj @@ -0,0 +1,102 @@ +;; Fractal zoom: the cljw server, a thin boundary over the renderer registry. +;; Usage: cljw docs/examples/fractal/server.clj [port] (default 8080) +;; +;; 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] + [clojure.java.io :as io] + [clojure.string :as str])) + +(def dir (.getParent (io/file *file*))) +(load-file (str dir "/mandel.clj")) +(load-file (str dir "/cljw/tile.clj")) +(load-file (str dir "/cljw/renderer.clj")) +(load-file (str dir "/cljw/engines.clj")) + +(def public (str dir "/web/public")) +(def port (if (seq *command-line-args*) (Long/parseLong (first *command-line-args*)) 8080)) + +;; --- 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}))) + (aset arr i (unchecked-byte b)))) + arr)))) + +(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))) + +;; --- responses (pure, given the registry) ------------------------------------------ +(defn json-strings [xs] (str "[" (str/join "," (map #(str "\"" % "\"") xs)) "]")) + +(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"}))) + +(defn tile-response [q] + (try + (let [t (fractal.cljw.tile/parse-query q) + 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))}))) + +;; --- Boundary ------------------------------------------------------------------------ +(defn handler [req] + (let [q (fractal.cljw.tile/query-map (:query-string req))] + (case (:uri req) + ("/" "/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"} + :body bytes} + {:status 404 :body "unknown kernel\n"}) + "/engines" {:status 200 + :headers {"content-type" "application/json"} + :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 + " engines=" (vec (keys registry)))) +(http/run-server handler {:port port}) diff --git a/docs/examples/fractal/verify.mjs b/docs/examples/fractal/verify.mjs new file mode 100644 index 000000000..988f3f696 --- /dev/null +++ b/docs/examples/fractal/verify.mjs @@ -0,0 +1,43 @@ +// Browser-compatible module and byte-for-byte kernel oracle. Run from repo root: +// node docs/examples/fractal/verify.mjs +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; + +let reference; +for (const artifact of ['kernel', 'kernel_simd']) { + const module = await WebAssembly.compile(readFileSync(`docs/examples/fractal/${artifact}.wasm`)); + assert.deepEqual(WebAssembly.Module.imports(module), []); + const { exports: e } = await WebAssembly.instantiate(module); + assert.equal(e.fb_cap(), 1048576); + const checks = []; + for (const args of [[-.5, 0, 3.5 / 512, 512, 512, 256], + [-.743643887037151, .13182590420533, 1e-6, 128, 128, 2000], + [-.5, 0, .1, 31, 29, 80], [0, 0, .01, 1024, 1024, 1], + [0, 0, 0, 1, 1, 7], [2, 0, 0, 1, 1, 7]]) { + const [cx, cy, scale, w, h, limit] = args; + assert.equal(e.set_view(cx, cy, scale), 0); + const total = e.render(w, h, limit); + const scalar = Buffer.from(new Uint8Array(e.memory.buffer, e.fb_ptr(), w * h * 4)); + assert.equal(e.render_simd(w, h, limit), total); + assert.deepEqual(Buffer.from(new Uint8Array(e.memory.buffer, e.fb_ptr(), w * h * 4)), scalar); + for (let i = 3; i < scalar.length; i += 4) assert.equal(scalar[i], 255); + if (cx === 0 && cy === 0 && scale === 0) { + assert.equal(total, 7n); + assert.deepEqual(scalar, Buffer.from([0, 0, 0, 255])); + } + if (cx === 2 && scale === 0) assert.equal(total, 2n); + checks.push([total, scalar]); + } + const before = Buffer.from(new Uint8Array(e.memory.buffer, e.fb_ptr(), 16)); + for (const fn of [e.render, e.render_simd]) { + for (const args of [[0, 0, 1, 0, 1, 1], [0, 0, 1, 1025, 1024, 1], [NaN, 0, 1, 1, 1, 1]]) { + const [cx, cy, scale, w, h, limit] = args; + assert.equal(e.set_view(cx, cy, scale), Number.isFinite(cx) ? 0 : -1); + assert.equal(fn(w, h, limit), -1n); + assert.deepEqual(Buffer.from(new Uint8Array(e.memory.buffer, e.fb_ptr(), 16)), before); + } + } + if (reference) assert.deepEqual(checks, reference); + else reference = checks; + console.log(`PASS ${artifact}: zero imports, scalar/vector parity, alpha, odd tail, invalid input, known points`); +} diff --git a/docs/examples/fractal/web/.gitignore b/docs/examples/fractal/web/.gitignore new file mode 100644 index 000000000..760fc4df0 --- /dev/null +++ b/docs/examples/fractal/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.shadow-cljs/ +public/js/cljs-runtime/ +public/js/manifest.edn +target/ 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..7fab2161d --- /dev/null +++ b/docs/examples/fractal/web/public/index.html @@ -0,0 +1,61 @@ + + + + + +cljw fractal zoom + + + +
+ + + 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..0c6494dc2 --- /dev/null +++ b/docs/examples/fractal/web/public/js/main.js @@ -0,0 +1,1096 @@ +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 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){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:c)?a/b/1E3:null}function Ss(a,b,c,d){var e=U(a);a=B(e,Lj);var f=B(e,Hj);e=B(e,cl);var g=S(b,0,null);b=S(b,1,null);var k=S(c,0,null);c=S(c,1,null);return new Z(null,5,[Lj,a+(k+c/2-g/2)*e,Hj,f,cl,e/d,Rl,me.j(1,Math.round(c*d)),km,me.j(1,Math.round(b*d))],null)} +function Ts(a){return Uf.O(Uf.l(Uf.l(a,Lj,function(b){return b+.08*(Lj.i(Ls)-b)}),Hj,function(b){return b+.08*(Hj.i(Ls)-b)}),cl,ke,.96)}function Us(a){var b=Number(a);var c=a!=null;a=c?(a=Ye(a,""))?Number.isFinite(b):a:c;return u(a)?b:null} +function Vs(){function a(f){return u(c.has(f))?Us(c.get(f)):null}var b=location.hash,c=new URLSearchParams((u(b)?b:"").substring(1)),d=function(){var f=u(a("cx"))?T.l(W,Lj,a("cx")):W;f=u(a("cy"))?T.l(f,Hj,a("cy")):f;var g=a("scale");return u(g==null?null:g>0)?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])) + ;; 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] + (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!) + (rdc/render @root (r-root))) + +(defn init [] + (rf/dispatch-sync [::ev/init]) + (apply-hash!) + (set! js/window.fractalApi (test-surface)) + (js/window.addEventListener "keydown" on-key) + (js/window.addEventListener "hashchange" apply-hash!) + (reset! root (rdc/create-root (js/document.getElementById "app"))) + (rdc/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..983247fc2 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/engines.cljs @@ -0,0 +1,233 @@ +(ns fractal.engines + "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." + (: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.") + (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)) + +;; --- adapters ----------------------------------------------------------------- +(defrecord WasmKernel [rid ^js exports simd?] + 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) + _ (.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))) + +(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})) + +(defrecord JsRaster [] + 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. +(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) + (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) + (fn [buf] + (let [rms (js/parseFloat (or (.get (.-headers resp) "X-Render-Ms") "NaN"))] + {:pixels (js/Uint8ClampedArray. buf) + :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 ---------------------------------------------------------- +(defn jvm-url + "The JVM engines' server (jvm/, raster and CUDA). Default: port 8081 on this + 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 :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] + (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." + [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 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 {:renderers [...] :catalog [...]}: every renderer this page can + reach, in display order, and the formulas they can render between them." + [] + (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 {:ids [...] :caps {id + caps} :catalog [...]}." + [] + (.then (discover) + (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; rejects a tile + whose fractal that renderer does not render." + [engine tile iter] + (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." + [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..c0cb6748a --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/events.cljs @@ -0,0 +1,268 @@ +(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. + 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 + :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] + (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 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 (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)}]))) + (.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 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 :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 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])] + [: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]} [_ {: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)))) + +(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))))))))) + +;; 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]] + (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 :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)))) +(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. +;; The tour dives into seahorse valley, so it shows the Mandelbrot set. +(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 (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]]})) + +(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 + "[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? + "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 %)) (not (:skipped %))) rows)] + (or (empty? rs) (apply = (map (juxt :iters :checksum) rs))))) + views)) + +(defn run-bench! + "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 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] + (-> (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 (view/mits iters ms) :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 :views (mapv first views) :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 :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 new file mode 100644 index 000000000..0df6e7255 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/ui.cljs @@ -0,0 +1,249 @@ +(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 (vec engines) e)) (count palette)))) + +(defn- swatch [engines e] [:span {:style {:color (color-of engines e)}} "▬ "]) + +(defn- fmt [n d] (.toFixed n d)) + +(defn- fmt-rate + "Miter/s of iters over ms to d places, or - for a frame under the timer + resolution." + [iters ms d] + (if-let [m (view/mits iters ms)] (fmt m d) "-")) + +;; --- header ----------------------------------------------------------------------- +(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 {: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 {})] + (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"] + [fractal-controls] + [: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] (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" + (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)) + (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 {:keys [formula julia]}] + (if error + (str engine ": " error) + (str engine " " (fmt ms 1) " ms " (fmt-rate iters ms 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))) + +(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]) + 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 f))] + [:div#hudR.hud {:style {:display (if split? "block" "none")}} + (when-let [p (:R panes)] (hud-text p v iter f))]]))})) + +;; --- 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-rate iters ms 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. " [: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]) + {: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 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 + [: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 %)) (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 " + [:span {:class (if (and (= (:iters ref) (:iters r)) (= (:checksum ref) (:checksum r))) "ok" "bad")} + (if (:mits r) (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..6a6c323c3 --- /dev/null +++ b/docs/examples/fractal/web/src/fractal/view.cljs @@ -0,0 +1,112 @@ +(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}) + +(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." + [{: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 mits + "Million iterations per second of a frame, or nil when ms is not positive: + a frame under the timer resolution has no meaningful rate." + [iters ms] + (when (and (js/Number.isFinite ms) (pos? ms)) (/ iters ms 1000))) + +(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 + "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 :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"))) + 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")))) + (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 new file mode 100644 index 000000000..a0003d191 --- /dev/null +++ b/docs/examples/fractal/web/test/fractal/view_test.cljs @@ -0,0 +1,137 @@ +(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] + [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}) + 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))))) + +(def ^:private plane-point view/plane-point) + +(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)))))) + +(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) 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 a-frame-under-the-timer-resolution-has-no-rate + (is (nil? (view/mits 1000 0))) + (is (nil? (view/mits 1000 -1))) + (is (nil? (view/mits 1000 js/NaN))) + (check 200 (prop/for-all [iters (gen/choose 0 100000000) + ms (gen/double* {:min 0.001 :max 1e5 :NaN? false :infinite? false})] + (close? (/ iters ms 1000) (view/mits iters ms) 1e-12)))) + +(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)))))) + +;; --- 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')))))))) diff --git a/scripts/check_e2e_count.sh b/scripts/check_e2e_count.sh index 35bd7433f..c5fb8ff56 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=263 +BASELINE=231 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/src/eval/backend/intrinsic.zig b/src/eval/backend/intrinsic.zig index 2de69860e..d2c97d649 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, @@ -225,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)`) @@ -315,12 +368,49 @@ 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. +// 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()); +} + +/// 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 +471,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 +489,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 +559,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 1bb5d6675..7da68f89b 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| { @@ -754,7 +750,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). @@ -762,9 +758,9 @@ 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.fastBinaryFixnum(rt, aop, a, b) + try intrinsic.fastBinaryNumber(rt, aop, a, b) else null; const result = fast orelse blk: { @@ -878,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 @@ -894,7 +914,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: { @@ -925,7 +945,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: { @@ -970,7 +990,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: { @@ -1029,7 +1049,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 +1063,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 +1137,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 +1152,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 +1161,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 +1179,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 +1220,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 +1229,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 +1244,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 +1257,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 +1276,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 +1295,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 +1322,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 +1339,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 +1389,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 +1398,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 +1407,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 +1425,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 +1510,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 +1570,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 +1619,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. 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 a1526d73a..24b3b5237 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, @@ -368,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 @@ -464,6 +472,8 @@ pub const Opcode = enum(u8) { .op_get, .op_nth, .op_nth2, + .op_inc, + .op_dec, => false, }; } @@ -566,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/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj index ca1813ba8..1c0f84b9b 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) @@ -1146,11 +1139,17 @@ (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)) + ;; 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. @@ -1574,7 +1573,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. @@ -2351,42 +2353,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)}))))))) @@ -2415,8 +2404,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/src/lang/diff_test.zig b/src/lang/diff_test.zig index 4fcb7eb7b..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); @@ -271,6 +294,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 +342,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. 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/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/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/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/lang/primitive/reduce_kv_vector.zig b/src/lang/primitive/reduce_kv_vector.zig new file mode 100644 index 000000000..6d2db5668 --- /dev/null +++ b/src/lang/primitive/reduce_kv_vector.zig @@ -0,0 +1,44 @@ +// 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. +//! `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; +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 { + // (__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, + // 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/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/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/cljw/http/server.zig b/src/runtime/cljw/http/server.zig index c0cb076f2..c35543f9a 100644 --- a/src/runtime/cljw/http/server.zig +++ b/src/runtime/cljw/http/server.zig @@ -3,12 +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. 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 @@ -30,6 +30,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 +207,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/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..ce99eb5b1 --- /dev/null +++ b/src/runtime/cljw/wasm/component_sig_cache.zig @@ -0,0 +1,66 @@ +// 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 +//! Clojure peer: wasm/component-call + +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()); +} diff --git a/src/runtime/collection/map.zig b/src/runtime/collection/map.zig index 92949778a..6eacd0e0e 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 @@ -77,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 { @@ -158,7 +186,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 +235,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 +278,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 +292,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 +334,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 +342,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 +354,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 +383,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 +397,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 +416,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 +464,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 +512,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 +537,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 +570,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 +627,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 +674,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 +1054,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 +1224,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 +1264,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 +1298,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/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(); 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")); +} diff --git a/src/runtime/collection/transient/editable_hamt.zig b/src/runtime/collection/transient/editable_hamt.zig new file mode 100644 index 000000000..fe9ddfd6f --- /dev/null +++ b/src/runtime/collection/transient/editable_hamt.zig @@ -0,0 +1,173 @@ +// 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.*; + // 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; +} + +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 0315d8305..2d30d8129 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) }; @@ -101,14 +104,20 @@ 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; }, .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.*; + copy.header = HeapHeader.init(.hash_map); + 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 +141,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 +155,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 +226,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/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/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/gc_trigger.zig b/src/runtime/gc/gc_trigger.zig index ca2b7a657..2115928fa 100644 --- a/src/runtime/gc/gc_trigger.zig +++ b/src/runtime/gc/gc_trigger.zig @@ -17,15 +17,16 @@ //! 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; /// 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 @@ -144,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 @@ -183,16 +188,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/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(); +} 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/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| { 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/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/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 diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig index 2d863b53c..a2c6517e5 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). 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 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/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/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/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/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) 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/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/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)))))))) 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/clj/suites/reduce_helpers_test.clj b/test/clj/suites/reduce_helpers_test.clj index 8e74477c2..8fad541b8 100644 --- a/test/clj/suites/reduce_helpers_test.clj +++ b/test/clj/suites/reduce_helpers_test.clj @@ -20,3 +20,40 @@ (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}))) + ;; 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")) 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_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_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/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/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 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/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/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_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/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/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")) 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_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_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_fractal_kernel.sh b/test/e2e/phase16_fractal_kernel.sh new file mode 100644 index 000000000..52617ec92 --- /dev/null +++ b/test/e2e/phase16_fractal_kernel.sh @@ -0,0 +1,74 @@ +#!/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 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}" +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 "$@"; 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 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 artifacts do not reproduce golden.edn: +$out" +# 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" +[ "$(grep -c '^frame .* golden$' <<<"$out")" -eq 16 ] || fail "expected 16 golden frames: +$out" +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" \ + "$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" +(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 -> 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 diff --git a/test/e2e/phase16_fractal_server.sh b/test/e2e/phase16_fractal_server.sh new file mode 100755 index 000000000..39448dfd3 --- /dev/null +++ b/test/e2e/phase16_fractal_server.sh @@ -0,0 +1,90 @@ +#!/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: / (the page), /engines, /kernel.wasm?k= (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), plus +# the 400 refusals of an unknown engine and of a formula cljw does not render. +# 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 +URL="http://127.0.0.1:$PORT" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/cljw_fractal_srv_XXXXXX")" +# 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 + +up=0 +for _ in $(seq 1 100); do + if curl -s -o /dev/null "$URL/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="zig-jit zig-interp zig-simd-jit c-jit c-interp rust-jit rust-interp cljw-clj" +KERNELS="zig zig-simd c rust" +want='{"engines":["zig-jit","zig-interp","zig-simd-jit","c-jit","c-interp","rust-jit","rust-interp","cljw-clj"],"kernels":["zig","zig-simd","c","rust"]}' +engines="$(curl -s "$URL/engines")" +[[ "$engines" == "$want" ]] || fail "/engines: got '$engines'" +echo "PASS fractal-server-engines -> $engines" + +code=$(curl -s -o "$scratch/index.html" -w "%{http_code}" "$URL/") +[[ "$code" == "200" ]] && grep -q 'js/main.js' "$scratch/index.html" || fail "/: status $code, or no js/main.js" +echo "PASS fractal-server-page -> /" + +for k in $KERNELS; do + file="$DEMO/kernel.wasm" + [[ "$k" == zig ]] || file="$DEMO/kernel_${k//-/_}.wasm" + [[ "$k" == zig-simd ]] && file="$DEMO/kernel_simd.wasm" + curl -s -o "$scratch/k.$k" "$URL/kernel.wasm?k=$k" + cmp -s "$scratch/k.$k" "$file" || fail "/kernel.wasm?k=$k differs from $file" +done +echo "PASS fractal-server-kernels -> $KERNELS, identical to the committed files" + +W=64; H=48 +ref_sum=""; ref_iters="" +for e in $ENGINES; do + curl -s -D "$scratch/h.$e" -o "$scratch/t.$e" \ + "$URL/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 zig-jit" + [[ "$iters" == "$ref_iters" ]] || fail "/tile $e X-Iters $iters, zig-jit had $ref_iters" + fi +done +echo "PASS fractal-server-tiles -> ${W}x${H} RGBA on 8 engines, identical (X-Iters $ref_iters)" + +code=$(curl -s -o /dev/null -w "%{http_code}" "$URL/tile?engine=nope") +[[ "$code" == "400" ]] || fail "/tile unknown engine: status $code, want 400" +code=$(curl -s -o /dev/null -w "%{http_code}" "$URL/tile?engine=zig-jit&formula=tricorn") +[[ "$code" == "400" ]] || fail "/tile tricorn on zig-jit: status $code, want 400" +echo "PASS fractal-server-refusals -> 400 for an unknown engine and an unrendered formula" 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_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_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_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..301ba2223 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 @@ -57,6 +58,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 | 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="" +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 +131,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" 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_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_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_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_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_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_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/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_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_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_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_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/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: # 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 d368a61e7..358990e32 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 @@ -659,27 +659,11 @@ run_step "e2e_macro_throw_catchable" "bash test/e2e/macro_throw_catchable run_step "e2e_temporal_hash_eq" "bash test/e2e/temporal_hash_eq.sh" run_step "e2e_phase15_require_fs" "bash test/e2e/phase15_require_fs.sh" run_step "e2e_phase15_system_property" "bash test/e2e/phase15_system_property.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 @@ -687,18 +671,14 @@ 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 # 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. @@ -706,13 +686,16 @@ 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" +# 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" 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" @@ -761,7 +744,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"