From 7d012e4c4b3d6bde52111ab4230e5686125f9ddb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 8 Oct 2026 16:00:10 -0700 Subject: [PATCH] Add KidLisp readers, deterministic rendering checks and a Wasm ray benchmark Preserve daily-token prose as transcript, JSON and source files and verify pinned bytes before minting. Add readable rich text, named links and podcast readalong data with cached layout and media-clock highlighting. Add opt-in seeded execution and work limits, audited numeric plans, an f64 Wasm backend and 32 exact CPU-render golden frames. Stop boot color probing from executing arbitrary operations. Keep browser checks in development mode. Include a standalone reflective-sphere preview and a four-backend benchmark. Wasm compiles the numeric ray/sphere kernel; scene traversal and shading remain JavaScript. Rebuild the disk worker and sync its reference source. Validation: 55 focused Node tests, 29 Jasmine specs, color-pattern checks, worker integrity, exact RGBA parity across ray kernels, six-piece browser worker comparison, and interactive preview playback/controls. --- aesel/context/reference/lib/disk.mjs | 4 +- kidlisp/SCORE.md | 172 +- kidlisp/benchmarks/ray-sphere.lisp | 6 + kidlisp/benchmarks/raytrace-preview.mjs | 38 + kidlisp/benchmarks/raytrace.mjs | 106 + kidlisp/conformance/README.md | 96 +- kidlisp/conformance/oracle.mjs | 72 +- .../pixel-golden/accumulation-000.png | Bin 0 -> 389 bytes .../pixel-golden/accumulation-001.png | Bin 0 -> 425 bytes .../pixel-golden/accumulation-002.png | Bin 0 -> 459 bytes .../pixel-golden/accumulation-003.png | Bin 0 -> 486 bytes .../pixel-golden/accumulation-004.png | Bin 0 -> 547 bytes .../pixel-golden/accumulation-005.png | Bin 0 -> 612 bytes .../pixel-golden/accumulation-006.png | Bin 0 -> 691 bytes .../pixel-golden/accumulation-007.png | Bin 0 -> 767 bytes .../pixel-golden/accumulation-008.png | Bin 0 -> 840 bytes .../pixel-golden/accumulation-009.png | Bin 0 -> 905 bytes .../conformance/pixel-golden/alpha-000.png | Bin 0 -> 474 bytes .../conformance/pixel-golden/alpha-001.png | Bin 0 -> 474 bytes .../conformance/pixel-golden/alpha-002.png | Bin 0 -> 474 bytes .../pixel-golden/clock-input-000.png | Bin 0 -> 344 bytes .../pixel-golden/clock-input-001.png | Bin 0 -> 348 bytes .../pixel-golden/clock-input-002.png | Bin 0 -> 359 bytes .../pixel-golden/clock-input-003.png | Bin 0 -> 361 bytes .../pixel-golden/clock-input-004.png | Bin 0 -> 373 bytes .../pixel-golden/clock-input-005.png | Bin 0 -> 359 bytes .../pixel-golden/clock-input-006.png | Bin 0 -> 359 bytes .../pixel-golden/clock-input-007.png | Bin 0 -> 362 bytes .../pixel-golden/clock-input-008.png | Bin 0 -> 372 bytes .../conformance/pixel-golden/manifest.json | 235 + .../pixel-golden/primitives-000.png | Bin 0 -> 613 bytes .../pixel-golden/primitives-001.png | Bin 0 -> 613 bytes .../conformance/pixel-golden/random-000.png | Bin 0 -> 849 bytes .../conformance/pixel-golden/random-001.png | Bin 0 -> 881 bytes .../conformance/pixel-golden/random-002.png | Bin 0 -> 976 bytes .../conformance/pixel-golden/random-003.png | Bin 0 -> 1075 bytes .../conformance/pixel-golden/random-004.png | Bin 0 -> 948 bytes .../conformance/pixel-golden/random-005.png | Bin 0 -> 816 bytes .../conformance/pixel-golden/random-006.png | Bin 0 -> 747 bytes .../conformance/pixel-golden/random-007.png | Bin 0 -> 731 bytes kidlisp/conformance/pixel-worker.mjs | 147 + kidlisp/conformance/pixels.mjs | 146 + kidlisp/conformance/pixels/accumulation.json | 4 + kidlisp/conformance/pixels/alpha.json | 4 + kidlisp/conformance/pixels/clock-input.json | 9 + kidlisp/conformance/pixels/primitives.json | 4 + kidlisp/conformance/pixels/random.json | 4 + kidlisp/conformance/replay.mjs | 88 + kidlisp/conformance/seeded-input.json | 14 + kidlisp/tools/bench-raytrace.mjs | 44 + kidlisp/tools/bench-rich-text.mjs | 41 + kidlisp/tools/build-raytrace-preview.mjs | 21 + kidlisp/tools/operations.mjs | 4 + marketing/podcast/SCORE.md | 49 +- .../podcast/bin/daily-artifact-check.mjs | 44 +- marketing/podcast/bin/daily-token.mjs | 27 +- marketing/podcast/bin/read-along-preview.mjs | 26 + marketing/podcast/bin/read-along.mjs | 24 + marketing/podcast/lib/artifact.mjs | 62 + marketing/podcast/lib/crawl.mjs | 41 +- marketing/podcast/lib/daily-richtext.mjs | 59 + marketing/podcast/lib/read-along.mjs | 36 + .../podcast/test/daily-artifact.test.mjs | 53 +- marketing/podcast/test/read-along.test.mjs | 15 + package.json | 2 + system/public/aesthetic.computer/bios.mjs | 27 +- .../lib/disk-worker-manifest.json | 10 +- system/public/aesthetic.computer/lib/disk.mjs | 4 +- .../lib/disk.worker.9efca6b25128.mjs | 48659 ++++++++++++++++ .../lib/kidlisp-execution.mjs | 86 + .../aesthetic.computer/lib/kidlisp-ops.mjs | 24 + .../lib/kidlisp-plan-wasm.mjs | 97 + .../aesthetic.computer/lib/kidlisp-plan.mjs | 62 + .../lib/kidlisp-reference.mjs | 25 + .../public/aesthetic.computer/lib/kidlisp.mjs | 135 +- .../aesthetic.computer/lib/rich-text.mjs | 266 + tests/kidlisp-execution.test.mjs | 94 + tests/kidlisp-pixels.test.mjs | 108 + tests/kidlisp-plan-wasm.test.mjs | 50 + tests/kidlisp-plan.test.mjs | 63 + tests/kidlisp-raytrace.test.mjs | 26 + tests/kidlisp-rich-text.test.mjs | 143 + 82 files changed, 51486 insertions(+), 90 deletions(-) create mode 100644 kidlisp/benchmarks/ray-sphere.lisp create mode 100644 kidlisp/benchmarks/raytrace-preview.mjs create mode 100644 kidlisp/benchmarks/raytrace.mjs create mode 100644 kidlisp/conformance/pixel-golden/accumulation-000.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-001.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-002.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-003.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-004.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-005.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-006.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-007.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-008.png create mode 100644 kidlisp/conformance/pixel-golden/accumulation-009.png create mode 100644 kidlisp/conformance/pixel-golden/alpha-000.png create mode 100644 kidlisp/conformance/pixel-golden/alpha-001.png create mode 100644 kidlisp/conformance/pixel-golden/alpha-002.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-000.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-001.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-002.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-003.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-004.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-005.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-006.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-007.png create mode 100644 kidlisp/conformance/pixel-golden/clock-input-008.png create mode 100644 kidlisp/conformance/pixel-golden/manifest.json create mode 100644 kidlisp/conformance/pixel-golden/primitives-000.png create mode 100644 kidlisp/conformance/pixel-golden/primitives-001.png create mode 100644 kidlisp/conformance/pixel-golden/random-000.png create mode 100644 kidlisp/conformance/pixel-golden/random-001.png create mode 100644 kidlisp/conformance/pixel-golden/random-002.png create mode 100644 kidlisp/conformance/pixel-golden/random-003.png create mode 100644 kidlisp/conformance/pixel-golden/random-004.png create mode 100644 kidlisp/conformance/pixel-golden/random-005.png create mode 100644 kidlisp/conformance/pixel-golden/random-006.png create mode 100644 kidlisp/conformance/pixel-golden/random-007.png create mode 100644 kidlisp/conformance/pixel-worker.mjs create mode 100644 kidlisp/conformance/pixels.mjs create mode 100644 kidlisp/conformance/pixels/accumulation.json create mode 100644 kidlisp/conformance/pixels/alpha.json create mode 100644 kidlisp/conformance/pixels/clock-input.json create mode 100644 kidlisp/conformance/pixels/primitives.json create mode 100644 kidlisp/conformance/pixels/random.json create mode 100644 kidlisp/conformance/replay.mjs create mode 100644 kidlisp/conformance/seeded-input.json create mode 100644 kidlisp/tools/bench-raytrace.mjs create mode 100644 kidlisp/tools/bench-rich-text.mjs create mode 100644 kidlisp/tools/build-raytrace-preview.mjs create mode 100644 kidlisp/tools/operations.mjs create mode 100644 marketing/podcast/bin/read-along-preview.mjs create mode 100644 marketing/podcast/bin/read-along.mjs create mode 100644 marketing/podcast/lib/daily-richtext.mjs create mode 100644 marketing/podcast/lib/read-along.mjs create mode 100644 marketing/podcast/test/read-along.test.mjs create mode 100644 system/public/aesthetic.computer/lib/disk.worker.9efca6b25128.mjs create mode 100644 system/public/aesthetic.computer/lib/kidlisp-execution.mjs create mode 100644 system/public/aesthetic.computer/lib/kidlisp-ops.mjs create mode 100644 system/public/aesthetic.computer/lib/kidlisp-plan-wasm.mjs create mode 100644 system/public/aesthetic.computer/lib/kidlisp-plan.mjs create mode 100644 system/public/aesthetic.computer/lib/rich-text.mjs create mode 100644 tests/kidlisp-execution.test.mjs create mode 100644 tests/kidlisp-pixels.test.mjs create mode 100644 tests/kidlisp-plan-wasm.test.mjs create mode 100644 tests/kidlisp-plan.test.mjs create mode 100644 tests/kidlisp-raytrace.test.mjs create mode 100644 tests/kidlisp-rich-text.test.mjs diff --git a/aesel/context/reference/lib/disk.mjs b/aesel/context/reference/lib/disk.mjs index 991821a46b..2cad927b0a 100644 --- a/aesel/context/reference/lib/disk.mjs +++ b/aesel/context/reference/lib/disk.mjs @@ -11941,10 +11941,10 @@ async function makeFrame({ data: { type, content } }) { // 🎡 Streaming Audio messages - forward to piece receive function // (the whole family: playing/paused/stopped/error/state, time-data, // seeked, frequencies/waveform/peaks-data, speed-data) - if (type.startsWith("stream:")) { + if (type.startsWith("stream:") || type === "kidlisp:data") { if (typeof receive === "function") { try { - receive({ type, content }); + receive({ type, content }, cachedAPI); } catch (e) { console.warn("🎡 Stream receive error:", e); } diff --git a/kidlisp/SCORE.md b/kidlisp/SCORE.md index 90bf75a11f..052dfddcf3 100644 --- a/kidlisp/SCORE.md +++ b/kidlisp/SCORE.md @@ -39,10 +39,10 @@ Every implementation in the monorepo, with claimed conformance level and current | Runtime | Path | Decree claim | Status | Notes | |---|---|---|---|---| -| **JS** (reference) | `system/public/aesthetic.computer/lib/kidlisp.mjs` | `'26: Core + Render + Audio` | shipping | Canonical; spec defers to behavior here when ambiguous | +| **JS** (reference) | `system/public/aesthetic.computer/lib/kidlisp.mjs` | `'26: Core + Render + Audio` | shipping baseline | Canonical; local opt-in execution controls and numeric-plan tooling are experimental (see Β§9) | | **Common Lisp** (AC Native) | `fedac/native/cl/kidlisp-*.lisp` | `'26: Core + Render` (target) | in progress | Tree-walker, DRM/KMS framebuffer; replacing QuickJS path | | **Swift** (Menuband) | `slab/menuband/` | `'26: Core + Render` (planned) | not started | This document's motivating port; Metal blit + CPU framebuffer | -| **WASM** | `kidlisp-wasm/` | unclaimed | experimental | Compiler approach (`compiler.mjs`) | +| **WASM** | `kidlisp-wasm/`; `lib/kidlisp-plan-wasm.mjs` | unclaimed | experimental | Existing f32 renderer compiler; separate f64 `numeric-v1` backend preserves audited JS arithmetic (Β§9) | | **Playdate** | `kidlisp-playdate/` | unclaimed | experimental | C runtime for Panic Playdate | | **Game Boy** | `kidlisp-gameboy/` | unclaimed | experimental | GBDK C + asm | | **N64** | `kidlisp-n64/` | unclaimed | experimental | Bare-metal asm exploration | @@ -106,3 +106,171 @@ A port hitting phase 3 can publish `KidLisp Decree '26: Core + Render + RBP-26`. - API map: [`COMPLETE_API_MAP.md`](COMPLETE_API_MAP.md) - Directory map: [`STRUCTURE.md`](STRUCTURE.md) - Top-level project score: [`../SCORE.md`](../SCORE.md) + +## 8. High-level assembly direction (proposal) + +KidLisp source is a compact, human-editable instruction language for a visual +computer. A compiler can lower its expressions into an execution plan with +explicit values, resources, effects, and update dependencies. Programs retain +their source and meaning across hosts; each host implements the same operations +using its own renderer and media system. + +This is an optimization direction, not a new Decree conformance claim or a +replacement compiler already implemented across the language. The existing WASM +compiler and JS precompile paths are precedents to consult before adding a +shared intermediate representation. + +### Execution contract + +| Stage | Responsibility | +|---|---| +| Parse | Source locations and canonical syntax; preserve inspectable source | +| Resolve | Bind operations and variables to known operations and value slots | +| Lower | Record pure calculations, ordered effects, resources, and dependencies | +| Prepare | Load data and build reusable layouts, buffers, and input regions | +| Update | Recompute values whose inputs changed; execute required effects in source order | +| Host | Draw, play media, and deliver input without changing language semantics | + +An operation records its inputs, result shape, effect class, dependencies, and +host capability. Constant arithmetic may fold; stable text may lay out once; +viewport expressions update on resize; media bindings update from the media +clock. Clock, frame, random, ink, page, timers, input, and network operations +have distinct dependencies. Unknown operations keep the reference evaluator +path until their behavior has an explicit contract. + +Optimization must preserve evaluation order, random consumption, timer behavior, +mutable graphics state, errors, and lifecycle cleanup. A drawing or random effect +cannot disappear because its arguments stayed constant. Fetching data cannot +execute source. Unsupported host capabilities must be discoverable and fail +explicitly. Future compiled plans should be versioned and inspectable alongside +the original source, with stable operation identifiers and source mappings. + +### First example: a prepared reader + +```lisp +(wipe black) +(def episode (fetch "episode.json")) +(flow (listen episode)) +``` + +The local JS prototype lowers the episode into cached text lines, link regions, +and positioned word fragments. It validates canonical prose and ordered cues +once per loaded resource. A viewport change rebuilds geometry; an audio-clock +update selects a cue by binary search and paints visible lines. Resource records +are immutable after loading: replace the record to change its contents. Generic +`flow` expressions still compare their values so dynamic prose keeps working. + +The reader uses a specialized execution plan. The local `numeric-v1` compiler +now also lowers an audited arithmetic subset into constant, input-slot, and call +instructions; it is available explicitly and does not replace reference +evaluation. `fetch`, `get`, `listen`, and rich-text forms are local JS extensions; +other runtimes have not claimed them. + +### Performance evidence and gates + +Run `node kidlisp/tools/bench-rich-text.mjs [path-to-rich-text-module]` for a +repeatable synthetic reader benchmark. On Neo, an initial before/after sample +put 295-word steady-frame bookkeeping at approximately 7 ΞΌs / 1 ΞΌs; a 10,000-word +sample was approximately 77 ΞΌs / 1 ΞΌs. Preparation became more expensive because +it builds the word geometry up front. These figures exclude glyph rasterization, +the KidLisp interpreter, audio decoding, and GPU presentation. They establish a +specific optimization, not total frame latency or a device-wide speed claim. + +A five-second headless Chrome sample of the packed reader at 990 Γ— 270 while +playing narration measured 0.599 seconds of main-thread task time (0.469 seconds +of script time). That covers the packed host as well as the reader; it excludes +startup, other browser processes, and GPU/audio work. Playback, pause, seek, +and the highlighted render were checked separately. This is one desktop sample, +not a mobile or frame-rate guarantee. + +A compiler optimization needs semantic differential tests and rendered evidence +under fixed input, clock, and random seeds. Measure preparation time, steady +frame median and tail latency, allocation/GC, memory, audio clock lag, and dropped +frames separately. Test representative pieces and long resources on intended +hosts. Keep the current corpus oracle for deployment regression checks; its +noise-tolerant images cannot establish exact compiler equivalence by themselves. + +## 9. Engineering constraints + +These are the rules for growing KidLisp. Existing paths that do not yet meet a +rule need an explicit limitation and a migration test; an optimization does not +establish a new language behavior merely by being faster. + +1. **Preserve meaning.** Changes to evaluation order, numeric edge cases, random + consumption, timers, graphics state, or lifecycle are language changes. + Optimizations need differential tests against the reference evaluator. +2. **Make execution reproducible.** Conformance profiles declare their clock, + seed, input stream, viewport, resources, and host capabilities. State belongs + to a runtime instance; tests must not patch global time or randomness. +3. **Declare instruction contracts.** Audited operations specify stable IDs, + arguments, results, effects, dependencies, and supported profiles. Tooling + derives its metadata from that registry. Unknown effects stay unoptimized. +4. **Bound work and ownership.** Nested evaluation shares a work budget; depth, + source size, memory, caches, requests, and media have explicit limits and + owners. Leaving a piece disposes its resources. Exhaustion must be actionable + and unwind cleanly. +5. **Make measurement observational.** Enabling a profiler must preserve program + behavior. Report preparation, execution, rendering, allocation, memory, and + slow-frame results separately, with the host and workload specified. +6. **Preserve inspectability and portability.** Keep canonical source and content, + source mappings, versioned plans, declared capabilities, and pinned assets. + An unsupported host capability must fail explicitly. + +### Implemented foundation (local, opt-in) + +The JS reference accepts `new KidLisp({ execution })`, where `execution` is a +`KidLispExecution` from `lib/kidlisp-execution.mjs`. The host calls +`execution.beginFrame(frame)` once before that frame's inputs and evaluation. +The context supplies a uint32 seed, epoch, frame duration, and shared work/depth +ledger. Embedded instances derive labeled seeds and inherit that ledger. Source +parsing is capped at one million characters and the selected nesting depth. +Budget failures include a code, frame, work count, and operation; failed frames +remain failed until the host begins the next frame. Function scope is unwound. + +`kidlisp/conformance/replay.mjs` exposes the bounded `commands-v1` fixture runner. +`lib/kidlisp-ops.mjs` owns eight audited numeric contracts and their aliases; +`node kidlisp/tools/operations.mjs` emits their versioned JSON manifest. +`lib/kidlisp-plan.mjs` exposes `compileNumeric(expression, { bindings, fold })` +and `runNumeric(plan, inputs, execution?)`. Plans carry source AST paths, explicit +input slots, and numeric encoding that survives JSON even for -0, NaN, and +infinities. Compilation has a node/depth budget. Plans contain data and execute +without dynamic JavaScript generation. Effects and undeclared bindings are +rejected. This numeric ABI is experimental, not a ratified Decree guarantee. + +Controlled evaluation bypasses the legacy profiler-dependent arithmetic cache +and diagnostic early-stop heuristic so monitoring is observational in this +profile. Default browser execution and its existing profiler paths have not +been migrated. The work ledger measures evaluator/plan work; host rasterization, +buffer allocation, network, and media still need their own resource budgets. +Command replay does not establish pixel conformance or complete media replay. +The registry covers the audited numeric subset; migrating the remaining +instructions and generated editor/reference documentation is subsequent work. + +`kidlisp/conformance/pixels.mjs check` now provides exact `pixels-v1` CPU renders +through the reference module lifecycle and real software rasterizer. Five pinned +fixtures check every RGBA byte, including alpha, against reviewed PNGs and a +second isolated run. It rejects unsupported capabilities, stale goldens, and +swallowed rendering errors. Its first failure exposed boot-time color detection +executing arbitrary first operations; detection now consults registered colors. +The profile's fixed simulation cadence, bounds, and exclusions are specified in +`conformance/README.md`; it does not claim full browser or GPU conformance. + +`lib/kidlisp-plan-wasm.mjs` compiles the eight `numeric-v1` operations to native +f64 Wasm, retaining reference division, remainder, rounding, NaN, and signed-zero +behavior. It imports only pure remainder when needed, validates inputs, and +charges the supplied execution ledger before running. It is an explicit backend, +not the default evaluator. The older f32 compiler remains a separate experiment. + +`kidlisp/benchmarks/ray-sphere.lisp` is a first rendering-kernel workload: three +spheres, a checker floor, hard shadows, and two reflection bounces. The benchmark +checks exact frames across reference evaluation, numeric plans, Wasm, and direct +JS. It reports preparation, median and p95 whole CPU frame time, resolution, +ray/intersection counts, host, and hashes. JS still owns scene traversal, shading, +and the pixel loop. Include that boundary cost; do not call kernel timing full +runtime performance or promise photorealism from this scene. Compile larger +batches only after measuring this baseline and preserving its pixels. + +Required next gates: fonts/media/GPU in controlled pixel profiles; host +memory/resource ownership; the remaining instruction contracts; then effectful +compiler lowering. Keep the browser corpus gate alongside deterministic checks +until its resources and frame delivery are controlled end to end. diff --git a/kidlisp/benchmarks/ray-sphere.lisp b/kidlisp/benchmarks/ray-sphere.lisp new file mode 100644 index 0000000000..68fc0b64a5 --- /dev/null +++ b/kidlisp/benchmarks/ray-sphere.lisp @@ -0,0 +1,6 @@ +; Squared-distance discriminant for a unit ray and a sphere. +; Inputs: origin relative to sphere center, ray direction, radius. +(- + (* (+ (* ox dx) (* oy dy) (* oz dz)) + (+ (* ox dx) (* oy dy) (* oz dz))) + (- (+ (* ox ox) (* oy oy) (* oz oz)) (* radius radius))) diff --git a/kidlisp/benchmarks/raytrace-preview.mjs b/kidlisp/benchmarks/raytrace-preview.mjs new file mode 100644 index 0000000000..df322b8272 --- /dev/null +++ b/kidlisp/benchmarks/raytrace-preview.mjs @@ -0,0 +1,38 @@ +import { rayKernel, renderRayFrame } from "./raytrace.mjs"; + +// The builder embeds the canonical .lisp kernel; this preview makes no requests. +const source = KIDLISP_RAY_SOURCE; +const canvas = document.querySelector("canvas"); +const context = canvas.getContext("2d"); +const backend = document.querySelector("#backend"); +const resolution = document.querySelector("#resolution"); +const status = document.querySelector("output"); +const pause = document.querySelector("button"); +let kernel = rayKernel(source, backend.value), frame = 0, running = true; +let samples = [], intervals = [], previous = 0; +backend.addEventListener("change", () => { kernel = rayKernel(source, backend.value); samples = []; intervals = []; previous = 0; }); +resolution.addEventListener("change", () => { samples = []; intervals = []; previous = 0; }); +pause.addEventListener("click", () => { running = !running; pause.textContent = running ? "Pause" : "Play"; }); +function paint(now) { + if (running) { + try { + const [width, height] = resolution.value.split("x").map(Number); + const started = performance.now(); + const result = renderRayFrame(kernel, { width, height, frame: frame++ }); + const renderMs = performance.now() - started; + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + context.putImageData(new ImageData(result.rgba, width, height), 0, 0); + samples.push(renderMs); + if (samples.length > 30) samples.shift(); + const median = [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; + if (previous) intervals.push(now - previous); + if (intervals.length > 30) intervals.shift(); + const fps = intervals.length ? 1000 * intervals.length / intervals.reduce((a, b) => a + b, 0) : 0; + status.textContent = `${median.toFixed(1)} ms render Β· ${Math.round(fps)} fps`; + previous = now; + window.raytracePreview = { backend: backend.value, width, height, frame, medianMs: median, fps }; + } catch (error) { running = false; status.textContent = error.message; } + } else { previous = 0; intervals = []; } + requestAnimationFrame(paint); +} +requestAnimationFrame(paint); diff --git a/kidlisp/benchmarks/raytrace.mjs b/kidlisp/benchmarks/raytrace.mjs new file mode 100644 index 0000000000..2df9148bc6 --- /dev/null +++ b/kidlisp/benchmarks/raytrace.mjs @@ -0,0 +1,106 @@ +// Experimental benchmark host. KidLisp owns the ray/sphere numeric kernel; +// JS currently owns scene traversal, shading, reflections, and the pixel loop. +import { KidLisp } from "../../system/public/aesthetic.computer/lib/kidlisp.mjs"; +import { KidLispExecution } from "../../system/public/aesthetic.computer/lib/kidlisp-execution.mjs"; +import { compileNumeric, runNumeric } from "../../system/public/aesthetic.computer/lib/kidlisp-plan.mjs"; +import { instantiateNumericWasm } from "../../system/public/aesthetic.computer/lib/kidlisp-plan-wasm.mjs"; + +export const RAY_BINDINGS = ["ox", "oy", "oz", "dx", "dy", "dz", "radius"]; +export function rayKernel(source, backend = "wasm") { + const execution = new KidLispExecution({ seed: 0, maxSteps: 100000000 }); + const lisp = new KidLisp({ execution }); + const forms = lisp.parse(source); + if (forms.length !== 1) throw new TypeError("Ray kernel needs one numeric expression"); + const ast = forms[0]; + const plan = compileNumeric(ast, { bindings: RAY_BINDINGS }); + const api = { screen: { width: 1, height: 1 } }; + let evaluate, bytes = 0; + if (backend === "reference") evaluate = values => { + RAY_BINDINGS.forEach((name, i) => { lisp.localEnv[name] = values[i]; }); + return lisp.evaluate(ast, api); + }; + else if (backend === "plan") evaluate = values => runNumeric(plan, values); + else if (backend === "wasm") { + const wasm = instantiateNumericWasm(plan); + evaluate = wasm.run; + bytes = wasm.bytes.length; + } else if (backend === "direct-js") evaluate = ([ox, oy, oz, dx, dy, dz, radius]) => { + const b = 0 + ox * dx + oy * dy + oz * dz; + return b * b - ((0 + ox * ox + oy * oy + oz * oz) - radius * radius); + }; + else throw new TypeError(`Unknown ray backend: ${backend}`); + return { backend, bytes, plan, evaluate, beginFrame: frame => execution.beginFrame(frame) }; +} + +const normalize = (x, y, z) => { const length = Math.sqrt(x * x + y * y + z * z); return [x / length, y / length, z / length]; }; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const sky = d => { + const t = Math.max(0, Math.min(1, 0.5 + d[1] * 0.5)); + return [0.12 + t * 0.37, 0.17 + t * 0.49, 0.25 + t * 0.65]; +}; + +export function renderRayFrame(kernel, { width = 96, height = 54, frame = 0, bounces = 2 } = {}) { + if (![width, height].every(n => Number.isInteger(n) && n > 0 && n <= 512) || !Number.isInteger(frame) || frame < 0 || frame > 1000000 || !Number.isInteger(bounces) || bounces < 0 || bounces > 3) throw new RangeError("Invalid ray frame controls"); + kernel.beginFrame(frame); + const rgba = new Uint8ClampedArray(width * height * 4); + const spheres = [ + { center: [-1.12, -0.12, 2.3], radius: 0.88, color: [0.78, 0.075, 0.055], mirror: 0.30 }, + { center: [1.0, -0.35, 1.8], radius: 0.65, color: [0.48, 0.56, 0.66], mirror: 0.82 }, + { center: [0.05 + Math.sin(frame * 0.04) * 0.28, 0.05, 4.15], radius: 1.05, color: [0.045, 0.48, 0.31], mirror: 0.20 }, + ]; + const light = [-3.5, 5.5, -1.5]; + let rays = 0, intersections = 0; + const intersect = (origin, direction, limit = Infinity) => { + rays++; + let nearest = limit, hit = null; + for (const sphere of spheres) { + const relative = origin.map((v, i) => v - sphere.center[i]); + intersections++; + const discriminant = kernel.evaluate([...relative, ...direction, sphere.radius]); + if (discriminant < 0) continue; + const b = dot(relative, direction), root = Math.sqrt(discriminant); + let t = -b - root; + if (t < 0.0001) t = -b + root; + if (t > 0.0001 && t < nearest) { + nearest = t; + const position = origin.map((v, i) => v + direction[i] * t); + hit = { position, normal: position.map((v, i) => (v - sphere.center[i]) / sphere.radius), ...sphere }; + } + } + if (Math.abs(direction[1]) > 1e-9) { + const t = (-1 - origin[1]) / direction[1]; + if (t > 0.0001 && t < nearest) { + const position = origin.map((v, i) => v + direction[i] * t); + const checker = ((Math.floor(position[0]) + Math.floor(position[2])) % 2 + 2) % 2; + hit = { position, normal: [0, 1, 0], color: checker ? [0.36, 0.34, 0.30] : [0.09, 0.105, 0.13], mirror: 0.18 }; + } + } + return hit; + }; + const trace = (origin, direction, depth) => { + const hit = intersect(origin, direction); + if (!hit) return sky(direction); + const toLight = light.map((v, i) => v - hit.position[i]); + const lightDistance = Math.sqrt(dot(toLight, toLight)); + const lightDirection = toLight.map(v => v / lightDistance); + const offset = hit.position.map((v, i) => v + hit.normal[i] * 0.001); + const shadowed = intersect(offset, lightDirection, lightDistance) !== null; + const diffuse = Math.max(0, dot(hit.normal, lightDirection)) * (shadowed ? 0.08 : 0.95); + const half = normalize(lightDirection[0] - direction[0], lightDirection[1] - direction[1], lightDirection[2] - direction[2]); + const specular = shadowed ? 0 : Math.pow(Math.max(0, dot(hit.normal, half)), 80) * 0.8; + const local = hit.color.map(c => c * (0.16 + diffuse) + specular); + if (depth === 0) return local; + const incidence = 2 * dot(direction, hit.normal); + const reflection = normalize(...direction.map((v, i) => v - incidence * hit.normal[i])); + const reflected = trace(offset, reflection, depth - 1); + return local.map((c, i) => c * (1 - hit.mirror) + reflected[i] * hit.mirror); + }; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const direction = normalize(((x + 0.5) / width * 2 - 1) * width / height * 0.52, (1 - (y + 0.5) / height * 2) * 0.52 - 0.09, 1); + const color = trace([0, 0.45, -4.2], direction, bounces); + const offset = (x + y * width) * 4; + for (let c = 0; c < 3; c++) rgba[offset + c] = Math.round(Math.sqrt(Math.max(0, Math.min(1, color[c]))) * 255); + rgba[offset + 3] = 255; + } + return { width, height, frame, rgba, rays, intersections }; +} diff --git a/kidlisp/conformance/README.md b/kidlisp/conformance/README.md index 24d526afbc..0b809a9014 100644 --- a/kidlisp/conformance/README.md +++ b/kidlisp/conformance/README.md @@ -1,4 +1,81 @@ -# conformance β€” the KidLisp pixel oracle +# KidLisp conformance + +Run the exact CPU pixel gate: + +```sh +node kidlisp/conformance/pixels.mjs check --out /tmp/kidlisp-pixels +npm run test:kidlisp:exact +``` + +`pixels-v1` runs the reference module's boot, pointer input, simulation, paint, +and leave lifecycle against AC's real `graph.mjs` software rasterizer. Each run +owns a Node worker, isolating the renderer's module state. Each zero-based frame +delivers its recorded inputs, advances simulation once, and paints once. Seed, +clock, viewport, and host FPS (60) are fixed. Unspecified host ink uses the +instance's seeded RNG; global time and randomness are never patched. + +The five fixtures cover primitives, clipping, seeded random geometry, alpha, +persistent layer compositing, timers, and pointer state. Every frame is rendered +twice and compared byte-for-byte, including alpha, with checked-in PNGs and +SHA-256 hashes. A single changed pixel fails. The report identifies its first +coordinate and old/new RGBA; failures also save expected, actual, and difference +images. Missing or corrupted goldens, renderer warnings/errors, and unsupported +capabilities fail rather than accepting blank output. Checks never update goldens. + +To deliberately record reviewed reference images: + +```sh +node kidlisp/conformance/pixels.mjs record --out /tmp/kidlisp-pixels-review +``` + +Inspect the output and review both `pixel-golden/manifest.json` and the PNGs. +Recording runs the repeat check first. A changed fixture requires an explicit +recording; it cannot silently borrow an old expected image. + +This profile is CPU 2D only. Fonts, rich text, media, network resources, embeds, +GPU effects, gradients, and live color patterns are excluded. Unsupported +values are rejected before module execution. Drawing coordinates must be finite +and within Β±8192; `line` needs explicit coordinates (its host defaults are +unseeded). A replay allows at most 512 pixels per axis, 240 frames, 32 MiB of +captured RGBA, 10,000 inputs, and 15 seconds by default. Evaluator work/depth +budgets also apply. These bounds are a test-host policy, not general production +resource ownership. Golden metadata records Node and platform; cross-engine +pixel portability has not been established. The browser corpus remains a +separate integration gate. + +For a deterministic reference-evaluator **command trace**, run: + +```sh +node kidlisp/conformance/replay.mjs kidlisp/conformance/seeded-input.json +node --test tests/kidlisp-execution.test.mjs tests/kidlisp-plan.test.mjs +``` + +`commands-v1` pins seed, frame time, viewport, and pointer inputs. It records +the drawing calls made by the reference evaluator and emits their SHA-256. +Timers and pointer handlers are included; the compositor, pixels, media, +network resources, microphone, and clock-dependent color patterns are outside +this profile. Unsupported operations fail before module execution. Frames +share one work ledger with their input handlers and nested evaluation. + +The numeric-plan tests compare folded and unfurled plans with the independent +reference evaluator, including changing input slots, zero division, negative +zero, NaN, and infinities. The f64 Wasm backend runs these same contracts; `%` +uses an explicit pure JS remainder import because Wasm has no f64 remainder. + +The ray-tracing benchmark compares exact frames from the reference evaluator, +numeric plans, Wasm, and direct JS, and measures whole CPU frame time: + +```sh +node kidlisp/tools/bench-raytrace.mjs /tmp/kidlisp-raytrace +node kidlisp/tools/build-raytrace-preview.mjs /tmp/kidlisp-raytrace/preview.html +``` + +Its `.lisp` kernel computes ray/sphere discriminants. Scene traversal, shading, +hard shadows, reflections, and the pixel loop are still JS. The self-contained +preview exposes backend, resolution, and pause controls. This is an experimental +ray tracer, not a photorealistic path tracer or a fully ported Wasm runtime. + +## Browser corpus Renders the corpus (the top pieces by live hits, `corpus.json`) from a checkout and compares it against another, so a KidLisp change is judged by what it draws. @@ -7,6 +84,7 @@ This is the gate every agent PR runs; its sheets go in the PR body. ```bash node kidlisp/conformance/oracle.mjs check # this checkout vs origin/main (~8 min, exit 1 = broke) node kidlisp/conformance/oracle.mjs check --against HEAD # vs your last commit +node kidlisp/conformance/oracle.mjs check --worker-against HEAD # same host, compare committed worker without a checkout node kidlisp/conformance/oracle.mjs refresh --top 40 # re-pull the corpus from prod node kidlisp/conformance/oracle.mjs render --out DIR [--repo PATH | --base URL] [--only bop,pie] [--at 1500,4000] [--video] node kidlisp/conformance/oracle.mjs compare BEFORE AFTER --out DIR [--noise AGAIN] @@ -17,6 +95,13 @@ lookups are answered from prod by the oracle), renders the base twice and the change once, and writes `before/`, `noise/`, `after/` and `diff/` (with `compare.md` and a before/after sheet per flagged piece). +`--worker-against` reads and verifies the committed worker directly from Git, +then selects it in the baseline passes through manifest interception. The +committed worker artifact must still exist in the checkout with the correct +hash, and each page must report that the selected worker is active. It keeps the +current host on both sides and writes that narrower scope into `scope.json`. +This avoids creating a second checkout; it does not compare BIOS or server code. + ## verdicts | verdict | meaning | fails? | @@ -28,13 +113,13 @@ change once, and writes `before/`, `noise/`, `after/` and `diff/` (with | `gone` | a steady piece became a different picture | **yes** | | `error` | a console error the base didn't throw | **yes** | -## why it isn't pixel-exact (yet) +## Why the browser corpus is not exact KidLisp seeds `?` from `Date.now()` and runs in a worker the harness can't clock, so the same code draws different frames run to run. The oracle judges by 64-bin colour histograms against each piece's measured self-noise. A -deterministic mode (seeded `?`, frame-stepped time, behind a URL flag) would -make this exact, and is the first roadmap item it wants. +deterministic browser mode would extend the controlled CPU profile above into +the full host. It still needs controlled resources, GPU output, and frame delivery. ## gotchas @@ -45,5 +130,8 @@ make this exact, and is the first roadmap item it wants. under load. Each piece gets its own browser context (shared service worker breaks parallel boots) and background throttling is off. - Local network noise (no db, no session server) is filtered out of errors. +- Servers started by the oracle always use development mode and select HTTPS + when the checkout has local certificates. Loopback test certificates are + allowed; production maintenance jobs stay disabled. - Proven on 2026-09-23: identical trees pass (38 same, 2 chaotic flagged); a tree with `wipe` stubbed fails (4 blank, 1 gone). diff --git a/kidlisp/conformance/oracle.mjs b/kidlisp/conformance/oracle.mjs index 6d6ee2fed1..ac5169ceac 100644 --- a/kidlisp/conformance/oracle.mjs +++ b/kidlisp/conformance/oracle.mjs @@ -6,6 +6,7 @@ // node kidlisp/conformance/oracle.mjs render --out DIR [--repo PATH | --base URL] [--only bop,pie] [--video] // node kidlisp/conformance/oracle.mjs compare BEFORE AFTER --out DIR // node kidlisp/conformance/oracle.mjs check [--against origin/main] [--out DIR] [--video] +// node kidlisp/conformance/oracle.mjs check --worker-against HEAD [--only bop,pie] // // `check` is the PR gate: it renders `--against` twice in a scratch worktree // (the second pass measures each piece's own noise) and the current checkout @@ -20,6 +21,7 @@ import puppeteer from "puppeteer"; import sharp from "sharp"; import { spawn, execFileSync } from "node:child_process"; +import { get as httpsGet } from "node:https"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -94,6 +96,9 @@ async function render(base, out) { ); await browser.close(); writeFileSync(join(out, "render.json"), JSON.stringify({ base, at: AT, pieces: report }, null, 2)); + if (flags["worker-against"] && Object.values(report).some(p => !p.worker?.verified)) { + throw new Error(`Required worker was not active for every piece; inspect ${join(out, "render.json")}`); + } console.log(`🎞️ ${pieces.length} pieces β†’ ${out}`); } @@ -110,13 +115,20 @@ async function shoot(browser, base, piece, out) { await page.setViewport({ width: SIZE, height: SIZE, deviceScaleFactor: 1 }); // By $code, exactly as it runs live. Sources resolve from prod (lith falls // back when there's no local db), which is fine: a $code's source never changes. - const url = `${base}/$${piece.code}?nogap=true&nolabel=true&density=1&noauth=true`; + const url = `${base}/$${piece.code}?nogap=true&nolabel=true&density=1&noauth=true${flags["worker-against"] ? "&workerbundle=1" : ""}`; await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); // The clock starts when boot hides its overlay, not at navigation; boot time // swings by seconds under load and would read as a changed piece. await page .waitForFunction(() => window.acBOOTED === true, { timeout: 30_000 }) .catch(() => errors.push(NOBOOT)); + let worker; + if (flags["worker-against"]) { + const state = await page.evaluate(() => window.acWORKER_BUNDLE); + const expected = workerOverride?.filename || JSON.parse(readFileSync(join(repo, "system/public/aesthetic.computer/lib/disk-worker-manifest.json"), "utf8")).filename; + worker = { expected, state, verified: Boolean(state?.ready && state.active && state.filename === expected) }; + if (!worker.verified) errors.push(`Required worker ${expected} was not active: ${JSON.stringify(state)}`); + } const t0 = Date.now(); const video = flags.video ? await page.screencast({ path: join(out, `${piece.code}.webm`) }) : null; const frames = []; @@ -128,7 +140,7 @@ async function shoot(browser, base, piece, out) { } await video?.stop(); await context.close(); - return { url, frames, errors }; + return { url, frames, errors, ...(worker && { worker }) }; } const NOBOOT = "oracle: never booted"; @@ -137,9 +149,15 @@ const NOBOOT = "oracle: never booted"; // has no db, and its 503 β†’ prod fallback costs seconds per embed; this also // hands both sides of a check the exact same sources. const sources = new Map(); +let workerOverride = null; +let workerOverrideRequests = 0; async function answer(req) { const url = new URL(req.url()); + if (workerOverride && url.pathname === "/aesthetic.computer/lib/disk-worker-manifest.json") { + workerOverrideRequests++; + return req.respond({ status: 200, contentType: "application/json", body: JSON.stringify(workerOverride) }); + } const code = url.pathname === "/api/store-kidlisp" && url.searchParams.get("code"); if (!code) return req.continue(); if (!sources.has(code)) sources.set(code, lookup(code)); @@ -283,6 +301,7 @@ function markdown(rows) { // βœ… check β€” the PR gate async function check() { + if (flags["worker-against"]) return checkWorker(); const against = flags.against || "origin/main"; const out = resolve(flags.out || mkdtempSync(join(tmpdir(), "kidlisp-oracle-"))); const tree = mkdtempSync(join(tmpdir(), "kidlisp-base-")); @@ -311,17 +330,59 @@ async function check() { process.exit(failed ? 1 : 0); } +// Compare the exact committed worker using one development host. No checkout: +// useful under a disk reserve, and explicitly narrower than a whole-tree check. +async function checkWorker() { + const ref = String(flags["worker-against"]); + if (!/^[a-zA-Z0-9][a-zA-Z0-9/._~^@{}-]*$/.test(ref)) throw new Error("Invalid worker revision"); + const out = resolve(flags.out || mkdtempSync(join(tmpdir(), "kidlisp-worker-oracle-"))); + const manifest = JSON.parse(execFileSync("git", ["-C", repo, "show", `${ref}:system/public/aesthetic.computer/lib/disk-worker-manifest.json`], { encoding: "utf8" })); + if (!/^disk\.worker\.[a-f0-9]{12}\.mjs$/.test(manifest.filename)) throw new Error("Invalid committed worker manifest"); + const worker = execFileSync("git", ["-C", repo, "show", `${ref}:system/public/aesthetic.computer/lib/${manifest.filename}`], { maxBuffer: 32 * 1024 * 1024 }); + const { createHash } = await import("node:crypto"); + if (createHash("sha256").update(worker).digest("hex") !== manifest.sha256) throw new Error("Committed worker hash differs from its manifest"); + const servedWorker = readFileSync(join(repo, "system/public/aesthetic.computer/lib", manifest.filename)); + if (createHash("sha256").update(servedWorker).digest("hex") !== manifest.sha256) throw new Error("Committed worker artifact is absent or modified in this checkout"); + const server = await serve(repo, 8870); + try { + workerOverride = manifest; + await render(server.base, join(out, "before")); + const beforeRequests = workerOverrideRequests; + await render(server.base, join(out, "noise")); + if (!beforeRequests || workerOverrideRequests === beforeRequests) throw new Error("Baseline worker was not intercepted in both passes"); + workerOverride = null; + await render(server.base, join(out, "after")); + writeFileSync(join(out, "scope.json"), JSON.stringify({ mode: "worker-only", reference: ref, workerSha256: manifest.sha256, host: "current checkout in development mode", pieces: corpus().map(p => p.code) }, null, 2)); + const failed = await compare(join(out, "before"), join(out, "after"), join(out, "diff"), join(out, "noise")); + console.log(`πŸ“Ž ${out} (worker comparison; current host on both sides)`); + process.exitCode = failed ? 1 : 0; + } finally { workerOverride = null; server.stop(); } +} + // 🧰 plumbing async function serve(root, port) { const child = spawn("node", ["server.mjs"], { cwd: join(root, "lith"), - env: { ...process.env, PORT: String(port) }, + // Conformance must never start production maintenance jobs, even if the + // invoking shell has NODE_ENV=production. Local certs are handled below. + env: { ...process.env, NODE_ENV: "development", PORT: String(port) }, stdio: "ignore", }); - const base = `http://localhost:${port}`; + const tls = existsSync(join(root, "ssl-dev/localhost.pem")) && existsSync(join(root, "ssl-dev/localhost-key.pem")); + const base = `${tls ? "https" : "http"}://localhost:${port}`; + const ready = () => tls ? new Promise(resolve => { + // This exception is confined to the child we own on loopback. It does not + // change global TLS validation or requests to any external service. + const request = httpsGet(base, { rejectUnauthorized: false }, response => { + response.resume(); + resolve(response.statusCode >= 200 && response.statusCode < 300); + }); + request.on("error", () => resolve(false)); + request.setTimeout(1000, () => { request.destroy(); resolve(false); }); + }) : fetch(base).then(r => r.ok, () => false); for (let i = 0; i < 60; i++) { - if (await fetch(base).then((r) => r.ok, () => false)) return { base, stop: () => child.kill() }; + if (await ready()) return { base, stop: () => child.kill() }; await wait(500); } child.kill(); @@ -342,6 +403,7 @@ async function launch() { "--disable-background-timer-throttling", "--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows", + "--allow-insecure-localhost", // the repo's local development certificate ], }); } diff --git a/kidlisp/conformance/pixel-golden/accumulation-000.png b/kidlisp/conformance/pixel-golden/accumulation-000.png new file mode 100644 index 0000000000000000000000000000000000000000..08027eb9051af2d7e1f395c6829d3935f738b489 GIT binary patch literal 389 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0V0)=5-ca7Q4c;g5oZ<15Ls z1~b72`#f}{STdLy-+3Q7)L%w)4t9XW?IJ)!Dex{J+t9ne!H*YgEj`K1h)wijxo;J zS5)WhTdA}8e$%B1({8c~C@&~05y)r|xNu1zqv4Z+gyXCl_L@C=*FD>wf8;#p4)*V# z|9n#ZUf0d@U3deVoZ!7Y7F(|QW);$m{RiBeeslfcuTk02-;n-5`Cu}~gSHfwD{pug zu(~<$7A(qa_!J=Nc&a&-JwrZ`lbcbOw~58{G4G5PulzAO*+`Z5P??w4RRJ>a;2(aFKvc>&|8<^-0^ za~qgj8m6^5Fi&AW$Z=^-B0C3)1p-V_QcS!7axBUo#ci?*EL%)kQkeHK9N4iw?Y+l> R%?u0-44$rjF6*2UngA-^o_+uT literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/accumulation-002.png b/kidlisp/conformance/pixel-golden/accumulation-002.png new file mode 100644 index 0000000000000000000000000000000000000000..70b3a919b54b8fb43be0b1d3c1ed6735db8448ac GIT binary patch literal 459 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0Ko zP^$Exw7%Zxd5rSU{SEgS_SxR~>v7sN<;fnlEe-$AeSG;%yZ$em;LaEa?i!URyQ~v9 zK@`)p1MW`ilpo0a@HlarGo!V?#l<0KEn~62y{-p~3;Xh!Y$y1q2&&kJlr^kV*GW`d zuuJg*!%s(pCX=)qjIRP1&Nm4$8dfn^$js&F5R^I~z3~h8gSiF2SU-fi{9@hG@nH6X zJ$o90m7c{s7CxFI{vgJ|Q9$6CvjcNW!>cw2<|ph2IWEmeWanVK%G=0xg1<>{iQ!>B z0j5>bOuQ51S(H7J+hi43Ld;pD6zn^Ud$s_kKPrz9(bT_qVfe&fOhq*smEXzr8zj=8e^J!x(NbEjag}L|={Z+a;Ya zRt8=MkB0NRS=Ywfi>-Fz&dPAyqOhEGZKh+4^XZuO4Zqgq?P31n7Wu4wMpbOU=JiTf z)Iu3v^sc%-qhb5m+SUJ7xBU@6pgH-T{JTDrZEQUDeg{;Kz5n{N z$87QLEQb&8Bn^)I+3!||&WWdW~oLwSQ?OLMfq?gbO#ogi$JopKEKTVnYe_AABkKM)ddo?3Z7_!mBoYqVuPI6(E<9*i!$$jh2XQ(kSFfe$!`njxg HN@xNA6hFsn literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/accumulation-004.png b/kidlisp/conformance/pixel-golden/accumulation-004.png new file mode 100644 index 0000000000000000000000000000000000000000..78db3eb58869322b696e146fbf4df1f88e31c94b GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0qM=t2_?#z5qzBi#O85x!_NT_`% z`L4!THSc>EBSSI61P1TjO>UFF-_r_kEY|AaGSbR$VJ+n0U8$G&e)F!Pdj1b~D|;+h zf7-7+WyyF?jg#S@+e;bYfIBhQv##I#QMf6jvO1zR?t1;^#6?zI4Y|+s^46PONi(ai zx*L$1cR8nq&A^Xo!MQtL^53U>o1T5;Q&;^n>~2n(n?fJog5IB{0=v$MMF(wJ`|j5Y zNAb_~vu~ENu|zYzNUB?TzP;PeSmA~iqnbRc&-sH2s&HlIRqOY;Sjc0re4L|iB zI@|osb>ZS+P;5+A=bW(rWypj-z3J>4yc=6s8)kgI%EBqYpv3vWRYxt<@eZpbgP5ew zgJUcOvbhWzRsX)|3m?!=ddA;yzvZkv!~ZUWdWKvF8E03v<#XpUTxqm8y5_`*AMy#&2!vFer_u6~`6g2~c5%2e0w5_Bn1f8ivk`D6JEr~ZH| zQ_};A8s{A3e!_JxQDtI=M_SS>``XKu_a8Nz9Ht z@6vh3JJzD|yA}RT^_pbea9d>(UxT&UAK%^uNAIl*TYc@@a`~B;L?_qlz21N8<=53= zeq{|O@BBzuzDRBBxXxi zlldBQ71-D>9I26QdGz_|)#h38e>DHbgB{jm-PQOsoB7(a zx~&Eh4Q9`l|FXJe#8k0+r}?ay@aQO>L;;4_Gv}{{>l(Vu$$T)oEN%9Z!0^wf)3&Wu zby8$th~!APdi8#2=L0%SjgLPXpOD_TZmY`y-WLnrxBOml)ai`;lX$_}t4WN{!Wm2$H|FWe znC=s6;A1JzeK_Oeeuf?O4eBur4h#!+_&Tr)>=*1%n521zfr;U0E|XQmk6s6si9U&J z0t^~?!WS5iZs1d>Qx8>t~MJEOkLE5+<8(xazg$5{s*kB1f0FOKZ##4#gz- zD_**~I#c#I)G&W!{U%z|J~1}(=9_bCcKZIiIWxE>;hV|BIX};zNzttc5!<|E$Hi@8 z`N!f|MFbqA8AW&_?q2g@ko~gus53(?O9Q9I>{!R8r}eWx1~}G+2xzV7;nG^zRKWAq z=Wff+^RJ%#vTgXiBT0{t@3UVpWpZ&e2s9jESCck)c6vr8BN{cf7#53H9KTs^g;=tih zFuQ?Mp-wr#@rl|EW)4P^9d}r&859`~<}#@@{OEOHoj5yzUx7tohwlLv wf&GFj6pDo3Ft#wTZ0DT8@W{MrbyFR~JJyRVwwzoI3=9kmp00i_>zopr0A;8p&j0`b literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/accumulation-007.png b/kidlisp/conformance/pixel-golden/accumulation-007.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d5c2062c65488d86eee934df10f6b1e107b6ff GIT binary patch literal 767 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0TyiX8yN(*&L7e z=a`tZ?A$;9?)43^ceSa5ktSRdBtiJMl>gG#VGq>73dm~qK z>Br8k^FDulTCBbNc6->Il?w{bnzt&%-?xy8dw8>aX5iLy|85F=O8IjmM*5J6;;SW7 z40(-t6J$O=6}XbUY38bfi?d{p)>yJ!{iEePBa&hAjQNEdtGsusy?XZWW$D)yBANdG z!zXnvK7871Q{pNu$K?N;MXzzqI#$zY#Tb43`U>ruptaX+b{{fR%nH3}R(hMWJC$jf zX*{o^S)gfg&|3AG=Ovk#GT4L;>{uQDtY&$nSSBPe1vm<7WpGwAm_3x3{o%mz;0q?-n^s*D z>hE8`vEW?+!{mE?&n^i0hubr)uoiH*>3?L$mLrF++D}eDH0Ra<^HkW8!QHT!+HfzFzS$oU!14gGPY>gJQ#!+bli|AEg_)78x9rbKp>TYQ`w+ z@W*XIlZMX*b^#`joxTTH1ojKAP~Z}N!`Q+QlqYP!cqE@itK$$$HA4HrP{;oatTR^F Vbat(tz`(%3;OXk;vd$@?2>_@kTnGRF literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/accumulation-008.png b/kidlisp/conformance/pixel-golden/accumulation-008.png new file mode 100644 index 0000000000000000000000000000000000000000..16a8af3de30efb2d8b55d8014c68b91e4cedba2b GIT binary patch literal 840 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0HpZJuGOE1m-oj%9M=;yvObB^*)`YERP{7&(^ zxt`Bt0>7!B{IfPKuTX7G=m8m?j6)5TY^$x!IR*T8?>ySb7%S1hGNm#+AoII_USWV^ zY>0r?iXJYlg-r!Kr91KtT}dwu{PLUO)nqTu=D z(Y|$|?Gq*!{`&ArV427gU*~0PJxcp>nyP%of<-M}u36ckKYKwJ2-{~08yPF~{$Tuj zl>64!6?u8`^TPYR?&Q3TSDks|zV&Jq;rp-q*|w$}3JDeTe61nOd`!^Z$=6|TY2U6x zm(SgKyO=S{sBTR-`wgK*VN-L1`@Ii^@Oo(N%WBeDq?LASrgrE0Sqk0rw{jXMIt1uz z6@6VYQ+KDjRFUq=8(F8y7Im%2m5rX6#oDX2uP0MAy*1mTZSArgb*OZqSa<}HzJU;jS`-umX zcogFA)~YnbJ*b`vkV(IBd37D zf#qV1oC05*7BsmS9+Xo^Xzj~4>cHBW)MV>$fMI?Vi;BYq?nW-5qb$`84UBeM uIXoH`h%Abwsry1)6p%{2xF1_n=8KbLh*2~7ZqCu~&! literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/accumulation-009.png b/kidlisp/conformance/pixel-golden/accumulation-009.png new file mode 100644 index 0000000000000000000000000000000000000000..28b3d15448011bd3ee21e20305312ccb75f152b2 GIT binary patch literal 905 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0`Zma18(3-`8- zzKSllwr*Lsh=0L?l!XdTtMnC|%y)I{YkY5D6#V|nvpdDdPp0IZ`(xS7e@jN=%lEsV zXV2bJ+^4bI)2M#wkJCh zUJ-5CZ}jrbqhrN<0Yd(KT;3+k>GkKl7Vs_!4JqB%6hw88D_cZ1@-Y{Enb@q-1&qLv=2d-Z+NIVdj zE-SE4i?MCJSHr{ALJ2<#RSrClPhxu1^8VxVUy^T4BhACV`>$pfGF$z3wMx(2x0AgN z{kXDA_=?bz=ckeayd?r(bL?*M(M_23LA8K6YWMO3!gA%;ZrgA)%>G!pR*u2^v3s)O zfh~#~R{6|M*u@zoC}3Kn;m9l4CRu(h_)xphl@AM9O#i5;w8S0{Rz9#=yd%oCFm3C- ziEUBd0$ZmkESqI?cZEs>YoxG2<`1nKp4(jcZ}M<%lLU(^s4-slNMJlWhx5RyBVwjY zW~oG&9_%`F;NBNcBgKSiqF1^i{g-{0{J<;U!unz?057(v86Nyo_H_*?dFX$EIywDUBc29i?p0)TBRu_@cr=%owII79&T>P z;$l(xE532fS+|yl(LZMwh^0F(`mpv-xJy{NbJGV?#z2=RbKmte@ryQeYTR0rZTZ8Y z;jH|*DUl76KQ$kaR)}cumSxIsSis+;?I9vu$HpP{AgKL-w8D$#2V6o&Tjn!1vF^B{ z_<@b%m%@%F7sH3?4GD~Tr5t-0o9sB&DFpibkc1fVl@3;nyvr< literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/alpha-000.png b/kidlisp/conformance/pixel-golden/alpha-000.png new file mode 100644 index 0000000000000000000000000000000000000000..b94d172025d0e12cc1b099cb943f6ea3d158a8f1 GIT binary patch literal 474 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0)JhSpQNmQ|K+pz740Cqm$CJS0P_LH2KSa6gTHy^ z3=HfH^8_|s{IdHV1A`1hN3-sRId$Kq73K=AInB?kAS)0xMV`w+N@UKgQdXZ&(>?Z?RgtS68`*>eSOKFTY-_Eft%s(@_-u+|6Dz;Xm@}DvPHmX cu5LYxYW?%~y~}JE7#J8lUHx3vIVCg!0BB#kJOBUy literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/alpha-001.png b/kidlisp/conformance/pixel-golden/alpha-001.png new file mode 100644 index 0000000000000000000000000000000000000000..b94d172025d0e12cc1b099cb943f6ea3d158a8f1 GIT binary patch literal 474 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0)JhSpQNmQ|K+pz740Cqm$CJS0P_LH2KSa6gTHy^ z3=HfH^8_|s{IdHV1A`1hN3-sRId$Kq73K=AInB?kAS)0xMV`w+N@UKgQdXZ&(>?Z?RgtS68`*>eSOKFTY-_Eft%s(@_-u+|6Dz;Xm@}DvPHmX cu5LYxYW?%~y~}JE7#J8lUHx3vIVCg!0BB#kJOBUy literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/alpha-002.png b/kidlisp/conformance/pixel-golden/alpha-002.png new file mode 100644 index 0000000000000000000000000000000000000000..b94d172025d0e12cc1b099cb943f6ea3d158a8f1 GIT binary patch literal 474 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0)JhSpQNmQ|K+pz740Cqm$CJS0P_LH2KSa6gTHy^ z3=HfH^8_|s{IdHV1A`1hN3-sRId$Kq73K=AInB?kAS)0xMV`w+N@UKgQdXZ&(>?Z?RgtS68`*>eSOKFTY-_Eft%s(@_-u+|6Dz;Xm@}DvPHmX cu5LYxYW?%~y~}JE7#J8lUHx3vIVCg!0BB#kJOBUy literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-000.png b/kidlisp/conformance/pixel-golden/clock-input-000.png new file mode 100644 index 0000000000000000000000000000000000000000..34cb5708986b3e994a1442984d4f0883511fb760 GIT binary patch literal 344 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0Cjt}#pgZ>s;)_x$xa zeaoFZ|C0+e9;~Xol4$aY aar*m{SEg^fdYgfPfx*+&&t;ucLK6W0YJRl< literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-001.png b/kidlisp/conformance/pixel-golden/clock-input-001.png new file mode 100644 index 0000000000000000000000000000000000000000..576375ba83517c56f5f99c11dfdf4ceee7ade08f GIT binary patch literal 348 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0XPqQ63X9=`%#OO+`V9MzdEI4`gO<|GmP7^lw z5Bgu3)f!lOco(ugXwGQ5F*A>WQ$pH__W=7vmW{@D8H5bXTIOdR`1|pX#y95k`u@%e)GV$<8Yne<;+YHJzH;@VVd42`)5Cp=_D7 z1Jkj#0Ok*lCXR1>%n4X2)xf`qGbw!+qe?;!hbe=+i|~o0w1yRK;)TZY5ThGf=A$J4ww`Zg%X%xmg99?-w=boGI6O3z%^?BSH3uXM>@^sB?& zSGp_Myoh#@fY}23^>wRWvDNS3RN%kK_2`)Blz$CUdQc}!U{7Q<49;VSWZoyIulV!9 TUD0<83=9mOu6{1-oD!M<>=l8A literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-003.png b/kidlisp/conformance/pixel-golden/clock-input-003.png new file mode 100644 index 0000000000000000000000000000000000000000..78fecc634a061b4716301dac1960755bd3b5e9eb GIT binary patch literal 361 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0}j&CoPw;C*4jBJ3d7B6`GUCu4@ga>W@8pA^<8q@Ch* zV7B5t$TERFk<~Cbk3s4}CWl&s4TpBe%p&Fs4SvoBj87aR9M4RVZeS~sYT|sr`>^Uw z|CF}!-CVm&9GFkB)4Axvz!y#q*o5{>*CRRESp&6R4b!$56kE@g@I?hsb7B dN^tIFVB+^rOZ@q zx7C8l8#wD-_!qD(V4T~Qz_g=%olTg4lz{!gRHjb?r@2lw>zF^7x9d~QZ{PQf84k<+ zw{a@)U*}TdzrmZ3XL%^Y;g85P&J)cV+8X?enTnVz=I!BJ3^wRQ^MUM3-~Zp3WNz{pW=03w&Fd=GJ!pjbz=L(3+$5o0w1yRK;)TZY5ThGf=A$J4ww`Zg%X%xmg99?-w=boGI6O3z%^?BSH3uXM>@^sB?& zSGp_Myoh#@fY}23_4}G%vDGi;RNz0tl@!6c)P6ymH>v>+*BbO+M*ex-!}9w&0|Ntt Mr>mdKI;Vst04BbKga7~l literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-006.png b/kidlisp/conformance/pixel-golden/clock-input-006.png new file mode 100644 index 0000000000000000000000000000000000000000..1b3dd7cae8c0fce4ccbb90c071e864ffffbfc602 GIT binary patch literal 359 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0o0w1yRK;)TZY5ThGf=A$J4ww`Zg%X%xmg99?-w=boGI6O3z%^?BSH3uXM>@^sB?& zSGp_Myoh#@fY}23_45|(Iw+sdbi#Ri(~dyarS=QjyipBsxYnTWxJ{FFn!$x@3=9km Mp00i_>zopr08GGy4*&oF literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-007.png b/kidlisp/conformance/pixel-golden/clock-input-007.png new file mode 100644 index 0000000000000000000000000000000000000000..6027c98bc31f4d2e81e91e61dd356514efecddf8 GIT binary patch literal 362 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0EH|BBwiVoRPyiG(@`m?oqy=|@{TXTu6G0M zzlw!0FJ;VdSgtsO;giA|g|t)n+{Yn(;r!;SKTX-x8fw-?I6gVJi}BA%wk7rp+Psn6 f{v=W53scI@Pyx$}j~+5GFfe$!`njxgN@xNAng)PY literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/clock-input-008.png b/kidlisp/conformance/pixel-golden/clock-input-008.png new file mode 100644 index 0000000000000000000000000000000000000000..3297e4476819e51d8fb03931f8b1a727ec7ac359 GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0iuXepRCd_Otc0Ai30hjxeQQ?><+bK4S_ zPBd?5iU*+4Qv39lsoSDzHsQgn@y9!PC{xWt~$(69D8mlFI-9 literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/manifest.json b/kidlisp/conformance/pixel-golden/manifest.json new file mode 100644 index 0000000000..1e7ae9e588 --- /dev/null +++ b/kidlisp/conformance/pixel-golden/manifest.json @@ -0,0 +1,235 @@ +{ + "version": 1, + "profile": "pixels-v1", + "engine": "v24.18.1", + "platform": "darwin/arm64", + "fixtures": { + "accumulation.json": { + "fixtureHash": "c4d7787434d946fc5fcdb5e0ad43b3c05aac6895f6ecfac39a0b25c4eb828fd9", + "width": 96, + "height": 64, + "frames": [ + { + "frame": 0, + "timeMs": 0, + "sha256": "f024769b913f33c03a512675122c306b2fb49b03d6473da66ddbed387e64e7f4", + "file": "accumulation-000.png" + }, + { + "frame": 1, + "timeMs": 16.666666666666668, + "sha256": "23dae32b0419cb3529a321372f871fb3206467730ae261f9e7d1c237deea19e6", + "file": "accumulation-001.png" + }, + { + "frame": 2, + "timeMs": 33.333333333333336, + "sha256": "82ad53bffd22496dcfa5adbf55b0fa87a3c4ed681d4e2af0bb2f02f9e0fa9db9", + "file": "accumulation-002.png" + }, + { + "frame": 3, + "timeMs": 50, + "sha256": "e211310453d101b69fe934689bd909f6c291ddce93b40549a01a3f55aa5baacb", + "file": "accumulation-003.png" + }, + { + "frame": 4, + "timeMs": 66.66666666666667, + "sha256": "b281492a3fcfa3eb61b3bf0a184f9ffc5edf9a64aed7ed6404c5fb0abba369b5", + "file": "accumulation-004.png" + }, + { + "frame": 5, + "timeMs": 83.33333333333334, + "sha256": "8e0d5f66258f314f8de99bf2e336f006e2d0866ac957befbb680e8ea8b3ec714", + "file": "accumulation-005.png" + }, + { + "frame": 6, + "timeMs": 100, + "sha256": "73f005a5744a9dd8e56f05668bf1ecdf8d1f6c3f559081f57319905b7e56c404", + "file": "accumulation-006.png" + }, + { + "frame": 7, + "timeMs": 116.66666666666667, + "sha256": "c730e0e7691fba1cf6f81bb7eb2e5d470ad83ad7cca9b991fc9a5f9d95b81ab7", + "file": "accumulation-007.png" + }, + { + "frame": 8, + "timeMs": 133.33333333333334, + "sha256": "169178727d9c02f1df162f56220c0f3cd8ebc51d3da78b8a184dd2b8a0ebb2d2", + "file": "accumulation-008.png" + }, + { + "frame": 9, + "timeMs": 150, + "sha256": "a79044a7d9c920e5cde8e3c6d3ac308d09d75fde82188821e656f8e9d0bef9fe", + "file": "accumulation-009.png" + } + ] + }, + "alpha.json": { + "fixtureHash": "9520c6f48a3e9175e5dfe2a990733f73798e6a12b263ed22e03d2fc0d8824b47", + "width": 96, + "height": 64, + "frames": [ + { + "frame": 0, + "timeMs": 0, + "sha256": "5f77220e465b44828d6231e5255fe36b3c3b8792f54e1e49fb1a1773265a5311", + "file": "alpha-000.png" + }, + { + "frame": 1, + "timeMs": 16.666666666666668, + "sha256": "5f77220e465b44828d6231e5255fe36b3c3b8792f54e1e49fb1a1773265a5311", + "file": "alpha-001.png" + }, + { + "frame": 2, + "timeMs": 33.333333333333336, + "sha256": "5f77220e465b44828d6231e5255fe36b3c3b8792f54e1e49fb1a1773265a5311", + "file": "alpha-002.png" + } + ] + }, + "clock-input.json": { + "fixtureHash": "4b3dafcc2b58bb3ea51fdae5f4c667677641a7487663e1153034e2df8acfac46", + "width": 96, + "height": 64, + "frames": [ + { + "frame": 0, + "timeMs": 1000, + "sha256": "79a8590cb9246b3efbb79fee1247dccffe63b950933813312ebfb7318e516b86", + "file": "clock-input-000.png" + }, + { + "frame": 1, + "timeMs": 1250, + "sha256": "fb505d62a978013281c361c9add2fc959c6ce2fb2ac0855591f151d10d20c83c", + "file": "clock-input-001.png" + }, + { + "frame": 2, + "timeMs": 1500, + "sha256": "605daa2cb0d1469b28a254c510997040f6cb637fe7146a0b6f285ee27707c260", + "file": "clock-input-002.png" + }, + { + "frame": 3, + "timeMs": 1750, + "sha256": "1d5a2290d613477363a534b854f448f0644014781cea374f0011e93f0a82b3ca", + "file": "clock-input-003.png" + }, + { + "frame": 4, + "timeMs": 2000, + "sha256": "0b3679675d536c423806033df5a068fb27ddbbe0206f1182846863ba316f1a56", + "file": "clock-input-004.png" + }, + { + "frame": 5, + "timeMs": 2250, + "sha256": "3f08a4bf73dd11c91eb845136465bdd13f4cef4523ec3f918173bf8dd2c7d723", + "file": "clock-input-005.png" + }, + { + "frame": 6, + "timeMs": 2500, + "sha256": "a604bef8b1068152dad30b650b595448e028952a8909a9b984992010d5382ef1", + "file": "clock-input-006.png" + }, + { + "frame": 7, + "timeMs": 2750, + "sha256": "6736d0ca2fc423cdb49224e16a7374baf964061e327d44b34008ddf77ca1c83c", + "file": "clock-input-007.png" + }, + { + "frame": 8, + "timeMs": 3000, + "sha256": "46cc22ae8af9f19416d1cc99cab24ec0bd02654eb3951c880d56acb6bffe65d8", + "file": "clock-input-008.png" + } + ] + }, + "primitives.json": { + "fixtureHash": "f345032c0efb1909f2485eb2e1c676b6fa9b2600f150638612f2431f295666cc", + "width": 96, + "height": 64, + "frames": [ + { + "frame": 0, + "timeMs": 0, + "sha256": "c27981ee481876609f1380e0117dfb66739145f24eddf31da40e8e659b2359aa", + "file": "primitives-000.png" + }, + { + "frame": 1, + "timeMs": 16.666666666666668, + "sha256": "c27981ee481876609f1380e0117dfb66739145f24eddf31da40e8e659b2359aa", + "file": "primitives-001.png" + } + ] + }, + "random.json": { + "fixtureHash": "5780965fcdb6da909ef93e3e3828f4385b067cea498f8aa583ddaf8e744a9d9c", + "width": 96, + "height": 64, + "frames": [ + { + "frame": 0, + "timeMs": 0, + "sha256": "e7ecadf5303cf56b70153e20f76712b99389488a28dce0f12b513a2a95579d46", + "file": "random-000.png" + }, + { + "frame": 1, + "timeMs": 16.666666666666668, + "sha256": "7b0bd318df991bbc35e5b416b4f3cb2859a15e9feed0260b274a9a92325bdcfc", + "file": "random-001.png" + }, + { + "frame": 2, + "timeMs": 33.333333333333336, + "sha256": "8cc3fff82954a7f5641fb0117f1f18d95706af58b8f6f02579577cce2188a339", + "file": "random-002.png" + }, + { + "frame": 3, + "timeMs": 50, + "sha256": "6a655db62265b55072433b0a01253d3c0449f134af3b8ead1ef2bbf868c45dfd", + "file": "random-003.png" + }, + { + "frame": 4, + "timeMs": 66.66666666666667, + "sha256": "d75302479df853abe83c9f17d9242565335e939df0aab29bcc3b466975a5bb08", + "file": "random-004.png" + }, + { + "frame": 5, + "timeMs": 83.33333333333334, + "sha256": "c8c4bedecea0b38c3fbd057bb71b3ca0b78911ee58de20435b2f6637d2d33f77", + "file": "random-005.png" + }, + { + "frame": 6, + "timeMs": 100, + "sha256": "6a5a974433bb0fe10a340d32778eb23d6ad7b4dbac24222c1db294e3ecc343fe", + "file": "random-006.png" + }, + { + "frame": 7, + "timeMs": 116.66666666666667, + "sha256": "40adb27377219dde7af30de68440f96134ec4d3a8688140496ffc900077b2503", + "file": "random-007.png" + } + ] + } + } +} diff --git a/kidlisp/conformance/pixel-golden/primitives-000.png b/kidlisp/conformance/pixel-golden/primitives-000.png new file mode 100644 index 0000000000000000000000000000000000000000..5740abc77dce6dcc416cadc39404f8babacc222a GIT binary patch literal 613 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0rIF@v*wfCC8 zwuU8wZNq(KSMQ4^-M44(9ZOKEVb^uw65zUVLEr}Cr=#8%UgzJeQvbE|IrkDaRt`2B zYljUFG|})wra!-!&;FI1_WU$Y-OK!TQ31LA%Xc2GKR++jeoccwW1X6UdmHPIg)A*R zA2gZzTq2s}-5fL;=4&~qZeg%*X$au34|VYVuw7h0>%noBj4OOE6Iollez-H)HRkI! zGR-UeAhO|B*#BzwB@OwS55r^ydLF!=8TECoYR%jRsShF_RWo;o&wuECaKZ}w|D6p)(LBcCveYW>alWITw<}AS&QXO;Ac-S z##Y${f!y<%j94R@mN)cBE=a89JHpq&abbfU`=W*uei4U%SsaQwP>}8@wf0=I#sh~2 zx6{Uo*^Jj(IbJpheVE7e=D_qf%zHMrXou8a=L`5SKdph?V+Ye8J*IoB?*%oyFECtf z`ezwa-+^eZ4|CV4-Scwb*|U}5{>P$sOIepRHC z7!UE?xFE2Dg;S0ty`d((h>e3aCxhb+!@*_;UB~}M8K3TKi%MC^z`(%Z>FVdQ&MBb@ E0JS>$@c;k- literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/primitives-001.png b/kidlisp/conformance/pixel-golden/primitives-001.png new file mode 100644 index 0000000000000000000000000000000000000000..5740abc77dce6dcc416cadc39404f8babacc222a GIT binary patch literal 613 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0rIF@v*wfCC8 zwuU8wZNq(KSMQ4^-M44(9ZOKEVb^uw65zUVLEr}Cr=#8%UgzJeQvbE|IrkDaRt`2B zYljUFG|})wra!-!&;FI1_WU$Y-OK!TQ31LA%Xc2GKR++jeoccwW1X6UdmHPIg)A*R zA2gZzTq2s}-5fL;=4&~qZeg%*X$au34|VYVuw7h0>%noBj4OOE6Iollez-H)HRkI! zGR-UeAhO|B*#BzwB@OwS55r^ydLF!=8TECoYR%jRsShF_RWo;o&wuECaKZ}w|D6p)(LBcCveYW>alWITw<}AS&QXO;Ac-S z##Y${f!y<%j94R@mN)cBE=a89JHpq&abbfU`=W*uei4U%SsaQwP>}8@wf0=I#sh~2 zx6{Uo*^Jj(IbJpheVE7e=D_qf%zHMrXou8a=L`5SKdph?V+Ye8J*IoB?*%oyFECtf z`ezwa-+^eZ4|CV4-Scwb*|U}5{>P$sOIepRHC z7!UE?xFE2Dg;S0ty`d((h>e3aCxhb+!@*_;UB~}M8K3TKi%MC^z`(%Z>FVdQ&MBb@ E0JS>$@c;k- literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/random-000.png b/kidlisp/conformance/pixel-golden/random-000.png new file mode 100644 index 0000000000000000000000000000000000000000..1b5b326fe651653f54953af0bc594dfdf6fea28a GIT binary patch literal 849 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0#lX(*P?l9x5irBCwee2@bw`aVpk0yMskoZ$S>t?K{Y(srm!082x?QH(nlxj9N z@-;X*Y-e^7xF&jm(SYYgSwpYJ6;1`)MQy*2FRHKK&X8@mul~ob%!el!me1bp{d0c5 z{l1v4=8adU*8lytv?}2dLrjP2ggJty7Rk#q8RVM|J!mK}Z2RqA^h};tF`GA{=|RIB zW`T`I%QzoCd_TX8p{7H^jpNCi64vtN!cG}N4Tm1o)qio=p`69^!rorK@GSF^64p5z zof`x0&p)($*8v8hT}crV)>lpxt*?4tGx)}&#PmR{^lH3o!?Z)?*;20W__FvF7BSqJSp4Z) zm*yXd3Ec*Nmu%Z?+SI|g1xzW*88t@_~khz1y;Z^OQdy>}K=S2Qpn_~P( zLx6M2BL++Uz^bHo#rzc;XP4!L?~o92^6W`sSb5t=r$zpd2V=qO$LsGu(bEdM%Jjs} z>W$>XU;#Te@kAyM&RXt{y!Q{48xFe8I8=Uk%8pmvj2Z3xOi8O$TMjc^5wT;ESoq;O zL(boC?LRytm>K*RpNQ8EVtDpGpY`Ha0XvzNpWiiW_G>RW?k~!)H{gfep_;G1AA1_r zls3d!{E<8$YrE3_LmB@9LA!ZfObZP7xC5MjT<>w7oZCwq>OO1`Tq19m!)ws6aa(aiDFaVJ&QFybSq5f_wahu< zES-X19(|48lq1b`*HnQem??Y9?uEY(9%9>i)LABsy{78B+u!-Ftr1BKTa^|tt%+$- zV+fGYW>37Tbcc({Xk!VM{h~-hKS#50 zS?`6(j5>$J6x-So#Tj=9Tv^z7VM8E;iXzXMLvG9uIt&cxv)9=+(U*CLzzl{PZy$9@ zJbeB9_$~%JrsX>jm!rsEOOUzP1>%sg5j9M4;g_nMgzSTf8Kzu z19AyHE&8$xB^~&V>@cd~<9Ne(=kXN|`Iaz7Pwpru3I644J}gz5aF+2~`gVK!J)HYZ z8u%t$_`S6`;n4pt@7Pa;YH%)$Wc9hf@4Q^O%^W?BJW~%h0agP!zkDqgZbvUslex?> zESK}#mOtOUf4h2%!hy3t-=`+bY1zFvi^1nZ-H!*3`K*f42ii4QH*%;|Gz&z(&0t?B z(o`*~a(Bx4qXq|~Tef=p_FCu{tZRC+Trt4uLPg!~8m9La}3FH|oT7)@%HBa&RvG9~Z?h7m7gHAb&3#A+~g3soM3Ntco z4+~*UcONwD5OuDDtdXTWZE&dZ?YlfzOG<#l~UnhlPwx$IF&jwDQXx zxHDNaTkW|ji=RMO@Q(=v?{7Li`6sK#5!i4^{c2Xrdh?bI90yD*-twO0=(4JDsCu$g zq>;VhFH^I?KJ}|F$4wwgN?~1G2IX hxuCRross8&y@r39O6%3aa|{d&44$rjF6*2UngBmuSegI; literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/random-002.png b/kidlisp/conformance/pixel-golden/random-002.png new file mode 100644 index 0000000000000000000000000000000000000000..1c3642dedd6e318216ca72810e8662e3afc715d0 GIT binary patch literal 976 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0r;e<0w|k>l*S@u*ioo@3{{ zHM%<(Okx@zg?4Y6bn(QG>^G94mh-(=uk*Xum*KiKE@$RiaMdwo;RFo4;d#C1*bG!MD5#-W=-; z|3t9}9k}{g-oMn749>AxSp_UIC{s2^Ov`KY!KTW-#p0RXkJ8^rE$( zS+M9~zLY}Cf`lE79I6fN_I^5l6d4S8v^k1hfBcIqV{~XOIK-jZU|Jzx_roUU;A*A- zg*gIFdVel`E@;(f`|$MUY_*QnENv`KJPSnX-k+YX$Z+L@VxwZ`1*w0o3=V8f+>Fdi ze*NwEaFcPzA@+mf3W-0aq+2n(`@yi{;cWH?GJh3JnAfz{Ph{ZdeK?!Z;{H7zPI)N? zuXL_cN!eltloL2QKP*gUJECyyf%kPjsfXr_2^`)GdKu0?3a$(9i)84ZkmY@gLEzvG z*#`N)LLBQ9|41;&{aq`tmErX3_5P+T3OTlSHXk(Y`zN4ytxn^;M%JNx&Uvy7&KXP$ zPAgviRqYUI;NNTdL$Be|A?=-J42%;t{aB{p#L#zPH-pA*Ip2<-zvh*=3OcQMdic52 z+rx|oEo=-W_ZF;Um=n)b#{A{YPxo_emKnApEH=0P@;vyb63oir+{4zu)w1E}5wUpA zjx2_A!t0;p%;1)fJnGc{_1m3qlOKCKh&F`h@BgpO&&bSZ<8-zus`|&P))h<>cD(;6 zkbdmu1aA5MZ$Z{sJF7BUHJChNx3!k;+xs)W?F-YBC?CFs&)>D*ns_GxeAq6a;u6Ui#pf;e@bd5bHzze2a57A_R_zJoG6=6w^N6!O zuAtWa(3v67+G%Aj_jLCOEFDuT85}k@-bz}bpxwmD;QdfPL;dpZRNcfi55M1$-N+cR zLe==zQooCJU;14PST_i)t5scRT<}}6HGwJN<&Nykltw z9`*@Un_1>=R^0x3qRWhL0yZohAsw^UE*EL)G-YT}uwYywP{z&RbhNjCp+WBuBf~D^ ghYSpNr+pKz`SNDtVWZD63=9kmp00i_>zopr06`^}{{R30 literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/random-003.png b/kidlisp/conformance/pixel-golden/random-003.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec788a7fd66e6813f785080a6dea73dd1e950e4 GIT binary patch literal 1075 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0ok*}mX3_nHZxn6`YeIlPaNl~-`$ z3V+SY*43AuUvSBO(sS(Fk8IoO=ZCo3gIxaKKXW7dtt`U>8?lnp4-5{quVb}fNMM*_ zs`L5tS(foj%ppVL*-#=fUzNPgisUeDE`SPXFKg>#EzWpy_ozV5+@8|xrf;^n_ zx%ayNxXMr@pmAiSAVb@GwsWobwEwJPSji!CWTjG*%7<>|6OIh*i$ogQ`mXM`xxa#U zKc|Bp(;9)59Q(ZPJO4P#;3eSa&%N8-!>Ro6@%iDr0bL9!{S(f8v2DFCb3iGev$jL^CbxGxS+mF z*-2eVJgI+idNG6fmkZBq1UqhQ6ggaOo#cP@zKnuNT$1{dlZ_t~cTDZ)sOH|!>2ON) zK=XqcT^vFTF&qNbZ96;~=2TA?VTss%K-M?r$Gdbsr|2Iy87|#EI*ViZR&IgD-M7E@ z%5yI?{G-w^tFid?hStPBJaI6`PVFY`VK7;4bgvZ2}Cqp zTX^}w!AM1xHFaGHho8?=&iJ9%B-XLWx1w&(*{Df{%sxN9-PdDUTgN3h>#vl;E`}*B z{Co}>Ulf{39>3*WD0iUZUYbLTKm+f6^I2OQROh7uoV_tJ52?gvxy%m0H~T z%{aC)s0rEqJHvQ^ZK2QsmpH|ebbD8i+DE>vJ1Q6^3)s18?97|sbmd)}9)lf+az*h@ zQ3sw!-_4byW;5Jae&f=E^iP({e`z}TWvCvUR(53JlV1zMo}TaD{{QfOr7cpjf!vQo z58VCoJoV?H=S`<0VL=KgY^^!GOO)j{qZm{3#!z{#wM&?JB(3dOCn%Q_csm=GQ$z43B62{PKOt!+gd7=|>8C)sDFudNHV|&#V0VSENB#Fw0>NlgCaq zx0Y)R8^Y|(r#ZQw(`77D*u$_=TU_Dk>P4rc{_4$pvgbatBI_b9hV4$(Z0qKHXk3uZ z=Fyt*D@m-?p-1^m^BF zV6(xWDQBzmIj%Bf#hhtmUvTZd1%nV{AMd@4Yi$KDBL5aMEWG;q3G)JD1`X$cn~cYg j#V?a!kX){~BcP@JmYpN7YQq9hk-*^T>gTe~DWM4fV*S?x literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/random-004.png b/kidlisp/conformance/pixel-golden/random-004.png new file mode 100644 index 0000000000000000000000000000000000000000..8a77815eeb0cc0f642a73f369d8d8d30a57f6dde GIT binary patch literal 948 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0V)pp*-<7>JFJv=!6<)>ZS&CShVbFL{*QpdRF#jOTmhB(6m z58tofd2hjI7Ks*4#=qegS{Nj+H^^OHAh-U=x~xpicxb))EJH^MwuLEX_&t7#=87c^S!Mfem^|EFKhzW8kxKf2?p06&WtOL z?0fFS!5GkL&(m;f&y6DA=57bce*z3#g_jn#ZD6?au$wJ_<+zU)2lD~JeGv?X8#QOgd-n(KP-K? znR!Xyj9ttOr9W;wxXD;_?zFFyRu@S5Ra zFk3ut!v03b4O4~xZGXL6eCxfqKmRpMYx*9XWXQSsA$GzX&WO3^_#ZA=<@!VBU^wHC zgU7AzALVGw;AaSy*fJ;c@ta>-4FT7mPjme7=jr9*wMVM||9Spz=Y|5a3LYu019eTY zmN$$Z&bJZR@5Quz;q^6hLylkf2@zVbMB~u+{sqeqth#lGv((b;PuS}=mIkFiSBe)* zZWfHzHl3`jSRuj?`oplHIAQ4$y(bIL`5*M)*vHZE=<34ygQg769UiPKGTe6We%mR9 z84t3TEwf~}Y8}noz$v4Y+H`(q-<@^KBQl>bY<+lpLGF)#EHYYh%}3;Ve@KYDZWB&l z$YX!y;k#Eqt3Ds#VmNm`Nx(ayrrs?W)wq;Qz&NsGQDgVgm)+BKg)8Zf?KDEjt~ zh2fv*6s~$dh7U|j4$WscV9zMbaEzfLQkcQY-0}PWNiB1)+cSmtC*HcS^bP|91B0il KpUXO@geCw@gs4ma literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-golden/random-005.png b/kidlisp/conformance/pixel-golden/random-005.png new file mode 100644 index 0000000000000000000000000000000000000000..e3c5ec6cc6fe03f7aa40554362815f1daef89927 GIT binary patch literal 816 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0^mLGaOzkWm&5J@rcDC%E+LFBHp}JZ&-1#X_NSma zxR%$M+d*B(g_~h#kLv;?bCQdfjer#*7%I)_3c6N;78YOuCa7ADwT` zlG`uSpmAsuPKw(nQ0)AD7CvMS9_H!5vJUn_Oqd&jiy^&QR zkjZ`f?+5&v4}SiR5B%M<{`U0w_7M;HH5vMa)^rF|eE+NbfM1)zrDf;GtvTiof*-DC zSaW#e1?P!VnC;l=iiJPC`}Au6qkV2GZr}CLV%%@Z*fC|2lEyQYWYsB6`c4c+EN^YM z-`;b+vg#i5es0DOBCY#O9m~4ztmB_M$>L$TYQm`suh_|(-JQA^qLf&DX8Nx*tduu) z-1)(kbB0m<a_HJ2rHLx&EFlF@x9pd#4HA_6EC)w5ci8ZAZFBMX^o1f8wx zBWF&rW$I(t&%RH4(XNI$Iq`C?5{!n`-rgTv<(vgI%9-|5cNhFv#ByNrcgy7lq7yU{ zZoc@nHaNoj!%^P@lLRddjl2$vt23~N@&3?aD1Eq^;Y#azrUgvvOn0v@`)+Q_D8cY= z1Jg6N2mG1|c^v-=n4TSbu$o~-!|M}w?a#2BVf^tt{rkN~d%j(}FSA>uK+S;nkI(|$ zH(Q1DAILCNnndvZP~=qC3^+gaK+&P^o)$h0B`YjA_Gu?>ZwNfFXj{X@gNwE?=!yt_ a5WmrV+)RKc!-j!@fx*+&&t;ucLK6Vj{bTkYF)CVEDs__psyyR&L`RTvZG{vHbxz zi$lJ#6twuwED=hR-uB!6_om(cHjR!o`)l$i3%D`x*(jV&IP$Rh3sVY%!GT6AmOO?_ zb5FjuU9Vuk@OLUl9z(6f15p=w4xwBBzkbNo;9|H@vuASrT8*8I8yQ!90%H zEA-6=c3uX~w)`K@XDx^>k7W3vq;TM>e37W(fmw_@7$1n1JP-}}Ao-#4``l}aZ@L3` zSr*tfNG7~xd+_$f$to!G><`>2t`fq%~$8Dj*?x>cD2wlbWJv5lK=qV(#JpwU>n49G zhDo7UT01xsq^mA5T-f5^!Xa5FzCgNaQ@cS`mZPm{!|UQ^otmpt&oTUv-*%$vj@5H>-xO}l|3QD~P%B=dn!G8guWnp)Om zv&`Q;eG%gV#{k9>XZ});-F{^Z)t*0gY2ST5;T#*ow8hK?KW$o>lrkGuEq~vbq@=HQG8ZbO+liSSOoPVH4<4c4w_u&Q|2_XS?MJ#WzjTcH!%MoXyNc~LiZA{xqNyK z-6AI!EoR7_GPP%OcK`S5o98{^lnSW0e{PL%r)Wbw=cdvPQAIyje^<|DVfffx_A}Q0 zcJ)k#lsQt24)?aIGJMeAnDy?TE5n3+CDAEN6PEwb6i~b#aIt^k)a0MZ983*Ap7JYl zFkG6F?<~OZf$@fn-|1bePK5>SpGb=lVH(1U&0TTKqHzQ~d zgGr)(()3TMOtTCa);-oc3Fj$^uXIvQje$h02h8=DJOJBFlVG91KD9EP%;On=KTE#ONN(4$?F7y1VTmNEi zZV7{QS5={!46lfF?)xq6`-H!o@3noJ!g!(ng207{n(p>Qt`+NVr{3Spv>=5m=(hFS zZ2^KB3+?W2W|i>Xeb3T2kfTL2e_aIgm3;nVkBf?1&+_7fYN_k~*<_kjMXQsUH zT;MnHwL8}uMyKg~+=l{cKqRN@eB%fttk(HOn-l z7*da3;bcfs4p2+rP_;O4hcGcPF7RbP0l+XkKu8|yb literal 0 HcmV?d00001 diff --git a/kidlisp/conformance/pixel-worker.mjs b/kidlisp/conformance/pixel-worker.mjs new file mode 100644 index 0000000000..c3576536c9 --- /dev/null +++ b/kidlisp/conformance/pixel-worker.mjs @@ -0,0 +1,147 @@ +// One isolate per replay: graph.mjs owns mutable renderer state. +import { parentPort, workerData } from "node:worker_threads"; +import { KidLisp } from "../../system/public/aesthetic.computer/lib/kidlisp.mjs"; +import { KidLispExecution, KidLispExecutionError } from "../../system/public/aesthetic.computer/lib/kidlisp-execution.mjs"; +import { numericOperationNames } from "../../system/public/aesthetic.computer/lib/kidlisp-ops.mjs"; +import { cssColors } from "../../system/public/aesthetic.computer/lib/num.mjs"; +import * as graph from "../../system/public/aesthetic.computer/lib/graph.mjs"; +import { validateReplayForms } from "./replay.mjs"; + +const operations = new Set([...numericOperationNames(), "wipe", "ink", "line", "box", "circle", "point", "clock", "random", "?", "repeat", "def", "tap", "draw", "lift", "if", "once", "frame", "width", "height", "w", "h", "no", "yes", "=", ">", "<", "...", "fill", "outline"]); +const fail = message => { throw new KidLispExecutionError("PIXEL_CAPABILITY", message); }; +const diagnostics = []; +// The evaluator catches some failures. A warning/error must still fail the run. +console.warn = console.error = (...args) => diagnostics.push(args.map(String).join(" ").slice(0, 1000)); +console.log = console.info = console.debug = () => {}; + +function run(fixture) { + const { source, seed, epochMs, stepMs, width, height, frames, events, maxSteps, maxDepth, monitor } = fixture; + const execution = new KidLispExecution({ seed, epochMs, stepMs, maxSteps, maxDepth }); + const lisp = new KidLisp({ execution }); + const ast = lisp.parse(source); + validateReplayForms(ast, { operations, profile: "pixels-v1" }); + const values = new Set([...operations, "penx", "peny", "down", "erase", "fill", "outline", ...Object.keys(cssColors)]); + const bindings = form => { + if (!Array.isArray(form)) return; + if (form[0] === "def" && typeof form[1] === "string") values.add(form[1]); + if (form[0] === "repeat" && form.length >= 4 && typeof form[2] === "string") values.add(form[2]); + form.forEach(bindings); + }; + ast.forEach(bindings); + // These values can reach live clocks or unseeded randomness before a host + // drawing call. Keep them outside this profile, including quoted spellings. + const validateValue = value => { + if (Array.isArray(value)) return value.forEach(validateValue); + if (typeof value !== "string") return; + const token = value.replace(/^"|"$/g, ""); + if (/^(?:fade:|rainbow|zebra|p\d+$|c\d+$)/i.test(token)) fail(`Unsupported pixel color: ${token}`); + if (!values.has(token) && !/^\d*\.?\d+s(?:!|\.{2,3})?$/.test(token) && !/^(?:#|0x)?[\da-f]{6}$/i.test(token)) fail(`Unsupported pixel value: ${token}`); + }; + ast.forEach(validateValue); + const piece = lisp.module(source, true); + if (!piece || !lisp.ast || lisp.lastValidationErrors?.length) fail("Pixel replay requires valid source"); + lisp.cacheInitiated = true; // No publishing, source lookup, HUD, or network host. + lisp.perf.enabled = monitor; + const display = { width, height, pixels: new Uint8ClampedArray(width * height * 4) }; + const commands = []; + graph.twoD(commands); + + function color(args) { + while (args.length === 1 && Array.isArray(args[0])) args = args[0]; + args = [...args]; + // The browser's unspecified ink uses host randomness. This profile routes + // that same choice through the instance seed; never patch Math.random. + if (!args.length || args[0] === undefined) { + const alpha = args[1] ?? 255; + args = [0, 0, 0].map(() => Math.floor(lisp.seededRandom() * 256)).concat(alpha); + } + if (typeof args[0] === "string") { + const name = args[0]; + if (!(Object.hasOwn(cssColors, name) || name === "erase" || /^(?:#|0x)?[\da-f]{6}$/i.test(name))) fail(`Unsupported pixel color: ${name}`); + if (args.length > 2) fail("Named colors accept only an optional alpha"); + } else if (typeof args[0] !== "number" && typeof args[0] !== "boolean") fail("Invalid pixel color"); + if (args.length > 4 || args.some((v, i) => (i > 0 || typeof v === "number") && !Number.isFinite(v))) fail("Color channels must be finite numbers"); + const resolved = graph.findColor(...args); + if (resolved.length !== 4 || !resolved.every(Number.isFinite)) fail("Color did not resolve to RGBA"); + graph.color(...resolved); + } + const coordinates = (name, values, count) => { + if (values.length < count || values.slice(0, count).some(n => !Number.isFinite(n) || Math.abs(n) > 8192)) fail(`${name} needs finite coordinates within Β±8192`); + }; + const api = { + screen: { ...display }, params: [], colon: [], clock: execution.clock, + system: { fps: 60 }, sound: {}, + fps() {}, needsPaint() {}, toggleHUD() {}, + page(buffer) { Object.assign(api.screen, buffer); graph.setBuffer(buffer); }, + ink(...args) { color(args); return api; }, + inkrn: () => graph.color(), + wipe(...args) { + const saved = graph.color(); + try { color(args.length ? args : [255]); graph.clear(); } + finally { graph.color(...saved); } + return api; + }, + backgroundFill(...args) { return api.wipe(...args); }, + blend(mode) { + if (!["blend", "erase"].includes(mode)) fail(`Unsupported blend: ${mode}`); + return graph.blendMode(mode); + }, + unmask: graph.unmask, + paste: graph.paste, + line(...args) { + coordinates("line", args, 4); + if (args.length > 5 || (args[4] !== undefined && (!Number.isFinite(args[4]) || args[4] < 0 || args[4] > 1024))) fail("Invalid line thickness"); + return graph.line(...args); + }, + box(...args) { + coordinates("box", args, 4); + if (args.length > 5 || ![undefined, "fill", "outline"].includes(args[4])) fail("Unsupported box mode"); + return graph.box(...args); + }, + circle(...args) { + coordinates("circle", args, 3); + if (args[2] < 0 || args[2] > 1024 || args.length > 4 || ![undefined, true, false, "fill", "outline"].includes(args[3])) fail("Unsupported circle radius or mode"); + return graph.circle(...args); + }, + point(...args) { coordinates("point", args, 2); if (args.length !== 2) fail("Invalid point"); return graph.point(...args); }, + plot(...args) { coordinates("plot", args, 2); if (args.length !== 2) fail("Invalid plot"); return graph.plot(...args); }, + send() { fail("Pixel replay has no messaging host"); }, + }; + api.page(display); + lisp.setAPI(api); + const output = []; + const check = () => { + if (execution.state.error) throw execution.state.error; + if (diagnostics.length) throw new KidLispExecutionError("PIXEL_RENDER", diagnostics.join("\n")); + }; + try { + execution.beginFrame(0); + piece.boot(execution.bindApi(api)); + check(); + for (let frame = 0; frame < frames; frame++) { + execution.beginFrame(frame); + commands.length = 0; + graph.writeBuffer.length = 0; + api.page(display); + const bound = execution.bindApi(api); + for (const event of events.filter(e => e.frame === frame)) { + piece.act({ api: bound, event: { ...event, delta: { x: event.dx, y: event.dy }, is: type => event.type === type } }); + } + // One simulation step, then one paint. The module's frame counter starts + // at one; the fixture and its clock use zero-based frame indices. + piece.sim(bound); + piece.paint(bound); + check(); + if (api.screen.pixels !== display.pixels) fail("Paint did not restore the display buffer"); + output.push({ frame, timeMs: execution.timeMs, rgba: display.pixels.slice() }); + } + } finally { piece.leave?.(); } + return { version: 1, profile: "pixels-v1", width, height, seed, epochMs, stepMs, frames: output }; +} + +try { + const result = run(workerData); + parentPort.postMessage({ result }, result.frames.map(frame => frame.rgba.buffer)); +} catch (error) { + parentPort.postMessage({ error: { name: error.name, message: error.message, code: error.code, frame: error.frame, steps: error.steps } }); +} diff --git a/kidlisp/conformance/pixels.mjs b/kidlisp/conformance/pixels.mjs new file mode 100644 index 0000000000..97e143a965 --- /dev/null +++ b/kidlisp/conformance/pixels.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import { Worker } from "node:worker_threads"; +import { createHash } from "node:crypto"; +import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; +import { resolve, join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { KidLispExecution } from "../../system/public/aesthetic.computer/lib/kidlisp-execution.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const hashPixels = bytes => createHash("sha256").update(bytes).digest("hex"); +const hashJSON = value => hashPixels(Buffer.from(JSON.stringify(value))); + +export function pixelFixture(input) { + const { source, seed = 0, epochMs = 0, stepMs = 1000 / 60, width = 96, height = 64, frames = 12, events = [], maxSteps = 100000, maxDepth = 128, monitor = false } = input; + if (typeof source !== "string" || !source.trim() || source.length > 1000000) throw new TypeError("Pixel source must contain 1..1,000,000 characters"); + new KidLispExecution({ seed, epochMs, stepMs, maxSteps, maxDepth }).beginFrame(frames - 1); + if (![width, height].every(n => Number.isInteger(n) && n >= 1 && n <= 512)) throw new RangeError("Pixel viewport must be 1..512 per axis"); + if (!Number.isInteger(frames) || frames < 1 || frames > 240 || width * height * frames > 8 * 1024 * 1024) throw new RangeError("Pixel replay exceeds frame/output budget"); + if (!Array.isArray(events) || events.length > 10000) throw new RangeError("Invalid pixel input count"); + const inputs = events.map(({ frame, type, x, y, dx = 0, dy = 0 }) => { + if (!Number.isInteger(frame) || frame < 0 || frame >= frames || !["touch", "draw", "lift"].includes(type) || ![x, y, dx, dy].every(n => Number.isFinite(n) && Math.abs(n) <= 8192)) throw new TypeError("Invalid pixel input event"); + return { frame, type, x, y, dx, dy }; + }); + if (typeof monitor !== "boolean") throw new TypeError("monitor must be boolean"); + return { source, seed, epochMs, stepMs, width, height, frames, events: inputs, maxSteps, maxDepth, monitor }; +} + +export function replayPixels(input, { timeoutMs = 15000 } = {}) { + const fixture = pixelFixture(input); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) throw new RangeError("Pixel timeout must be 1..60000ms"); + return new Promise((resolveRun, reject) => { + const worker = new Worker(new URL("./pixel-worker.mjs", import.meta.url), { + workerData: fixture, execArgv: ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON"], + resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 }, + }); + let settled = false; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // Terminate before resolving, including any unexpected background work. + worker.terminate().then(() => error ? reject(error) : resolveRun(result), reject); + }; + const timer = setTimeout(() => finish(Object.assign(new Error("Pixel replay timed out"), { code: "PIXEL_TIMEOUT" })), timeoutMs); + worker.once("message", ({ result, error }) => finish(error ? Object.assign(new Error(error.message), error) : null, result)); + worker.once("error", error => finish(error)); + worker.once("exit", code => { if (!settled) finish(new Error(`Pixel worker exited without a result (${code})`)); }); + }); +} + +export function comparePixelFrames(before, after, width, height) { + const size = width * height * 4; + if (!Number.isSafeInteger(size) || size < 4 || !Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || before?.length !== size || after?.length !== size) throw new TypeError("Pixel buffers must match the viewport"); + let changedPixels = 0, changedChannels = 0, first = null; + for (let i = 0; i < size; i += 4) { + let changed = false; + for (let c = 0; c < 4; c++) if (before[i + c] !== after[i + c]) { changed = true; changedChannels++; } + if (changed) { + changedPixels++; + first ||= { x: (i / 4) % width, y: Math.floor(i / 4 / width), before: Array.from(before.slice(i, i + 4)), after: Array.from(after.slice(i, i + 4)) }; + } + } + return { equal: changedPixels === 0, changedPixels, changedChannels, first }; +} + +async function png(path, rgba, width, height) { + const { default: sharp } = await import("sharp"); + await sharp(Buffer.from(rgba), { raw: { width, height, channels: 4 } }).png().toFile(path); +} + +async function loadPNG(path, width, height) { + const { default: sharp } = await import("sharp"); + const { data, info } = await sharp(path).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + if (info.width !== width || info.height !== height || info.channels !== 4) throw new Error(`Invalid golden dimensions: ${path}`); + return data; +} + +export async function pixelSuite({ command = "check", fixtures = join(here, "pixels"), golden = join(here, "pixel-golden"), out = "/tmp/kidlisp-pixels" } = {}) { + if (!["check", "record"].includes(command)) throw new Error("Expected check or record"); + const names = (await readdir(fixtures)).filter(n => /^[a-z0-9-]+\.json$/.test(n)).sort(); + if (!names.length) throw new Error("No pixel fixtures"); + // Missing goldens fail; checking never blesses current output automatically. + const expected = command === "check" ? JSON.parse(await readFile(join(golden, "manifest.json"), "utf8")) : null; + if (expected && (expected.version !== 1 || expected.profile !== "pixels-v1" || JSON.stringify(Object.keys(expected.fixtures).sort()) !== JSON.stringify(names))) throw new Error("Golden profile or fixture set differs"); + const manifest = { version: 1, profile: "pixels-v1", engine: process.version, platform: `${process.platform}/${process.arch}`, fixtures: {} }; + const report = { profile: "pixels-v1", passed: true, fixtures: {} }; + await mkdir(out, { recursive: true }); + if (command === "record") await mkdir(golden, { recursive: true }); + for (const name of names) { + const input = pixelFixture(JSON.parse(await readFile(join(fixtures, name), "utf8"))); + const fixtureHash = hashJSON(input); + const before = expected?.fixtures[name]; + if (before && (before.fixtureHash !== fixtureHash || before.frames.length !== input.frames)) throw new Error(`Golden fixture changed: ${name}; review before recording`); + const first = await replayPixels(input); + const second = await replayPixels(input); + const rows = []; + const hashes = []; + for (const frame of first.frames) { + const file = `${name.slice(0, -5)}-${String(frame.frame).padStart(3, "0")}.png`; + const repeat = comparePixelFrames(frame.rgba, second.frames[frame.frame].rgba, input.width, input.height); + let baseline = null; + const hash = hashPixels(frame.rgba); + hashes.push({ frame: frame.frame, timeMs: frame.timeMs, sha256: hash, file }); + if (before) { + const prior = before.frames[frame.frame]; + if (prior.frame !== frame.frame || prior.timeMs !== frame.timeMs || prior.file !== file) throw new Error(`Invalid golden frame: ${name}/${frame.frame}`); + const bytes = await loadPNG(join(golden, file), input.width, input.height); + if (hashPixels(bytes) !== prior.sha256) throw new Error(`Golden image hash mismatch: ${file}`); + baseline = comparePixelFrames(bytes, frame.rgba, input.width, input.height); + if (!baseline.equal) { + await png(join(out, `expected-${file}`), bytes, input.width, input.height); + const diff = new Uint8ClampedArray(bytes.length); + for (let i = 0; i < diff.length; i += 4) { + const changed = bytes.subarray(i, i + 4).some((v, c) => v !== frame.rgba[i + c]); + diff.set(changed ? [255, 0, 128, 255] : [0, 0, 0, 255], i); + } + await png(join(out, `diff-${file}`), diff, input.width, input.height); + } + } + if (!repeat.equal || baseline?.equal === false) report.passed = false; + await png(join(out, file), frame.rgba, input.width, input.height); + if (command === "record") { + if (!repeat.equal) throw new Error(`Nondeterministic pixels: ${file}`); + await png(join(golden, file), frame.rgba, input.width, input.height); + } + rows.push({ frame: frame.frame, sha256: hash, repeat, baseline }); + } + manifest.fixtures[name] = { fixtureHash, width: input.width, height: input.height, frames: hashes }; + report.fixtures[name] = rows; + } + if (command === "record") await writeFile(join(golden, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); + await writeFile(join(out, "report.json"), JSON.stringify(report, null, 2) + "\n"); + return report; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [command = "check", ...args] = process.argv.slice(2); + const options = { command }; + for (let i = 0; i < args.length; i += 2) { + if (!["--fixtures", "--golden", "--out"].includes(args[i]) || !args[i + 1]) throw new Error("usage: pixels.mjs check|record [--fixtures DIR] [--golden DIR] [--out DIR]"); + options[args[i].slice(2)] = resolve(args[i + 1]); + } + const report = await pixelSuite(options); + console.log(`${report.passed ? "PASS" : "FAIL"}: ${Object.keys(report.fixtures).length} exact pixel fixtures; ${options.out || "/tmp/kidlisp-pixels"}/report.json`); + process.exitCode = report.passed ? 0 : 1; +} diff --git a/kidlisp/conformance/pixels/accumulation.json b/kidlisp/conformance/pixels/accumulation.json new file mode 100644 index 0000000000..578008361a --- /dev/null +++ b/kidlisp/conformance/pixels/accumulation.json @@ -0,0 +1,4 @@ +{ + "source": "(if (= frame 0) (wipe 16 24 40)) (ink 255 90 30 120) (circle (+ 5 (* frame 7)) 30 14) (ink 30 150 255 100) (box 20 (* frame 3) 55 7)", + "width": 96, "height": 64, "frames": 10, "seed": 19 +} diff --git a/kidlisp/conformance/pixels/alpha.json b/kidlisp/conformance/pixels/alpha.json new file mode 100644 index 0000000000..b17d4dc644 --- /dev/null +++ b/kidlisp/conformance/pixels/alpha.json @@ -0,0 +1,4 @@ +{ + "source": "(wipe 0 0 0 0) (ink 255 180 40 100) (box 8 8 80 48) (ink 30 80 140 180) (circle 48 32 20) (ink 255 40 100 100) (circle 66 34 18)", + "width": 96, "height": 64, "frames": 3, "seed": 3 +} diff --git a/kidlisp/conformance/pixels/clock-input.json b/kidlisp/conformance/pixels/clock-input.json new file mode 100644 index 0000000000..d68d6cc236 --- /dev/null +++ b/kidlisp/conformance/pixels/clock-input.json @@ -0,0 +1,9 @@ +{ + "source": "(wipe 8 14 22) (tap (def shade 240)) (lift (def shade 70)) (if (= frame 0) (def shade 70)) (ink shade 120 180) (box penx peny 12 12) (ink 255 200 40) (line 0 55 (% (/ (clock) 10) width) 55) (1s (ink white) (box 75 4 15 15))", + "width": 96, "height": 64, "frames": 9, "seed": 11, "epochMs": 1000, "stepMs": 250, + "events": [ + { "frame": 1, "type": "touch", "x": 12, "y": 12 }, + { "frame": 2, "type": "draw", "x": 35, "y": 28, "dx": 23, "dy": 16 }, + { "frame": 3, "type": "lift", "x": 35, "y": 28 } + ] +} diff --git a/kidlisp/conformance/pixels/primitives.json b/kidlisp/conformance/pixels/primitives.json new file mode 100644 index 0000000000..d5cb2caecd --- /dev/null +++ b/kidlisp/conformance/pixels/primitives.json @@ -0,0 +1,4 @@ +{ + "source": "(wipe 12 18 30) (ink 250 190 40) (box -4 5 33 20) (ink 40 180 250) (circle 60 30 18) (ink 255 80 120 128) (box 30 12 32 33) (ink white) (line -10 60 100 2) (point 88 56)", + "width": 96, "height": 64, "frames": 2, "seed": 7 +} diff --git a/kidlisp/conformance/pixels/random.json b/kidlisp/conformance/pixels/random.json new file mode 100644 index 0000000000..3cc7d0c226 --- /dev/null +++ b/kidlisp/conformance/pixels/random.json @@ -0,0 +1,4 @@ +{ + "source": "(wipe 8) (repeat 12 (ink (random 256) (random 256) (random 256)) (line (random width) (random height) (random width) (random height))) (box) (circle)", + "width": 96, "height": 64, "frames": 8, "seed": 42 +} diff --git a/kidlisp/conformance/replay.mjs b/kidlisp/conformance/replay.mjs new file mode 100644 index 0000000000..98e6844009 --- /dev/null +++ b/kidlisp/conformance/replay.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// Deterministic reference-evaluator command replay. This profile deliberately +// records host calls; the browser pixel oracle remains a separate renderer gate. +import { KidLisp } from "../../system/public/aesthetic.computer/lib/kidlisp.mjs"; +import { KidLispExecution, KidLispExecutionError } from "../../system/public/aesthetic.computer/lib/kidlisp-execution.mjs"; +import { numericOperationNames } from "../../system/public/aesthetic.computer/lib/kidlisp-ops.mjs"; + +const heads = new Set([...numericOperationNames(), "wipe", "ink", "line", "box", "circle", "point", "write", "clock", "random", "?", "repeat", "def", "tap", "draw", "lift", "if", "once", "frame", "width", "height", "w", "h", "no", "yes", "=", ">", "<", "..."]); +const traceValue = value => { + if (typeof value === "number") { + if (Number.isNaN(value)) return { $number: "NaN" }; + if (!Number.isFinite(value)) return { $number: value > 0 ? "+Infinity" : "-Infinity" }; + if (Object.is(value, -0)) return { $number: "-0" }; + } + if (value === undefined) return { $undefined: true }; + if (Array.isArray(value)) return value.map(traceValue); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, traceValue(v)])); + return value; +}; +export function validateReplayForms(forms, { operations = heads, profile = "commands-v1" } = {}) { + const visit = form => { + if (!Array.isArray(form)) { + if (typeof form === "string" && (/^[$#!]/.test(form) || ["rainbow", "zebra", "mic", "amp", "speaker"].includes(form))) throw new KidLispExecutionError("REPLAY_CAPABILITY", `Unavailable replay value: ${form}`); + return; + } + const [head, ...args] = form; + if (!(typeof head === "number" || operations.has(head) || (typeof head === "string" && /^\d*\.?\d+s(?:!|\.{2,3})?$/.test(head)))) throw new KidLispExecutionError("REPLAY_CAPABILITY", `Unavailable ${profile} operation: ${String(head)}`); + if (head === "clock" && args.length) throw new KidLispExecutionError("REPLAY_CAPABILITY", "Replay clock is numeric; audio needs a recorded media host"); + if (head === "def" && (args.length !== 2 || typeof args[0] !== "string")) throw new KidLispExecutionError("REPLAY_CAPABILITY", "commands-v1 supports value definitions"); + args.forEach(visit); + }; + forms.forEach(visit); +} + +export function replay({ source, seed = 0, epochMs = 0, stepMs = 1000 / 60, frames = 60, width = 128, height = 128, events = [], maxSteps = 100000, maxDepth = 128, monitor = false }) { + if (!Number.isInteger(frames) || frames < 1 || frames > 10000) throw new RangeError("Replay frames must be 1..10000"); + if (![width, height].every(n => Number.isInteger(n) && n > 0 && n <= 4096)) throw new RangeError("Invalid replay viewport"); + if (!Array.isArray(events) || events.length > 10000) throw new RangeError("Invalid replay input count"); + const byFrame = new Map(); + for (const event of events) { + if (!Number.isInteger(event.frame) || event.frame < 0 || event.frame >= frames || !["touch", "draw", "lift"].includes(event.type) || ![event.x, event.y, event.dx ?? 0, event.dy ?? 0].every(Number.isFinite)) throw new TypeError("Invalid replay input event"); + if (!byFrame.has(event.frame)) byFrame.set(event.frame, []); + byFrame.get(event.frame).push({ ...event }); + } + const execution = new KidLispExecution({ seed, epochMs, stepMs, maxSteps, maxDepth }); + const lisp = new KidLisp({ execution }); + // Validate before module construction: unsupported source must not resolve + // cached programs or initiate any host/network work. + const parsed = lisp.parse(source); + validateReplayForms(parsed); + const piece = lisp.module(source, true); + if (!piece || !lisp.ast || lisp.lastValidationErrors?.length) throw new KidLispExecutionError("REPLAY_SOURCE", "Replay needs valid source in commands-v1"); + lisp.perf.enabled = monitor; + const output = []; + let calls; + const api = { + screen: { width, height }, clock: execution.clock, needsPaint() {}, toggleHUD() {}, + send() { throw new KidLispExecutionError("REPLAY_CAPABILITY", "Replay has no external messaging host"); }, + }; + for (const name of ["wipe", "ink", "line", "box", "circle", "point", "plot", "write"]) { + api[name] = (...args) => { calls.push([name, ...args.map(traceValue)]); return api; }; + } + try { + for (let frame = 0; frame < frames; frame++) { + execution.beginFrame(frame); + lisp.frameCount = frame; + api.paintCount = frame; + calls = []; + const bound = execution.bindApi(api); + for (const event of byFrame.get(frame) || []) { + piece.act({ api: bound, event: { ...event, delta: { x: event.dx ?? 0, y: event.dy ?? 0 }, is: type => event.type === type } }); + } + lisp.evaluate(lisp.ast, bound, undefined, undefined, true); + output.push({ frame, timeMs: execution.timeMs, calls }); + } + } finally { piece.leave?.(); } + return { version: 1, profile: "commands-v1", seed, epochMs, stepMs, width, height, frames: output }; +} + +// JSON fixture in; a stable command trace and SHA-256 out. No browser or service. +if (typeof process !== "undefined" && process.argv[1] && import.meta.url === (await import("node:url")).pathToFileURL(process.argv[1]).href) { + const { readFileSync } = await import("node:fs"); + const { createHash } = await import("node:crypto"); + if (!process.argv[2]) throw new Error("usage: node kidlisp/conformance/replay.mjs fixture.json"); + const result = replay(JSON.parse(readFileSync(process.argv[2], "utf8"))); + const hash = createHash("sha256").update(JSON.stringify(result)).digest("hex"); + process.stdout.write(JSON.stringify({ ...result, sha256: hash }, null, 2) + "\n"); +} diff --git a/kidlisp/conformance/seeded-input.json b/kidlisp/conformance/seeded-input.json new file mode 100644 index 0000000000..69e68fdf75 --- /dev/null +++ b/kidlisp/conformance/seeded-input.json @@ -0,0 +1,14 @@ +{ + "source": "(wipe black) (tap (write \"pressed\" 0 0)) (lift (write \"released\" 0 0)) (ink (random 256) (random 256) (random 256)) (line penx peny (random width) (random height)) (write (clock) 0 10) (write down 0 20) (1s (write \"tick\" 0 30))", + "seed": 42, + "epochMs": 1000, + "stepMs": 250, + "frames": 9, + "width": 128, + "height": 128, + "events": [ + { "frame": 1, "type": "touch", "x": 12, "y": 30 }, + { "frame": 2, "type": "draw", "x": 24, "y": 36, "dx": 12, "dy": 6 }, + { "frame": 3, "type": "lift", "x": 24, "y": 36 } + ] +} diff --git a/kidlisp/tools/bench-raytrace.mjs b/kidlisp/tools/bench-raytrace.mjs new file mode 100644 index 0000000000..a27183b016 --- /dev/null +++ b/kidlisp/tools/bench-raytrace.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Measure the whole CPU frame, including JS traversal and Wasm crossings. +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import { cpus, platform, arch } from "node:os"; +import { resolve, join } from "node:path"; +import sharp from "sharp"; +import { rayKernel, renderRayFrame } from "../benchmarks/raytrace.mjs"; +import { hashPixels, comparePixelFrames } from "../conformance/pixels.mjs"; + +const source = await readFile(new URL("../benchmarks/ray-sphere.lisp", import.meta.url), "utf8"); +const out = resolve(process.argv[2] || "/tmp/kidlisp-raytrace"); +await mkdir(out, { recursive: true }); +const results = []; +const quantile = (samples, q) => [...samples].sort((a, b) => a - b)[Math.ceil(q * samples.length) - 1]; +for (const [width, height] of [[64, 36], [96, 54], [160, 90]]) { + const expected = []; + for (const backend of ["direct-js", "reference", "plan", "wasm"]) { + const start = performance.now(); + const kernel = rayKernel(source, backend); + const prepareMs = performance.now() - start; + for (let i = 0; i < 3; i++) renderRayFrame(kernel, { width, height, frame: i }); + const samples = []; + let rendered; + // Reference interpretation is intentionally measured too, with the same + // work and sample count. Direct JS prevents a misleading speedup baseline. + for (let frame = 0; frame < 12; frame++) { + const t = performance.now(); + rendered = renderRayFrame(kernel, { width, height, frame }); + samples.push(performance.now() - t); + if (backend === "direct-js") expected.push(rendered.rgba); + else { + const parity = comparePixelFrames(expected[frame], rendered.rgba, width, height); + if (!parity.equal) throw new Error(`${backend} changed ${parity.changedPixels} pixels at ${width}x${height}, frame ${frame}`); + } + } + const medianMs = quantile(samples, 0.5), p95Ms = quantile(samples, 0.95); + const row = { width, height, backend, prepareMs, medianMs, p95Ms, samples, wasmBytes: kernel.bytes, rays: rendered.rays, intersections: rendered.intersections, pixelSha256: hashPixels(rendered.rgba) }; + results.push(row); + console.log(`${width}x${height} ${backend}: median ${medianMs.toFixed(2)}ms; p95 ${p95Ms.toFixed(2)}ms; exact pixels`); + if (backend === "wasm") await sharp(Buffer.from(rendered.rgba), { raw: { width, height, channels: 4 } }).png().toFile(join(out, `raytrace-${width}x${height}.png`)); + } +} +await writeFile(join(out, "report.json"), JSON.stringify({ version: 1, scene: "three spheres, checker floor, hard shadows, two reflection bounces, one sample per pixel", kernel: source, scope: "KidLisp ray/sphere discriminant; JS scene traversal/shading/pixel loop; no display cost", host: { node: process.version, platform: platform(), arch: arch(), cpu: cpus()[0]?.model }, results }, null, 2) + "\n"); diff --git a/kidlisp/tools/bench-rich-text.mjs b/kidlisp/tools/bench-rich-text.mjs new file mode 100644 index 0000000000..3cbadf0f35 --- /dev/null +++ b/kidlisp/tools/bench-rich-text.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Renderer bookkeeping benchmark. Draw calls are counted, not rasterized. +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +const modulePath = process.argv[2] || resolve('system/public/aesthetic.computer/lib/rich-text.mjs'); +const { RichTextFlow } = await import(pathToFileURL(resolve(modulePath))); +for (const count of [295, 10000]) { + const body = Array.from({ length: count }, (_, i) => `word${i}`).join(' '); + const words = [...body.matchAll(/\S+/g)].map((m, i) => ({ start: m.index, end: m.index + m[0].length, fromMs: i * 250, toMs: (i + 1) * 250 })); + let wraps = 0, draws = 0; + const api = { screen: { width: 330, height: 90 }, ink() {}, box() {}, write() { draws++; }, send() {}, needsPaint() {}, text: { + width: text => text.length * 6, + box(text, _at, width, scale) { + wraps++; + const columns = Math.max(1, Math.floor(width / (6 * scale))); + const lines = [], charMap = []; + for (let i = 0; i < text.length; i += columns) { + lines.push(text.slice(i, i + columns)); + charMap.push(Array.from({ length: Math.min(columns, text.length - i) }, (_, j) => i + j)); + } + return { lines, charMap }; + }, + } }; + const flow = new RichTextFlow(); + const handle = { kidlispData: true, url: 'https://example.com/episode.json', value: { title: 'reader', body, words, audio: { url: 'episode.mp3' } } }; + const values = [{ kind: 'listen', data: handle }]; + const start = performance.now(); flow.paint(api, values); const prepareMs = performance.now() - start; + flow.playing = true; + const times = []; + for (let batch = 0; batch < 60; batch++) { + const t = performance.now(); + for (let repeat = 0; repeat < 100; repeat++) { + flow.time = ((batch * 100 + repeat) % count) * .25 + .1; + flow.paint(api, values); + } + times.push((performance.now() - t) / 100); + } + times.sort((a, b) => a - b); + console.log(JSON.stringify({ words: count, prepareMs: +prepareMs.toFixed(3), medianFrameMs: +times[30].toFixed(4), p95BatchFrameMs: +times[57].toFixed(4), wraps, draws, excludes: 'glyph rasterization, interpreter, audio decoding, GPU presentation' })); +} diff --git a/kidlisp/tools/build-raytrace-preview.mjs b/kidlisp/tools/build-raytrace-preview.mjs new file mode 100644 index 0000000000..ba22416421 --- /dev/null +++ b/kidlisp/tools/build-raytrace-preview.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +const { build } = createRequire(new URL("../../system/package.json", import.meta.url))("esbuild"); + +const source = await readFile(new URL("../benchmarks/ray-sphere.lisp", import.meta.url), "utf8"); +const result = await build({ + entryPoints: [fileURLToPath(new URL("../benchmarks/raytrace-preview.mjs", import.meta.url))], + bundle: true, write: false, format: "iife", platform: "browser", minify: true, + external: ["https", "url", "module"], + define: { KIDLISP_RAY_SOURCE: JSON.stringify(source) }, +}); +const out = resolve(process.argv[2] || "/tmp/kidlisp-raytrace/preview.html"); +await mkdir(dirname(out), { recursive: true }); +await writeFile(out, `KidLisp ray tracing + +

KidLisp ray tracing

KidLisp ray–sphere kernel; JavaScript scene and shading. One sample per pixel, two reflection bounces.

+`); +console.log(out); diff --git a/kidlisp/tools/operations.mjs b/kidlisp/tools/operations.mjs new file mode 100644 index 0000000000..1b395a8905 --- /dev/null +++ b/kidlisp/tools/operations.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// Machine-readable audited instruction contracts, generated from the registry. +import { operationManifest } from "../../system/public/aesthetic.computer/lib/kidlisp-ops.mjs"; +process.stdout.write(JSON.stringify(operationManifest(), null, 2) + "\n"); diff --git a/marketing/podcast/SCORE.md b/marketing/podcast/SCORE.md index c2edef96e8..13407ba270 100644 --- a/marketing/podcast/SCORE.md +++ b/marketing/podcast/SCORE.md @@ -48,9 +48,8 @@ removing a stale lock. A saved receipt always wins over a leftover queue job. **The daily token (bin/daily-token.mjs).** With `DAILY_MINT=1` the episode is also minted as a hic et nunc OBJKT signed by aesthetic.tez, named just the episode title. Its image is the update itself: the script set in AC's pixel -font by a KidLisp piece (stored as a new $code; commas, semicolons, quotes and -parens are stripped because KidLisp splits on them) over a slow fade whose -colors turn with the date, grabbed by the oven as a 512Β² GIF, with a frame as +font by a KidLisp piece (stored as a new $code, with punctuation preserved) +over a date-colored starfield, rendered locally as a 512Β² GIF, with a frame as the thumbnail. AC's own IPFS node pins it (`/api/ipfs-add`, admin-only, the Kubo node Keeps uses) with TZIP-21 metadata (the script as description, links to the episode and the live $code), `mint_OBJKT` mints `DAILY_EDITIONS` (1, a @@ -64,6 +63,30 @@ as a directory. The artifact URI is the directory root with MIME type display image. Never mint a bare HTML or ZIP file CID as the interactive artifact. `DAILY_ARTIFACT=gif` retains the GIF-only option. An unminted receipt with previously pinned metadata in another format stops for inspection. +New interactive packages also carry `transcript.txt`, `content.json` and +`source.lisp`. The canonical transcript preserves Unicode and punctuation +even where the pixel font cannot draw them. Before signing, the crawl's text +must match the episode after font normalization; the pinned metadata and every +directory file are fetched through IPFS and compared with the intended bytes. +Any missing or mismatched content stops minting, including an old unminted +directory without transcripts. `contentQA` in the receipt records file SHA-256 +hashes and the read-back time. GIF mode verifies the prose in pinned metadata. +These checks confirm content preservation, not visual legibility; the sandbox +render check remains separate. +Daily text now wraps at 20 columns, sizes from width in whole glyph pixels, +and scrolls without perspective shrinking. A new view starts at the title; +short tiled windows must not scale the font down from their height. Inspect +both a 512Β² viewer and a 990Γ—270 tiled view with `daily-artifact-check.mjs` +(`--width 990 --height 270 --shots `) before accepting a change. +`DAILY_RICH_TEXT=1` selects a compact `(flow (heading ...) (paragraph ...))` +reader with named `(link "Aesel" "https://aesel.app")` buttons, manual scrolling +and keyboard link selection. Destinations live in `lib/daily-richtext.mjs`. +Keep this option off until the live AC runtime supports these forms; new +bundles carry the updated runtime, but live `$code` URLs use production. +Use `daily-artifact-check.mjs --reader` to verify static text and scrolling. +To verify a visible link, add `--link-x --link-y --expect-url `; +the checker requires a separate tab and an unchanged reader URL. Interactive +hosts must allow popups for new tabs; blocked popups never replace the reader. Stages resume from `out/daily/.token.json`; it refuses to sign with any key but aesthetic.tez or below 0.15 XTZ (each day burns ~0.06). Needs `AESTHETIC_KEY` in the appliance env (jasellite: `~/.config/ac/tezos-daily.env`, @@ -174,3 +197,23 @@ private paper directory and its slug outside the publish allowlist. - `/api/say` invocation pattern + content-hash caching β€” lifted from `pop/bin/say.mjs`. - Jingle synthesis follows the pop DSP posture (phase-increment sines, exp-decay bells) but is self-contained here. + +## Readalong data (local prototype) + +`(def episode (fetch "episode.json"))` followed by `(flow (listen episode))` +loads canonical prose, optional named link blocks, the actual mastered podcast, +and word timings. Native Play/Pause and seek use the media clock. Follow scrolls +the spoken line; dragging or wheeling releases it. Leaving stops playback. + +Build the JSON from an existing daily and Whisper word output (`-ml 1 -sow -oj`): + +```sh +node bin/read-along.mjs daily.md words.json 12384 episode.mp3 episode.json +``` + +The offset is the narration's start in the final master, in milliseconds. +Keep episode.json and episode.mp3 beside the packed piece. The audio SHA-256 +is recorded; canonical text is never replaced with ASR spelling. Edit alignment +records substitutions and omits missing words; boundaries are ASR measurements, +not phoneme forced alignment. This is an explicit build step; the daily cron +does not yet produce readalong data or opt into the new runtime. diff --git a/marketing/podcast/bin/daily-artifact-check.mjs b/marketing/podcast/bin/daily-artifact-check.mjs index c56512834b..f225f56a00 100644 --- a/marketing/podcast/bin/daily-artifact-check.mjs +++ b/marketing/podcast/bin/daily-artifact-check.mjs @@ -27,15 +27,22 @@ const puppeteer = need("puppeteer"), sharp = need("sharp"); const [file, ...rest] = process.argv.slice(2); if (!file) { console.error("usage: daily-artifact-check.mjs [--shots dir]"); process.exit(2); } const shotsDir = rest.includes("--shots") ? rest[rest.indexOf("--shots") + 1] : null; +const dimension = (name, fallback) => { + const value = rest.includes(`--${name}`) ? Number(rest[rest.indexOf(`--${name}`) + 1]) : fallback; + if (!Number.isInteger(value) || value < 64 || value > 4096) throw new Error(`Invalid --${name}`); + return value; +}; +const width = dimension("width", 512), height = dimension("height", 512); const zip = file.endsWith(".zip") ? new (need("adm-zip"))(readFileSync(file)) : null; const bundle = zip ? zip.readFile("index.html") : readFileSync(file); if (!bundle) throw new Error("ZIP is missing root index.html"); const ART = "https://art.test/index.html", HOST = "https://host.test/"; -const host = ``; +const reader = rest.includes("--reader"); +const host = ``; const browser = await puppeteer.launch({ headless: "new" }); const page = await browser.newPage(); -await page.setViewport({ width: 512, height: 512 }); +await page.setViewport({ width, height }); await page.setRequestInterception(true); const leaked = []; page.on("request", (r) => { @@ -70,12 +77,41 @@ while (Date.now() - t0 < 4000) { shots++; await new Promise((r) => setTimeout(r, 150)); } -await browser.close(); // A healthy crawl changes on nearly every shot; the stalled one managed two // frames in five seconds, or none at all. const moving = frames.size >= Math.min(6, shots - 1); -const ok = lit > 500 && moving && !leaked.length; +let scrolled = false; +let linked = true; +const expectedURL = rest.includes("--expect-url") ? rest[rest.indexOf("--expect-url") + 1] : null; +if (reader && expectedURL) { + const x = Number(rest[rest.indexOf("--link-x") + 1]); + const y = Number(rest[rest.indexOf("--link-y") + 1]); + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= width || y >= height) throw new Error("Supply visible --link-x and --link-y coordinates"); + const original = page.url(); + const destination = new URL(expectedURL).href; + try { + const popup = browser.waitForTarget(target => target.type() === 'page' && target.url() === destination, { timeout: 5000 }); + await page.mouse.click(x, y); + const target = await popup; + linked = page.url() === original; + await (await target.page()).close(); + await page.bringToFront(); + } catch { linked = false; } +} +if (reader) { + const before = await page.screenshot(); + await page.mouse.move(width / 2, height / 2); + await page.mouse.wheel({ deltaY: 200 }); + await new Promise(resolve => setTimeout(resolve, 500)); + const after = await page.screenshot(); + scrolled = !before.equals(after); + if (shotsDir) writeFileSync(resolve(shotsDir, `${basename(file, '.html')}-scroll.png`), after); +} +const ok = lit > 500 && (reader ? scrolled && linked : moving) && !leaked.length; +await browser.close(); console.log(`${ok ? "βœ“" : "βœ—"} ${basename(file)}: ${frames.size}/${shots} distinct frames in 4 s, ${lit} lit px, ${leaked.length} network requests`); for (const u of leaked) console.log(` leaked: ${u}`); +if (reader) console.log(` scroll changes the rendered page: ${scrolled}`); +if (expectedURL) console.log(` link opens a separate tab and preserves the reader: ${linked}`); process.exit(ok ? 0 : 1); diff --git a/marketing/podcast/bin/daily-token.mjs b/marketing/podcast/bin/daily-token.mjs index dad5b79af2..dce70bd52e 100644 --- a/marketing/podcast/bin/daily-token.mjs +++ b/marketing/podcast/bin/daily-token.mjs @@ -73,7 +73,7 @@ const PRICE_XTZ = Number(process.env.DAILY_PRICE_XTZ || 3); const ROYALTIES = Number(process.env.DAILY_ROYALTIES_PERMILLE || 150); // HEN is per-mille const MIN_BALANCE_XTZ = 0.15; // a mint + a listing burn ~0.06 -const { artifactMode, crawlBundle, checkBundle, crawlPackage, checkPinnedArtifact, tokenMetadata, TEIA_FORMAT } = await import(resolve(ROOT, "lib", "artifact.mjs")); +const { artifactMode, crawlBundle, checkBundle, dailyPackage, contentFiles, checkPublishedContent, checkPinnedArtifact, tokenMetadata, TEIA_FORMAT } = await import(resolve(ROOT, "lib", "artifact.mjs")); let ARTIFACT; try { ARTIFACT = artifactMode(); } catch (err) { console.error(`βœ— ${err.message}`); process.exit(1); } @@ -137,9 +137,10 @@ async function store(source) { if (!receipt.code) { const layout = crawlLayout({ title, body, date }); - // The live piece is paced for reading and keeps more of its size as a - // line recedes (a shallower plane); the GIF stays a 30 s preview. - const source = crawlPiece(crawlLayout({ title, body, date, kmin: .62 })); + // Whole-pixel text remains readable in AC's default tiled view. + const source = process.env.DAILY_RICH_TEXT === "1" + ? (await import(resolve(ROOT, "lib", "daily-richtext.mjs"))).richDailyPiece({ title, body }) + : crawlPiece(layout); if (source.length > 50000) { console.error(`βœ— the crawl piece is ${source.length} chars (store-kidlisp takes 50000)`); process.exit(1); } const code = await store(source); console.log(` page stored as $${code} β€” rendering the crawl…`); @@ -162,6 +163,8 @@ if (!receipt.code) { // that fails is rebuilt once from this checkout's runtime, and if it still // fails the night refuses to mint HTML rather than mint a broken artifact. const htmlPath = resolve(dailyDir, `${slug}.html`); +const episodeContent = { title, body, date, code: receipt.code, source: receipt.source }; +contentFiles(episodeContent); // Also check GIF-mode source against the script. let packaged; if (ARTIFACT === "zip") { const pack = async () => { @@ -185,13 +188,13 @@ if (ARTIFACT === "zip") { process.exit(1); } console.log(" βœ“ bundle checks out: PACK mode, offline, source intact, runtime current"); - packaged = crawlPackage(readFileSync(htmlPath, "utf8"), { + packaged = dailyPackage(readFileSync(htmlPath, "utf8"), { gif: readFileSync(resolve(dailyDir, `${slug}.gif`)), thumbnail: readFileSync(resolve(dailyDir, `${slug}-thumb.png`)), frames: receipt.frames, - }); + }, episodeContent); writeFileSync(resolve(dailyDir, `${slug}.zip`), packaged.zip); - console.log(` βœ“ HEN/Teia package β†’ out/daily/${slug}.zip (index.html + covers)`); + console.log(` βœ“ HEN/Teia package β†’ out/daily/${slug}.zip (page, covers, transcript, JSON, source)`); } const metadataFor = (uris) => tokenMetadata({ title, body, date, episodeUrl, code: receipt.code, creator: SIGNER, artifact: ARTIFACT, uris, ac: AC }); @@ -236,6 +239,16 @@ if (!receipt.metadataUri) { } // ── 3. mint ────────────────────────────────────────────────────────────── +if (!receipt.mintOp && receipt.tokenId === undefined) { + receipt.contentQA = await checkPublishedContent({ + metadataUri: receipt.metadataUri, + metadata: metadataFor({ directory: receipt.artifactUri, gif: receipt.displayUri, thumb: receipt.thumbnailUri }), + files: packaged?.files || [], + gateway: process.env.IPFS_GATEWAY || "https://ipfs.aesthetic.computer", + }); + save(); + console.log(" βœ“ pinned content read back: episode text matches; SHA-256 evidence saved"); +} // Taquito lives in tezos/'s node_modules (npm install --prefix tezos). const requireTezos = createRequire(resolve(REPO, "tezos", "package.json")); const { TezosToolkit } = requireTezos("@taquito/taquito"); diff --git a/marketing/podcast/bin/read-along-preview.mjs b/marketing/podcast/bin/read-along-preview.mjs new file mode 100644 index 0000000000..42fd9261f2 --- /dev/null +++ b/marketing/podcast/bin/read-along-preview.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Loopback preview with byte-range audio seeking. Serve an existing packed directory. +import { createServer } from 'node:http'; +import { statSync, createReadStream } from 'node:fs'; +import { resolve } from 'node:path'; +const directory = resolve(process.argv[2] || '.'); +const port = Number(process.argv[3] || 8898); +const files = new Map(['index.html', 'episode.json', 'episode.mp3', 'source.lisp'].map(name => [name, resolve(directory, name)])); +const mime = { html: 'text/html', json: 'application/json', mp3: 'audio/mpeg', lisp: 'text/plain' }; +createServer((request, response) => { + const name = new URL(request.url, 'http://localhost').pathname.slice(1) || 'index.html'; + const file = files.get(name); + try { + if (!file) { response.writeHead(404); return response.end(); } + const size = statSync(file).size; + const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/); + let start = range ? Number(range[1]) : 0, end = range?.[2] ? Math.min(Number(range[2]), size - 1) : size - 1; + if (request.headers.range && (!range || start > end || start >= size)) { + response.writeHead(416, { 'Content-Range': `bytes */${size}` }); return response.end(); + } + response.writeHead(range ? 206 : 200, { 'Content-Type': mime[name.split('.').at(-1)], 'Content-Length': end - start + 1, + 'Accept-Ranges': 'bytes', ...(range ? { 'Content-Range': `bytes ${start}-${end}/${size}` } : {}) }); + if (request.method === 'HEAD') response.end(); + else createReadStream(file, { start, end }).pipe(response); + } catch { response.writeHead(404); response.end(); } +}).listen(port, '127.0.0.1', () => console.log(`http://127.0.0.1:${port}`)); diff --git a/marketing/podcast/bin/read-along.mjs b/marketing/podcast/bin/read-along.mjs new file mode 100644 index 0000000000..eedceaa76a --- /dev/null +++ b/marketing/podcast/bin/read-along.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// Build readalong data from an existing master and measured Whisper word JSON. +// node read-along.mjs daily.md words.json 12384 episode.mp3 episode.json +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { alignWords } from '../lib/read-along.mjs'; +import { richDailyPiece, readRichDailySource } from '../lib/daily-richtext.mjs'; +import { KidLisp } from '../../../system/public/aesthetic.computer/lib/kidlisp.mjs'; +import { richNode, richLink } from '../../../system/public/aesthetic.computer/lib/rich-text.mjs'; +const [markdown, timingFile, offset, audioFile, output] = process.argv.slice(2); +if (!output || !Number.isFinite(Number(offset))) throw new Error('usage: read-along.mjs daily.md whisper-words.json offsetMs audio.mp3 output.json'); +const md = readFileSync(markdown, 'utf8'); +const title = md.match(/^title: (.+)$/m)?.[1]; +const body = md.split(/\n---\n/).slice(1).join('\n---\n').trim(); +if (!title || !body) throw new Error('Expected daily title and prose'); +const source = richDailyPiece({ title, body }); +if (readRichDailySource(source).body !== body) throw new Error('Daily prose did not round-trip'); +const ast = new KidLisp().parse(source).find(form => form[0] === 'flow'); +const literal = value => Array.isArray(value) ? richLink(literal(value[1]), literal(value[2])) : value.slice(1,-1); +const blocks = ast.slice(2).map(form => richNode('paragraph', form.slice(1).map(literal))); +const result = alignWords(body, JSON.parse(readFileSync(timingFile)).transcription, Number(offset)); +const audio = { url: 'episode.mp3', sha256: createHash('sha256').update(readFileSync(audioFile)).digest('hex') }; +writeFileSync(output, JSON.stringify({ version: 1, title, body, blocks, audio, ...result }, null, 2) + '\n'); +console.log(JSON.stringify(result.alignment)); diff --git a/marketing/podcast/lib/artifact.mjs b/marketing/podcast/lib/artifact.mjs index 4a68eaa716..9a697a8034 100644 --- a/marketing/podcast/lib/artifact.mjs +++ b/marketing/podcast/lib/artifact.mjs @@ -8,10 +8,70 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { gunzipSync } from "node:zlib"; +import { createHash } from "node:crypto"; +import AdmZip from "adm-zip"; +import { crawlLayout } from "./crawl.mjs"; +import { readRichDailySource } from "./daily-richtext.mjs"; import { teiaPackage, TEIA_FORMAT } from "../../../system/backend/whistlegraph-teia.mjs"; export { teiaPackage as crawlPackage, TEIA_FORMAT }; +// Preserve canonical prose outside the compressed canvas runtime. These +// files can be read without JavaScript, OCR, fonts, or a running browser. +export function contentFiles({ title, body, date, code, source }) { + if (![title, body, date, code, source].every(v => typeof v === "string" && v.trim())) { + throw new Error("Daily content needs a title, body, date, code and source"); + } + const prose = lines => lines.join(" ").replace(/\s+/g, " ").trim(); + if (/^\(flow\b/m.test(source)) { + const document = readRichDailySource(source); + if (document.title !== title || prose([document.body]) !== prose([body])) throw new Error("Rich text differs from the episode"); + } else { + const written = [...source.matchAll(/\(write ("(?:[^"\\]|\\.)*")/g)].map(m => JSON.parse(m[1])); + const intended = crawlLayout({ title, body, date }).lines.filter(Boolean).map(l => l.text.replace(/\\/g, "")); + if (prose(written) !== prose(intended)) throw new Error("Crawl text differs from the episode"); + } + const content = { title, body, date, code, source, bodySha256: createHash("sha256").update(body).digest("hex") }; + return [ + { name: "transcript.txt", mime: "text/plain", content: Buffer.from(`${title}\n\n${body}\n`) }, + { name: "content.json", mime: "application/json", content: Buffer.from(JSON.stringify(content, null, 2) + "\n") }, + { name: "source.lisp", mime: "text/plain", content: Buffer.from(source) }, + ]; +} + +export function dailyPackage(html, preview, episode) { + const prose = contentFiles(episode); + const structured = JSON.stringify({ "@context": "https://schema.org", "@type": "CreativeWork", name: episode.title, text: episode.body, dateCreated: episode.date }).replace(/]*)?>/i, head => `${head}\n\n`); + const base = teiaPackage(readable, preview); + const files = [...base.files, ...prose]; + const zip = new AdmZip(); + for (const file of files) zip.addFile(file.name, file.content); + return { files, zip: zip.toBuffer() }; +} + +// Read the actual pinned metadata and directory, including resumed receipts. +// A local successful pack is insufficient evidence for an immutable mint. +export async function checkPublishedContent({ metadataUri, metadata, files = [], gateway = "https://ipfs.aesthetic.computer", fetch = globalThis.fetch }) { + const read = async (uri, name = "") => { + if (!/^ipfs:\/\/[A-Za-z0-9]+$/.test(uri || "")) throw new Error("Expected an IPFS root URI"); + const response = await fetch(`${gateway.replace(/\/$/, "")}/ipfs/${uri.slice(7)}${name ? `/${name}` : ""}`, { signal: AbortSignal.timeout(30000) }); + if (!response.ok) throw new Error(`IPFS read-back failed (${response.status}): ${name || "metadata"}`); + return Buffer.from(await response.arrayBuffer()); + }; + const actual = JSON.parse((await read(metadataUri)).toString("utf8")); + for (const key of ["name", "description", "artifactUri", "displayUri", "thumbnailUri"]) { + if (actual[key] !== metadata[key]) throw new Error(`Pinned metadata differs: ${key}`); + } + const hashes = {}; + for (const file of files) { + const bytes = await read(actual.artifactUri, file.name); + if (!bytes.equals(file.content)) throw new Error(`Pinned content differs: ${file.name}`); + hashes[file.name] = createHash("sha256").update(bytes).digest("hex"); + } + return { checkedAt: new Date().toISOString(), metadataUri, files: hashes }; +} + const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "..", "..", ".."); @@ -77,6 +137,8 @@ export function checkBundle(html, { code, source }) { let vfs; try { vfs = JSON.parse(inner.match(/window\.VFS = (\{.*?\});\n/s)[1].replace(/<\\\/script>/g, "")); } catch { return fail("no readable VFS"); } for (const f of REQUIRED_FILES) if (!vfs[f]) problems.push(`VFS lacks ${f}`); + if (vfs[`disks/${code}.lisp`]?.content !== source) problems.push("VFS crawl source differs from the stored $code"); + if (/^\(flow\b/m.test(source) && (!vfs["lib/rich-text.mjs"] || !/flow:\s*\(/.test(vfs["lib/kidlisp.mjs"]?.content || ""))) problems.push("VFS lacks rich-text support"); if (!Object.keys(vfs).some((f) => f.startsWith("disks/drawings/font_1/"))) problems.push("VFS lacks the font_1 glyphs"); const kidlisp = vfs["lib/kidlisp.mjs"]?.content || ""; for (const [fix, mark] of Object.entries(RUNTIME_MARKS)) if (!mark.test(kidlisp)) problems.push(`runtime lacks the ${fix}`); diff --git a/marketing/podcast/lib/crawl.mjs b/marketing/podcast/lib/crawl.mjs index 60551bc378..fc61172086 100644 --- a/marketing/podcast/lib/crawl.mjs +++ b/marketing/podcast/lib/crawl.mjs @@ -1,10 +1,8 @@ // crawl.mjs β€” the daily token's image: the episode as a Star Wars crawl. // -// Drawn here in node rather than on the oven, which repaints a page this -// heavy only every few seconds while it captures. The glyphs are AC's own -// font_1 (6Γ—10 vector drawings), rasterized once and scaled with area -// coverage, so a line sharpens as it nears and fades as it recedes. The -// frames loop seamlessly by construction: frame N would equal frame 0. +// AC's font_1 glyphs stay at whole-pixel sizes and scroll at a constant +// scale. Fractional shrinking loses strokes on the default low-res canvas. +// Frames loop seamlessly by construction: frame N would equal frame 0. // // The same geometry, driven by (clock), is what crawlPiece() writes as the // token's live KidLisp $code. @@ -77,7 +75,7 @@ const PALETTES = [ ]; const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); -export function crawlLayout({ title, body, date, size = 512, cols = 30, kmin = 0.42 }) { +export function crawlLayout({ title, body, date, size = 512, cols = 20, kmin = 0.42 }) { const lines = []; for (const t of wrap(fontText(title).toUpperCase(), cols)) lines.push({ text: t, title: true }); lines.push(null); @@ -150,11 +148,14 @@ function drawLine(buf, g, text, color, yBase, s) { export function crawlFrame(g, t) { const buf = new Float32Array(g.W * g.H * 3); for (const { x, y, c } of g.stars) buf.set(c, (y * g.W + x) * 3); - const zNow = t * g.TRAVEL; + const cols = Math.max(...g.lines.filter(Boolean).map(l => l.text.length)); + const s = Math.max(1, Math.floor(.92 * g.W / (cols * GW))); + const lineHeight = 14 * s; + const offset = t * (g.lines.length * lineHeight + g.H); g.lines.forEach((l, i) => { if (!l) return; - const p = place(g, i, zNow); - if (p) drawLine(buf, g, l.text, l.color, p.y, p.s); + const y = 4 * s + i * lineHeight - offset; + if (y + GH * s > 0 && y < g.H) drawLine(buf, g, l.text, l.title ? l.color : [235, 245, 255], y, s); }); return Uint8Array.from(buf, (v) => Math.round(v)); } @@ -195,22 +196,20 @@ export function crawlPiece(g, { periodMs = readablePeriod(g) } = {}) { const kl = (s) => s.replace(/\\/g, "").replace(/"/g, '\\"'); const f = (v) => +v.toFixed(4); const cols = Math.max(...g.lines.map((l) => (l ? l.text.length : 0))); - // The same plane as the GIF, in fractions of the screen: the horizon at - // HOR/H of the height, A/H of it from the near line to the vanishing depth. - const hor = f(g.HOR / g.H), a = f(g.A / g.H); - const s0 = `(min (/ (* .9 w) ${cols * GW}) (/ h ${f(g.H / g.S0)}))`; - const zNow = `(* (/ (mod (clock) ${periodMs}) ${periodMs}) ${g.TRAVEL.toFixed(1)})`; - const src = ["(wipe black)"]; + // AC's default canvas is deliberately low resolution. Fractional font + // scales discard glyph pixels, especially in short, wide tiled windows. + // Keep whole glyph pixels, size from width, and scroll without recession. + const fit = `(/ (* .92 w) ${cols * GW})`; + const s = `(max 1 (- ${fit} (mod ${fit} 1)))`; + const offset = `(* (/ (mod (- (clock) crawl_start) ${periodMs}) ${periodMs}) (+ (* ${g.lines.length * 14} ${s}) h))`; + const src = ["(wipe black)", "(def crawl_start (clock))"]; for (const { x, y, c } of g.stars) src.push(`(ink ${c.join(" ")})(plot (* w ${f(x / g.W)}) (* h ${f(y / g.H)}))`); g.lines.forEach((l, i) => { if (!l) return; const text = kl(l.text); - const z = `(- ${zNow} ${(i * g.L).toFixed(2)})`; - const k = `(/ ${g.D} (+ ${g.D} (max ${z} 0)))`; - const y = `(- (- h (* h ${a} (- 1 ${k}))) (* (max (- ${z} ${g.GONE.toFixed(1)}) 0) h))`; - const s = `(* ${s0} ${k} ${k})`; - const x = `(- (/ w 2) (* ${text.length * GW / 2} ${s}))`; - src.push(`(ink ${l.color.join(" ")})`, `(write "${text}" ${x} ${y} nil ${s})`); + const y = `(- (* ${4 + i * 14} ${s}) ${offset})`; + const x = `(/ (- w (* ${cols * GW} ${s})) 2)`; + src.push(`(ink ${(l.title ? l.color : [235, 245, 255]).join(" ")})`, `(write "${text}" ${x} ${y} nil ${s})`); }); return src.join("\n"); } diff --git a/marketing/podcast/lib/daily-richtext.mjs b/marketing/podcast/lib/daily-richtext.mjs new file mode 100644 index 0000000000..c25b7881d9 --- /dev/null +++ b/marketing/podcast/lib/daily-richtext.mjs @@ -0,0 +1,59 @@ +// Explicit destinations: names stay prose; only recognized subjects become links. +import { KidLisp } from "../../../system/public/aesthetic.computer/lib/kidlisp.mjs"; +import { richBlocks, richNode, richLink } from "../../../system/public/aesthetic.computer/lib/rich-text.mjs"; +export const DAILY_LINKS = { + Aesel: "https://aesel.app", + Slab: "https://github.com/whistlegraph/aesthetic-computer/tree/main/slab", + Loopboy: "https://github.com/whistlegraph/aesthetic-computer/tree/main/slab", + Whistlegraph: "https://aesthetic.computer/whistlegraph", + Give: "https://give.aesthetic.computer", + KidLisp: "https://kidlisp.com", + "Aesthetic Computer": "https://aesthetic.computer", + GitHub: "https://github.com/whistlegraph/aesthetic-computer", + Tangled: "https://tangled.org/aesthetic.computer/core", +}; +// KidLisp resolves escaped quotes but otherwise keeps backslashes literal. +const quote = value => '"' + value.replace(/"/g, '\\"').replace(/\r?\n/g, " ") + '"'; + +export function linkedParagraph(text, destinations = DAILY_LINKS) { + const names = Object.keys(destinations).sort((a, b) => b.length - a.length); + if (!names.length) return `(paragraph ${quote(text)})`; + const pattern = new RegExp(`\\b(${names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "g"); + const parts = []; + let cursor = 0; + for (const match of text.matchAll(pattern)) { + if (match.index > cursor) parts.push(quote(text.slice(cursor, match.index))); + parts.push(`(link ${quote(match[0])} ${quote(destinations[match[0]])})`); + cursor = match.index + match[0].length; + } + if (cursor < text.length) parts.push(quote(text.slice(cursor))); + return `(paragraph ${parts.join(" ")})`; +} + +export function richDailyPiece({ title, body, destinations }) { + return `(wipe black)\n(flow\n (heading ${quote(title)})\n ${body.split(/\n\s*\n/).map(paragraph => linkedParagraph(paragraph, destinations)).join("\n ")}\n)`; +} + +// Inspect literal data, without executing a piece or following its links. +export function readRichDailySource(source) { + const lisp = new KidLisp(); + const ast = lisp.parse(source); + const flows = ast.filter(form => form[0] === "flow"); + if (lisp.lastValidationErrors || flows.length !== 1) throw new Error("Expected one literal rich-text flow"); + const literal = value => { + if (Array.isArray(value)) { + if (value[0] !== "link" || value.length !== 3) throw new Error("Expected a literal link"); + return richLink(literal(value[1]), literal(value[2])); + } + if (typeof value !== "string" || !value.startsWith('"')) throw new Error("Expected literal prose"); + // KidLisp's tokenizer has already resolved escaped quotes. + return value.slice(1, -1); + }; + const blocks = richBlocks(flows[0].slice(1).map(form => { + if (!Array.isArray(form) || !["heading", "paragraph"].includes(form[0])) throw new Error("Expected a heading or paragraph"); + return richNode(form[0], form.slice(1).map(literal)); + })); + const headings = blocks.filter(block => block.kind === "heading"); + if (headings.length !== 1) throw new Error("Expected one daily title"); + return { title: headings[0].text, body: blocks.filter(block => block.kind === "paragraph").map(block => block.text).join("\n\n") }; +} diff --git a/marketing/podcast/lib/read-along.mjs b/marketing/podcast/lib/read-along.mjs new file mode 100644 index 0000000000..082b4a5cd4 --- /dev/null +++ b/marketing/podcast/lib/read-along.mjs @@ -0,0 +1,36 @@ +// Align measured ASR word boundaries to canonical prose; missing words stay untimed. +export function alignWords(body, transcription, offsetMs = 0) { + if (!Number.isFinite(offsetMs) || offsetMs < 0) throw new Error("Expected a nonnegative narration offset"); + const canonical = [...body.matchAll(/[\p{L}\p{N}]+(?:['’\-][\p{L}\p{N}]+)*/gu)].map(m => ({ text: m[0], start: m.index, end: m.index + m[0].length })); + const heard = transcription.filter(w => w.text.trim() && w.offsets.to > w.offsets.from); + const normalize = s => s.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); + const n = canonical.length, m = heard.length; + const costs = Array.from({ length: n + 1 }, () => new Float64Array(m + 1)); + const moves = Array.from({ length: n + 1 }, () => []); + for (let i = 0; i <= n; i++) costs[i][0] = i; + for (let j = 0; j <= m; j++) costs[0][j] = j; + for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++) { + const choices = [ + [costs[i - 1][j - 1] + (normalize(canonical[i - 1].text) === normalize(heard[j - 1].text) ? 0 : 1), 'pair'], + [costs[i - 1][j] + 1, 'missing'], [costs[i][j - 1] + 1, 'extra'], + ]; + if (j > 1 && normalize(canonical[i - 1].text) === normalize(heard[j - 2].text + heard[j - 1].text)) choices.push([costs[i - 1][j - 2], 'merge']); + choices.sort((a, b) => a[0] - b[0]); + [costs[i][j], moves[i][j]] = choices[0]; + } + const words = []; + let i = n, j = m; + while (i || j) { + const move = i && j ? moves[i][j] : i ? 'missing' : 'extra'; + if (move === 'pair' || move === 'merge') { + const count = move === 'merge' ? 2 : 1; + const original = canonical[i - 1], first = heard[j - count], last = heard[j - 1]; + words.push({ ...original, fromMs: offsetMs + first.offsets.from, toMs: offsetMs + last.offsets.to, + match: normalize(original.text) === normalize(heard.slice(j - count, j).map(w => w.text).join('')) ? 'exact' : 'substitution' }); + i--; j -= count; + } else if (move === 'missing') i--; + else j--; + } + words.reverse(); + return { words, alignment: { method: 'whisper-word-boundaries + text edit alignment', canonicalWords: n, timedWords: words.length, missingWords: n - words.length, substitutions: words.filter(w => w.match === 'substitution').length } }; +} diff --git a/marketing/podcast/test/daily-artifact.test.mjs b/marketing/podcast/test/daily-artifact.test.mjs index 2841760cd1..4908dee176 100644 --- a/marketing/podcast/test/daily-artifact.test.mjs +++ b/marketing/podcast/test/daily-artifact.test.mjs @@ -4,7 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { gunzipSync, gzipSync } from "node:zlib"; -import { artifactMode, tokenMetadata, crawlBundle, checkBundle, crawlPackage, checkPinnedArtifact, CRAWL_STYLE } from "../lib/artifact.mjs"; +import { artifactMode, tokenMetadata, crawlBundle, checkBundle, crawlPackage, dailyPackage, contentFiles, checkPublishedContent, checkPinnedArtifact, CRAWL_STYLE } from "../lib/artifact.mjs"; import AdmZip from "adm-zip"; import { crawlLayout, crawlPiece, readablePeriod } from "../lib/crawl.mjs"; @@ -81,13 +81,45 @@ test("gif metadata is exactly what the daily minted before the switch", () => { const layout = crawlLayout({ title: episode.title, body: episode.body, date: episode.date }); const source = crawlPiece(layout); +test("canonical prose remains readable without the canvas, including unsupported glyphs", () => { + const e = { ...episode, body: `${episode.body}\n\nCafΓ©, ' }; + hostile.source = crawlPiece(crawlLayout(hostile)); + const safe = new AdmZip(dailyPackage(outer, { gif: Buffer.from('gif'), thumbnail: Buffer.from('png'), frames: 2 }, hostile).zip).readAsText('index.html'); + assert.equal(JSON.parse(safe.match(/