From c4332363a953f7fe5a5f2db54f00a82a754518de Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Sun, 6 Sep 2026 21:54:09 +0000 Subject: [PATCH] docs: raise the format specification to revision 0.9 and land the reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision 0.9 adds section 20, the cadence and event subsystems contract, and carries two corrections the implementation forced. Section 6.1 now states that a duration is the authored literal or a non-negative finite number already in milliseconds, since a DurationSpec may be the resolved output of a ValueSpec or a bounded TimeSpec, with the one documented exception of an automation track's `at`, which 19.1 keeps literal-only so that point ordering stays decidable at import. Section 20.11 documents the rejection of an undeclared input name in an event action's `with` map as ERR_UNKNOWN_FIELD — the section's own convention for that shape of error, replacing an invented code that appeared nowhere in the registry. The review record is committed with the code it describes: the two code triages that found these defects, the reconciliation plan that sequenced the fixes, and a follow-up debt record listing what was deliberately left open — the unchecked JSON Schema artifact, degenerate path arcs, post-effect transient allocation, the window-traffic fixture's per-copy wrap bounds, and the unstated `ownership: "persistent"` value on a sound action. None of the five blocks phase 6; all five are written down rather than dropped. Devlog entries are backfilled for the two milestones that had none: phase 3c slice 2, the audio lifecycle and voice ceilings, and slice 4d, the renderer core. The implementation status summary now reflects the reconciled state rather than the in-flight one. 231 tests pass. tools/verify-spec-contract.py reports 46 declared diagnostic codes with every used code resolving and its two long-standing unresolved cross-references unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy --- .../contents.lr | 49 + .../frame-plans-before-pixels/contents.lr | 47 + XZBT.html | 3413 ++++++++++++++++- docs/IMPLEMENTATION_STATUS.md | 24 +- docs/XZBT_0-1_Format_Specification.md | 314 +- docs/XZBT_0-1_Implementation_Plan.md | 10 +- package.json | 5 +- reviews/.completed-artifacts/00-triage.md | 408 ++ reviews/.completed-artifacts/01-grok-4.6.md | 109 + reviews/.completed-artifacts/01-triage.md | 397 ++ .../02-abacus-ai-agent.md | 213 + .../02-application-code-triage.md | 268 ++ reviews/.completed-artifacts/02-kimi-k3.md | 110 + .../03-deepseek-v4-flash.md | 135 + reviews/.completed-artifacts/04-fix-log.md | 64 + .../.completed-artifacts/05-visual-fix-log.md | 110 + ...cus-ai-agent-20260905-214821-deepseekv4.md | 486 +++ ...abacus-ai-agent-20260905-214822-grok4-6.md | 491 +++ ...review-abacus-ai-agent-20260906-kimi-k3.md | 154 + reviews/00-pre-phase6-reconciliation.md | 72 + reviews/02-triage.md | 386 ++ reviews/03-phase5-triage.md | 116 + reviews/04-followup-debt.md | 35 + ...026-09-06-unknown-model-173154-a7c3e91b.md | 61 + ...026-09-06-unknown-model-180353-6b68e324.md | 93 + ...026-09-06-unknown-model-183137-f3a91c2d.md | 98 + ...026-09-06-unknown-model-173816-c4f8a2d1.md | 87 + schema/xzbt-0.1.schema.json | 240 +- test/phase4-visual-contract.test.mjs | 23 +- tools/build-xzbt.mjs | 11 + 30 files changed, 7892 insertions(+), 137 deletions(-) create mode 100644 .labyricorn/devlog/audio-lifecycle-and-voice-ceilings/contents.lr create mode 100644 .labyricorn/devlog/frame-plans-before-pixels/contents.lr create mode 100644 reviews/.completed-artifacts/00-triage.md create mode 100644 reviews/.completed-artifacts/01-grok-4.6.md create mode 100644 reviews/.completed-artifacts/01-triage.md create mode 100644 reviews/.completed-artifacts/02-abacus-ai-agent.md create mode 100644 reviews/.completed-artifacts/02-application-code-triage.md create mode 100644 reviews/.completed-artifacts/02-kimi-k3.md create mode 100644 reviews/.completed-artifacts/03-deepseek-v4-flash.md create mode 100644 reviews/.completed-artifacts/04-fix-log.md create mode 100644 reviews/.completed-artifacts/05-visual-fix-log.md create mode 100644 reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214821-deepseekv4.md create mode 100644 reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214822-grok4-6.md create mode 100644 reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260906-kimi-k3.md create mode 100644 reviews/00-pre-phase6-reconciliation.md create mode 100644 reviews/02-triage.md create mode 100644 reviews/03-phase5-triage.md create mode 100644 reviews/04-followup-debt.md create mode 100644 reviews/review-2026-09-06-unknown-model-173154-a7c3e91b.md create mode 100644 reviews/review-2026-09-06-unknown-model-180353-6b68e324.md create mode 100644 reviews/review-2026-09-06-unknown-model-183137-f3a91c2d.md create mode 100644 reviews/spec-review-2026-09-06-unknown-model-173816-c4f8a2d1.md diff --git a/.labyricorn/devlog/audio-lifecycle-and-voice-ceilings/contents.lr b/.labyricorn/devlog/audio-lifecycle-and-voice-ceilings/contents.lr new file mode 100644 index 0000000..5f2b7b3 --- /dev/null +++ b/.labyricorn/devlog/audio-lifecycle-and-voice-ceilings/contents.lr @@ -0,0 +1,49 @@ +_model: devlog-entry +--- +schema_version: 1 +--- +title: Giving every voice an ending +--- +date: 2026-09-05 +--- +author: Labyricorn +--- +summary: The second slice of the audio engine is the one about endings — a seven-state lifecycle, an engine-owned release gain no author can reach, a proof that a one-shot sound actually stops, and a four-step eviction order for when the voice ceiling is reached. +--- +tags: Audio, Lifecycle, Limits, Runtime, Phase 3 +--- +source_commit: 9ae2c760754477df86b01cede0dd2aaa28963921 +--- +body: + +The [second slice of Phase 3c](https://git.labyricorn.com/Labyricorn/XZBT/commit/9ae2c760754477df86b01cede0dd2aaa28963921) is about endings. The first slice could build a sound graph and start it. This one has to answer what happens after that: when a sound is finished, who says so, what it costs while it waits to be cleaned up, and what the runtime does when more sounds are asked for than it can carry. + +## A state machine, and the transitions that are refused + +A voice moves through seven states — `CREATED`, `SCHEDULED`, `ACTIVE`, `RELEASING`, `FINISHED`, `DISPOSED`, and `FAILED`. Writing them down is the easy half. The half that matters is that every transition *not* in the permitted set raises `ERR_RUNTIME_FAULT` rather than being quietly tolerated, and that calling `stop()` on a voice that has already ended is a verified no-op rather than an error. A runtime that shrugs at an illegal transition will eventually let a disposed voice be resurrected by a stale reference, and the bug that produces is unreproducible. + +## The gain the author cannot reach + +Release is implemented as a gain node the engine owns, sitting between a sound graph's `output` and its destination bus. It starts at 1.0, and entering `RELEASING` ramps it linearly to zero over the release duration. + +The important properties are negative ones: it cannot be bypassed, it cannot be addressed from a document, and it does not count against an author's node limits. Release is not an effect an author composes with — it is the runtime's guarantee that nothing stops with a click. An author who wants a longer fade declares `release` on the recipe, between `0ms` and `10s`, defaulting to `50ms`. Declaring it inside a component definition is `ERR_UNKNOWN_FIELD`, because release belongs to a sound instance and not to any part of one. + +## Proving a one-shot ends + +A `oneshot` sound has to be *determinably* finite, and the runtime computes that bound rather than guessing at it: a longest-path traversal over the expanded route graph, summing each node's documented contribution — an impulse's duration, a delay's decay bound, a reverb's predelay plus decay, a resonator's longest mode ring-down, an inlined component's own bound — plus the recipe's release. + +The consequence is a rejection an author can act on. An oscillator or a noise source on an audible path in a one-shot sound has no bound, so the document fails validation with `ERR_INDETERMINATE_ONESHOT`. The remedy is to declare the sound `continuous` and stop it explicitly, which is what the author actually meant. Catching this at import is the whole point: the alternative is a "one-shot" that runs until the exhibit closes. + +## What happens at the ceiling + +One-shot and continuous voices have independent ceilings — 64 and 16 — and a voice occupies its slot from `CREATED` all the way to `DISPOSED`, not merely while it is audible. A `FINISHED` voice still costs a slot until it is cleaned up, which is the honest accounting. + +When a request arrives at the ceiling, eviction runs in a strict order: dispose the oldest `FINISHED` instance; failing that, advance the oldest `RELEASING` instance to immediate completion and dispose it; failing that, and only for one-shots, evict the oldest `ACTIVE` one-shot by starting its release; failing that, refuse the request. Every eviction and every refusal emits `WARN_VOICE_LIMIT`. + +Two rules in that list are deliberate and worth stating plainly. Nothing is ever hard-stopped — step 2 completes a release rather than dropping the node mid-sample, because a drop is a click, and a click is a defect. And the two pools never evict across each other: a burst of one-shots cannot silence the continuous bed an exhibit is built on. A refused one-shot is a sound the visitor does not hear; an evicted ambience is an exhibit that has visibly broken. + +## The reconciliation that came first + +Five inconsistencies in Section 16 were found and corrected *before* this code was written, in `e5ed468` — among them an eviction step that contradicted the no-hard-stop invariant, a recipe field table that had omitted `release` entirely, and an author remedy the contract elsewhere forbids. Implementing against a section that still disagrees with itself produces code that encodes the disagreement, and the disagreement then becomes very hard to see. + +All 67 tests pass, and two clean builds are byte-identical. Traces 7, 9, and 10 of 16.11 are automated. What remains open is the part no test can close: no sound has yet been heard from a production build. diff --git a/.labyricorn/devlog/frame-plans-before-pixels/contents.lr b/.labyricorn/devlog/frame-plans-before-pixels/contents.lr new file mode 100644 index 0000000..f6816c3 --- /dev/null +++ b/.labyricorn/devlog/frame-plans-before-pixels/contents.lr @@ -0,0 +1,47 @@ +_model: devlog-entry +--- +schema_version: 1 +--- +title: Frame plans before pixels +--- +date: 2026-09-06 +--- +author: Labyricorn +--- +summary: The first visual code that draws anything. The renderer is split in two so that a frame can be checked numerically without a display — an engine that emits a frame plan in device pixels, and a backend that turns a plan into drawing calls. Implementing it corrected two rules in the contract. +--- +tags: Visuals, Renderer, Testing, Runtime, Phase 4 +--- +source_commit: 0af58da89dd6095fa9ca3ed546a2e48d7c7971b7 +--- +body: + +Slice 4d is [the first visual code that draws](https://git.labyricorn.com/Labyricorn/XZBT/commit/0af58da89dd6095fa9ca3ed546a2e48d7c7971b7). Sections 17.4 through 17.14 of the contract are implemented, and the standalone artifact now has a stage canvas that renders a declared scene. + +## Why the renderer is two pieces + +The required traces of 17.16 ask things like: this scene point lands at this display point; this object's perspective factor is this number; this object's fog fraction is that one; these objects sort in this order. None of those questions can be answered by looking at a canvas. A canvas has pixels. The answers live one step earlier. + +So `visual-engine.js` resolves an exhibit's objects once at their instantiation boundary, composes the scene-to-device chain, sorts by depth, applies fog and the compositing order, and emits a **frame plan** in device pixels. `visual-canvas2d.js` turns a plan into drawing calls and does nothing else. The split is not tidiness — it is what makes the traces runnable without a display, which is what those traces require. A frame plan carries the numeric oracles; the backend carries the pixels. + +## Exactness as a contract property + +Colors are parsed into exact components, and a color outside the four documented forms is `ERR_TYPE_MISMATCH` rather than a best-effort interpretation. That strictness has a specific payoff: per-object fog is arithmetic on those components, so two conforming renderers must produce the same fogged color. A permissive parser would make fog renderer-dependent, which is a thing you cannot un-promise later. + +The depth rules are similarly narrow. Sortable units order farthest-first with the documented tie-break; a point's own `z` projects that point without making it a sortable unit; an object at or behind the eye is culled with no diagnostic at all, because that is a scene an author composed, not an error. + +## Two rules the implementation corrected + +Writing the code found two defects in the prose I had written. + +The first is buffer accounting. Section 17.12 originally counted compositing buffers as *concurrently live*, which sounded right and was wrong: group nesting is already capped at 8, so that reading made 19.5's farthest-first shedding rule nearly unreachable. It is now **16 allocations per frame** — the per-frame "pass budget" reading that 17.5 had been using since it first appeared. The shedding rule now actually engages, and the sixteen grants go nearest-first so refusals fall on the far content, each one diagnosed under the 19.5 cadence rather than dropped in silence. + +The second is arc sweep. The prose always described `350°` to `10°` clockwise as a 20-degree sweep, but the rule as written normalized the wrong quantity and produced 340 degrees the long way round. It now normalizes the *signed* delta, and the bound is checked before normalization rather than after. + +Both corrections came from implementing the section rather than from re-reading it. That is an argument for writing the contract first *and* for not treating it as finished until something has been built against it. + +## What this slice does not do + +`visual-validation.js` gains the whole object tree — fifteen object types and their declared fields, no `id` and no `layer` on an object, a mask only on a group and only naming its own child, path legality, spline modes and point counts, gradient stop order, and every authoring limit of 19.5 that applies to a drawn object. + +Twenty-two new tests cover traces 1 through 13 of 17.16 plus buffer shedding and the backend's call order; the suite goes from 131 to 153. No procedural system runs, no automation track advances, and no post-effect executes — 4e and 4f own those. Traces 15 and 16 stay user-observed, because what they check is whether it looks right, and nothing automated can answer that. diff --git a/XZBT.html b/XZBT.html index 52fa0f5..b8478ae 100644 --- a/XZBT.html +++ b/XZBT.html @@ -187,7 +187,8 @@ const RNG_DOMAINS = Object.freeze([ 'scenario', 'visual', 'sound', - 'manual-sample' + 'manual-sample', + 'sample' ]); const DATABASE = Object.freeze({ @@ -400,7 +401,13 @@ function easingValue(name, progress) { function lerp(from, to, amount) { return from + ((to - from) * amount); } function parseDuration(value, path = '$') { - if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a single-unit string.', path); + // C5 (6.1): a value that is already a number is a non-negative count of + // milliseconds and passes through. The literal form remains the authored one. + if (typeof value === 'number') { + if (!Number.isFinite(value) || value < 0) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration ${value} is not a non-negative finite number of milliseconds.`, path); + return value; + } + if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a duration literal or a finite number.', path); const match = DURATION_PATTERN.exec(value); if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path); const scalar = Number(match[1]); @@ -986,6 +993,15 @@ function interpolateAutomation(curve, v0, v1, t) { function automationValueAt(track, milliseconds) { const points = track.points; + if (track.loop) { + const period = points.at(-1).at; + const pingPong = track.loop.mode === 'ping-pong'; + const cycle = period * (pingPong ? 2 : 1); + const count = track.loop.count ?? 'infinite'; + if (count !== 'infinite' && milliseconds >= cycle * count) return pingPong ? points[0].value : points.at(-1).value; + milliseconds = Math.max(0, milliseconds) % cycle; + if (pingPong && milliseconds > period) milliseconds = cycle - milliseconds; + } if (milliseconds <= points[0].at) return points[0].value; for (let index = 1; index < points.length; index += 1) { const left = points[index - 1], right = points[index]; @@ -2365,8 +2381,8 @@ class VisualDiagnosticCadence { shed(code, subject, message, logicalMilliseconds, context = {}) { const key = code + ' ' + subject; const state = this.entry(key); + if (!state.seenThisTick) state.consecutive += 1; state.seenThisTick = true; - state.consecutive += 1; if (logicalMilliseconds - state.lastRaisedAt < DIAGNOSTIC_CADENCE_MS) return null; state.lastRaisedAt = logicalMilliseconds; const sustained = state.consecutive >= SUSTAINED_TICKS; @@ -2386,6 +2402,15 @@ class VisualDiagnosticCadence { state.lastRaisedAt = -Infinity; } } + // P3.1: Cap raised list to prevent unbounded growth over long sessions. + if (this.raised.length > 1024) this.raised.splice(0, this.raised.length - 1024); + } + + /** P3.1: Snapshot raised diagnostics and clear the list to prevent unbounded growth. */ + snapshot() { + const copy = this.raised.slice(); + this.raised.length = 0; + return copy; } // A capability warning, not a resource one: 17.12's conic fallback and @@ -2738,10 +2763,11 @@ function linearSpline(points, closed) { /** Endpoint-parameterized elliptical arc, converted to center parameterization. */ function endpointArc(current, from, command, path) { const [rxRaw, ryRaw] = radiusPair(command.radius, path); - const rx = Math.abs(rxRaw); - const ry = Math.abs(ryRaw); + if (rxRaw <= 0 || ryRaw <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path); const to = point(command.to, path); - if (rx <= 0 || ry <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path); + if (from[0] === to[0] && from[1] === to[1]) return; + const rx = rxRaw; + const ry = ryRaw; const phi = (command.rotation ?? 0) * DEGREES_TO_RADIANS; const cosPhi = Math.cos(phi); const sinPhi = Math.sin(phi); @@ -2940,6 +2966,8 @@ function primitiveSubpaths(object, path = '$') { hole.closed = true; return inner > 0 ? [outer, hole] : [outer]; } + // C16: §17.9: sweep === 0 draws nothing and raises no diagnostic. + if (sweep === 0) return []; const endAngle = startDegrees + sweep * sign; const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]); arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign); @@ -2989,6 +3017,1366 @@ function boundingBox(subpaths) { return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } +/* src/runtime/visual-noise.js */ +// The normative coherent noise of 18.7. +// +// Reproducibility (9.3) promises identical procedural decisions, not identical +// pixels — but a field that drove motion differently on two conforming +// renderers would make a fixture untestable, so the function is fixed as an +// algorithm rather than described. Every constant, the draw count of the +// shuffle, the octave normalization, and the three scalar-to-vector modes are +// exactly as 18.7 states them. + +/** The twelve edge-midpoint vectors of a cube, in this order, unnormalized. */ +const GRADIENTS = Object.freeze([ + [1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], + [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1], + [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1] +].map(Object.freeze)); + +/** The central-difference step for the derivative modes, in noise space. */ +const DERIVATIVE_STEP = 1e-3; + +function fade(a) { return a * a * a * (a * (a * 6 - 15) + 10); } +function mix(s, a, b) { return a + s * (b - a); } + +/** + * 18.7: a 256-entry permutation table shuffled by Fisher-Yates from the owning + * stream, consuming exactly 255 samples — one per swap, not one per entry — and + * duplicated into a 512-entry tail so the lattice hash needs no wrap arithmetic. + */ +function permutationTable(stream) { + const p = new Uint8Array(256); + for (let index = 0; index < 256; index += 1) p[index] = index; + for (let index = 255; index >= 1; index -= 1) { + const swap = Math.floor(stream.nextFloat() * (index + 1)); + const held = p[index]; + p[index] = p[swap]; + p[swap] = held; + } + const table = new Uint16Array(512); + for (let index = 0; index < 256; index += 1) { + table[index] = p[index]; + table[index + 256] = p[index]; + } + return table; +} + +/** The number of samples `permutationTable` draws, stated so a trace can assert it. */ +const PERMUTATION_DRAWS = 255; + +function gradientDot(hash, dx, dy, dz) { + const g = GRADIENTS[hash % 12]; + return g[0] * dx + g[1] * dy + g[2] * dz; +} + +/** One octave of three-dimensional gradient noise on the integer lattice. */ +function singleOctave(table, x, y, z) { + const fx = x - Math.floor(x); + const fy = y - Math.floor(y); + const fz = z - Math.floor(z); + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + const Z = Math.floor(z) & 255; + const u = fade(fx); + const v = fade(fy); + const w = fade(fz); + const A = table[X] + Y; + const AA = table[A & 511] + Z; + const AB = table[(A + 1) & 511] + Z; + const B = table[X + 1] + Y; + const BA = table[B & 511] + Z; + const BB = table[(B + 1) & 511] + Z; + const value = mix(w, + mix(v, + mix(u, gradientDot(table[AA & 511], fx, fy, fz), gradientDot(table[BA & 511], fx - 1, fy, fz)), + mix(u, gradientDot(table[AB & 511], fx, fy - 1, fz), gradientDot(table[BB & 511], fx - 1, fy - 1, fz))), + mix(v, + mix(u, gradientDot(table[(AA + 1) & 511], fx, fy, fz - 1), gradientDot(table[(BA + 1) & 511], fx - 1, fy, fz - 1)), + mix(u, gradientDot(table[(AB + 1) & 511], fx, fy - 1, fz - 1), gradientDot(table[(BB + 1) & 511], fx - 1, fy - 1, fz - 1)))); + // A guard, never a shaping step: with these gradients the magnitude does not + // exceed 1. + return Math.max(-1, Math.min(1, value)); +} + +/** + * 18.7: octaves sum at doubling frequency and `persistence` amplitude decay, + * normalized by the accumulated amplitude so `persistence: 0` stays defined. + */ +function octaveNoise(table, x, y, z, octaves = 1, persistence = 0.5) { + let total = 0; + let amplitude = 1; + let frequency = 1; + let norm = 0; + for (let octave = 0; octave < octaves; octave += 1) { + total += amplitude * singleOctave(table, x * frequency, y * frequency, z * frequency); + norm += amplitude; + amplitude *= persistence; + frequency *= 2; + } + return norm === 0 ? 0 : total / norm; +} + +/** + * 18.7: `curl` is the explicit perpendicular of the gradient of the scalar + * potential, which is divergence-free by construction; `gradient` is that + * gradient; `value` is the scalar along `direction` degrees. + */ +function noiseVector(table, x, y, z, { mode = 'curl', octaves = 1, persistence = 0.5, amplitude = 1, direction = 0 } = {}) { + if (mode === 'value') { + const scalar = octaveNoise(table, x, y, z, octaves, persistence); + const radians = direction * (Math.PI / 180); + return [amplitude * scalar * Math.cos(radians), amplitude * scalar * Math.sin(radians)]; + } + const h = DERIVATIVE_STEP; + const dx = (octaveNoise(table, x + h, y, z, octaves, persistence) - octaveNoise(table, x - h, y, z, octaves, persistence)) / (2 * h); + const dy = (octaveNoise(table, x, y + h, z, octaves, persistence) - octaveNoise(table, x, y - h, z, octaves, persistence)) / (2 * h); + if (mode === 'gradient') return [amplitude * dx, amplitude * dy]; + return [amplitude * dy, -amplitude * dx]; +} + +/** + * The exhibit-wide table the four noise-using behaviors of 18.6 share. Deriving + * 255 samples for every particle would make instantiation quadratic in nothing + * an author asked for; per-object independence comes from the offset pair each + * behavior draws instead. + */ +function behaviorNoiseTable(rng) { + return permutationTable(rng.stream('visual', 'behavior-noise')); +} + +/** 18.7: each noise-using behavior draws one offset pair, uniform in [0, 1024). */ +function noiseOffsets(stream) { + return [stream.nextFloat() * 1024, stream.nextFloat() * 1024]; +} + +/* src/runtime/visual-behaviors.js */ +// Visual Behavior Set 0.1 — the seventeen behaviors of 18.6. +// +// A behavior's *configuration* resolves once at its owning object's +// instantiation boundary; its *effect* varies with logical time. Behaviors +// advance on the fixed tick of 9.1, after the particle integration of 18.2 and +// in array order, and compose by accumulation on position, z, and rotation, by +// multiplication on scale and opacity, and last-writer-wins elsewhere. +// +// The accumulating-versus-fresh division of 18.6 is the load-bearing rule here: +// an orbit recomputed from `t` traces its ellipse, while an orbit added as a +// fresh displacement each tick integrates into an outward spiral. + + + + +const BEHAVIOR_TYPES = Object.freeze([ + 'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander', + 'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce', + 'attract', 'repel', 'field-follow', 'morph' +]); + +/** 18.6: the channel set `oscillate`, `pulse`, and `twinkle` may name. */ +const PROPERTY_CHANNELS = Object.freeze([ + 'position.x', 'position.y', 'z', + 'transform.rotation', 'transform.scale.x', 'transform.scale.y', + 'style.opacity', 'style.strokeWidth', 'style.pointSize', + 'size.width', 'size.height', 'radius' +]); + +/** + * 18.6/19.1: each behavior's write set as *scalar* channels, so a behavior and + * an automation track on one channel can be detected as ERR_AUTOMATION_CONFLICT. + * `velocity.*` and `points[*].*` are outside the automatable registry, so the + * behaviors that write only those can never collide with a track. + */ +function behaviorChannels(behavior) { + switch (behavior?.type) { + case 'drift': case 'noise-displace': return ['position.x', 'position.y', 'z']; + case 'field-follow': return (behavior.mode ?? 'force') === 'direct' ? ['position.x', 'position.y', 'z'] : ['velocity.x', 'velocity.y', 'velocity.z']; + case 'orbit': case 'wander': case 'wrap': return ['position.x', 'position.y']; + case 'rotate': case 'face-motion': return ['transform.rotation']; + case 'follow-path': return behavior.align === true ? ['position.x', 'position.y', 'transform.rotation'] : ['position.x', 'position.y']; + case 'oscillate': case 'pulse': return [behavior.property]; + case 'twinkle': return [behavior.property ?? 'style.opacity']; + case 'bounce': case 'attract': case 'repel': return ['velocity.x', 'velocity.y', 'velocity.z']; + case 'point-wander': case 'morph': return ['points[*].x', 'points[*].y', 'points[*].z']; + default: return []; + } +} + +// --------------------------------------------------------------------------- +// Waveforms and curves (18.6, 18.2) +// --------------------------------------------------------------------------- + +const frac = (value) => value - Math.floor(value); + +function waveform(name, phi) { + switch (name ?? 'sine') { + case 'sine': return Math.sin(2 * Math.PI * phi); + case 'triangle': return 1 - 4 * Math.abs(frac(phi + 0.25) - 0.5); + case 'square': return phi < 0.5 ? 1 : -1; + case 'sawtooth': return 2 * frac(phi + 0.5) - 1; + default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown waveform '${name}'.`); + } +} + +/** The four-curve set 18.2 fixes for ramps and 18.6 reuses for `pulse`. */ +function curveValue(name, t) { + const u = Math.max(0, Math.min(1, t)); + switch (name ?? 'linear') { + case 'step': return u < 1 ? 0 : 1; + case 'linear': return u; + case 'exponential': return u * u; + case 'smooth': return u * u * (3 - 2 * u); + default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown curve '${name}'.`); + } +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +/** + * Create one behavior instance. `wander`, `twinkle`, `point-wander`, and + * `noise-displace` each draw exactly one offset pair here and never sample + * again — the whole of their stream consumption (18.6). + */ +/** 18.6: which behaviors accumulate their contribution and which recompute it. */ +const ACCUMULATING = new Set(['drift', 'rotate', 'wander', 'bounce', 'attract', 'repel', 'wrap']); + +function isAccumulating(behavior) { + if (behavior?.type === 'field-follow') return (behavior.mode ?? 'force') !== 'direct'; + return ACCUMULATING.has(behavior?.type); +} + +function createBehavior(specification, { stream = null, noiseTable = null, path = '$' } = {}) { + if (!BEHAVIOR_TYPES.includes(specification?.type)) { + throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${specification?.type}' is outside Visual Behavior Set 0.1.`, path); + } + const instance = { spec: specification, type: specification.type, path, noiseTable, state: {}, accumulating: isAccumulating(specification) }; + if (['wander', 'twinkle', 'point-wander', 'noise-displace'].includes(specification.type)) { + if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise behavior requires an owning random stream.', path); + instance.offsets = noiseOffsets(stream); + } + if (specification.type === 'drift') instance.state.velocity = { ...vector(specification.velocity) }; + if (specification.type === 'wander') instance.state.velocity = { x: 0, y: 0 }; + return instance; +} + +function vector(value, fallback = 0) { + return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback }; +} + +function bounds(specification, scene) { + const declared = specification.bounds; + if (declared === undefined || declared === 'scene') return scene; + return { x: declared.x ?? 0, y: declared.y ?? 0, width: declared.width ?? 0, height: declared.height ?? 0 }; +} + +function writeChannel(item, channel, value) { + switch (channel) { + case 'position.x': item.offset.x += value; break; + case 'position.y': item.offset.y += value; break; + case 'z': item.offset.z += value; break; + case 'transform.rotation': item.rotationOffset += value; break; + case 'transform.scale.x': item.scale.x *= value; break; + case 'transform.scale.y': item.scale.y *= value; break; + case 'style.opacity': item.opacityMultiplier *= value; break; + default: item.channels[channel] = value; break; + } +} + +/** + * `oscillate`, `pulse`, and `twinkle` write one named channel. Additive + * channels accumulate, multiplicative ones multiply, and the rest are + * last-writer-wins, exactly as 18.6 states. + */ +function writeNamedChannel(item, channel, absolute, additive) { + if (channel === 'transform.scale.x' || channel === 'transform.scale.y' || channel === 'style.opacity') { + writeChannel(item, channel, absolute); + return; + } + if (['position.x', 'position.y', 'z', 'transform.rotation'].includes(channel)) { + writeChannel(item, channel, additive); + return; + } + item.channels[channel] = absolute; +} + +/** + * Advance one behavior by `dt` logical seconds at logical time `t`, measured + * from the owning object's instantiation boundary. + */ +function advanceBehavior(instance, item, { dt, time, env = {} }) { + const spec = instance.spec; + switch (instance.type) { + case 'drift': { + // Accumulating: the contribution grows by velocity * dt, with the + // velocity decaying first. + const damping = spec.damping ?? 0; + const velocity = instance.state.velocity; + const decay = (1 - damping) ** dt; + velocity.x *= decay; velocity.y *= decay; velocity.z *= decay; + item.offset.x += velocity.x * dt; + item.offset.y += velocity.y * dt; + item.offset.z += velocity.z * dt; + break; + } + case 'rotate': { + item.rotationOffset += (spec.speed ?? 0) * dt; + break; + } + case 'oscillate': { + const phi = frac((spec.frequency ?? 1) * time + (spec.phase ?? 0) / 360); + const value = (spec.center ?? 0) + (spec.amplitude ?? 0) * waveform(spec.waveform, phi); + writeNamedChannel(item, spec.property, value, value); + break; + } + case 'orbit': { + // Fresh every tick: an absolute placement expressed as an offset from the + // resolved base position. + const theta = ((spec.speed ?? 0) * time + (spec.phase ?? 0)) * DEGREES_TO_RADIANS; + const [rx, ry] = typeof spec.radius === 'number' ? [spec.radius, spec.radius] : [spec.radius?.x ?? 0, spec.radius?.y ?? 0]; + const center = vector(spec.center); + item.offset.x += center.x + rx * Math.cos(theta) - item.base.x; + item.offset.y += center.y + ry * Math.sin(theta) - item.base.y; + break; + } + case 'wander': { + const rate = spec.rate ?? 1; + const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5); + const theta = 360 * scalar * DEGREES_TO_RADIANS; + const strength = spec.strength ?? 0; + const velocity = instance.state.velocity; + velocity.x += strength * Math.cos(theta) * dt; + velocity.y += strength * Math.sin(theta) * dt; + const maxSpeed = spec.maxSpeed; + if (maxSpeed !== undefined) { + const speed = Math.hypot(velocity.x, velocity.y); + if (speed > maxSpeed && speed > 0) { + velocity.x *= maxSpeed / speed; + velocity.y *= maxSpeed / speed; + } + } + item.offset.x += velocity.x * dt; + item.offset.y += velocity.y * dt; + break; + } + case 'follow-path': { + const flat = env.resolvePath?.(spec.path, instance.path); + if (!flat || flat.total === 0) break; + const duration = spec.duration !== undefined ? env.duration(spec.duration) / 1000 : flat.total / Math.max(1e-9, spec.speed ?? 1); + let progress = (spec.offset ?? 0) + (duration === 0 ? 0 : time / duration); + const loop = spec.loop ?? 'once'; + if (loop === 'repeat') progress = frac(progress); + else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; } + else progress = Math.max(0, Math.min(1, progress)); + const at = env.alongPath(flat, progress * flat.total); + item.offset.x += at.x - item.base.x; + item.offset.y += at.y - item.base.y; + if (spec.align === true) item.rotationOffset += at.tangent; + break; + } + case 'point-wander': { + if (!Array.isArray(item.points)) throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TARGET', 'point-wander requires an addressable point list.', instance.path); + const rate = spec.rate ?? 1; + const amplitude = vector(spec.amplitude); + const indices = Array.isArray(spec.indices) ? spec.indices : null; + for (let index = 0; index < item.points.length; index += 1) { + if (indices && !indices.includes(index)) continue; + const [o1, o2] = instance.offsets; + item.points[index].x += amplitude.x * octaveNoise(instance.noiseTable, o1 + 64 * index, o2, time * rate, 1, 0.5); + item.points[index].y += amplitude.y * octaveNoise(instance.noiseTable, o1, o2 + 64 * index, time * rate, 1, 0.5); + if (amplitude.z !== 0) item.points[index].z += amplitude.z * octaveNoise(instance.noiseTable, o1 + 64 * index, o2 + 64 * index, time * rate, 1, 0.5); + } + break; + } + case 'pulse': { + const duty = spec.duty ?? 0.5; + const phi = frac((spec.frequency ?? 1) * time); + let envelope = 0; + if (phi < duty / 2) envelope = curveValue(spec.curve ?? 'smooth', (2 * phi) / duty); + else if (phi < duty) envelope = curveValue(spec.curve ?? 'smooth', 2 - (2 * phi) / duty); + const value = (spec.amplitude ?? 0) * envelope; + writeNamedChannel(item, spec.property, 1 + value, value); + break; + } + case 'twinkle': { + const rate = spec.rate ?? 1; + const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5); + const min = spec.min ?? 0; + const max = spec.max ?? 1; + const value = min + (max - min) * ((scalar + 1) / 2); + writeNamedChannel(item, spec.property ?? 'style.opacity', value, value); + break; + } + case 'noise-displace': { + const scale = spec.scale ?? 100; + const speed = spec.speed ?? 0; + const octaves = spec.octaves ?? 1; + const persistence = spec.persistence ?? 0.5; + const amplitude = vector(spec.amplitude); + const px = (item.base.x + item.offset.x) / scale; + const py = (item.base.y + item.offset.y) / scale; + const table = instance.noiseTable; + item.offset.x += amplitude.x * octaveNoise(table, px, py, time * speed, octaves, persistence); + item.offset.y += amplitude.y * octaveNoise(table, px + 137, py + 71, time * speed, octaves, persistence); + if (amplitude.z !== 0) item.offset.z += amplitude.z * octaveNoise(table, px + 271, py + 193, time * speed, octaves, persistence); + break; + } + case 'face-motion': { + const speed = Math.hypot(item.vx, item.vy); + if (speed < 1e-6) { + // 18.6: An object with zero velocity holds its previous rotation. + if (instance.state.rotation !== undefined) { + item.rotationOffset += instance.state.rotation - item.base.rotation; + } + break; + } + const target = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS + (spec.offset ?? 0); + const smoothing = spec.smoothing ?? 0; + // Lazy init: use the current drawn rotation, not the target, on first tick. + const current = instance.state.rotation ?? item.base.rotation; + let delta = ((target - current + 540) % 360) - 180; + const step = 1 - smoothing ** dt; + instance.state.rotation = current + delta * step; + item.rotationOffset += instance.state.rotation - item.base.rotation; + break; + } + case 'wrap': { + const box = bounds(spec, env.scene); + const margin = spec.margin ?? 0; + const x = item.base.x + item.offset.x; + const y = item.base.y + item.offset.y; + if (x < box.x - margin) item.offset.x += box.width + 2 * margin; + else if (x > box.x + box.width + margin) item.offset.x -= box.width + 2 * margin; + if (y < box.y - margin) item.offset.y += box.height + 2 * margin; + else if (y > box.y + box.height + margin) item.offset.y -= box.height + 2 * margin; + break; + } + case 'bounce': { + const box = bounds(spec, env.scene); + const restitution = spec.restitution ?? 1; + const axes = spec.axes ?? 'both'; + const x = item.base.x + item.offset.x; + const y = item.base.y + item.offset.y; + if (axes !== 'y' && ((x <= box.x && item.vx < 0) || (x >= box.x + box.width && item.vx > 0))) item.vx = -item.vx * restitution; + if (axes !== 'x' && ((y <= box.y && item.vy < 0) || (y >= box.y + box.height && item.vy > 0))) item.vy = -item.vy * restitution; + break; + } + case 'attract': + case 'repel': { + const target = env.resolveTarget?.(spec.target, instance.path) ?? vector(spec.target); + const dx = target.x - (item.base.x + item.offset.x); + const dy = target.y - (item.base.y + item.offset.y); + const distance = Math.hypot(dx, dy); + const maxDistance = spec.maxDistance; + if (maxDistance !== undefined && distance > maxDistance) break; + const clamped = Math.max(spec.minDistance ?? 1, distance); + const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, maxDistance); + const sign = instance.type === 'attract' ? 1 : -1; + if (distance > 0) { + item.vx += sign * magnitude * (dx / distance) * dt; + item.vy += sign * magnitude * (dy / distance) * dt; + } + break; + } + case 'field-follow': { + const field = env.sampleField?.(spec.field, item.base.x + item.offset.x, item.base.y + item.offset.y, time); + if (!field) break; + const strength = spec.strength ?? 1; + const mode = spec.mode ?? 'force'; + if (mode === 'velocity') { item.vx = field[0] * strength; item.vy = field[1] * strength; } + // 18.6: `direct` is the mode that does not integrate, so no dt factor. + else if (mode === 'direct') { item.offset.x += field[0] * strength; item.offset.y += field[1] * strength; } + else { item.vx += field[0] * strength * dt; item.vy += field[1] * strength * dt; } + break; + } + case 'morph': { + const target = env.resolveMorphTarget?.(spec.to, instance.path); + if (!target || !Array.isArray(item.points)) break; + const duration = env.duration(spec.duration) / 1000; + let progress = duration === 0 ? 1 : time / duration; + const loop = spec.loop ?? 'once'; + if (loop === 'repeat') progress = frac(progress); + else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; } + else progress = Math.max(0, Math.min(1, progress)); + const amount = curveValue(spec.curve ?? 'linear', progress); + for (let index = 0; index < item.points.length && index < target.length; index += 1) { + item.points[index].x += (target[index].x - item.points[index].x) * amount; + item.points[index].y += (target[index].y - item.points[index].y) * amount; + item.points[index].z += ((target[index].z ?? 0) - (item.points[index].z ?? 0)) * amount; + } + break; + } + default: + throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${instance.type}' is outside Visual Behavior Set 0.1.`, instance.path); + } +} + +/** 18.6: the shared falloff vocabulary, also used by the fields of 18.7. */ +function falloffFactor(falloff, distance, maxDistance) { + switch (falloff ?? 'linear') { + case 'none': return 1; + case 'inverse': return 1 / distance; + case 'inverse-square': return 1 / (distance * distance); + case 'linear': + default: { + if (maxDistance === undefined) return 1 / distance; + return Math.max(0, 1 - distance / maxDistance); + } + } +} + +/* src/runtime/visual-distributions.js */ +// The nine placement distributions of 18.3. +// +// A distribution answers one question: where does an item start? Its sample +// consumption is normative — an explicit list of draws per distribution and +// mode, in a fixed order — so a fixture's placements are byte-identical across +// renderers and a trace can assert the final stream position. + + + + +/** 18.3: the flattening tolerance for arc-length placement, in scene units. */ +const PATH_FLATNESS = 0.1; + +function fraction(index, count) { + // The n == 1 convention `repeat.fraction` uses (18.5). + return count <= 1 ? 0 : index / (count - 1); +} + +function pointOf(value, fallback = { x: 0, y: 0, z: 0 }) { + return { x: value?.x ?? fallback.x, y: value?.y ?? fallback.y, z: value?.z ?? fallback.z }; +} + +function distributionRadii(radius) { + return typeof radius === 'number' ? [radius, radius] : [radius?.x ?? 0, radius?.y ?? 0]; +} + +// --------------------------------------------------------------------------- +// Arc-length flattening for the `path` distribution +// --------------------------------------------------------------------------- + +function flattenCubic(from, c1, c2, to, out, depth = 0) { + // Recursive subdivision to within PATH_FLATNESS, which fixes the sampled + // length so two renderers place an item at the same distance. + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const d1 = Math.abs((c1[0] - to[0]) * dy - (c1[1] - to[1]) * dx); + const d2 = Math.abs((c2[0] - to[0]) * dy - (c2[1] - to[1]) * dx); + const flat = (d1 + d2) * (d1 + d2) < PATH_FLATNESS * (dx * dx + dy * dy); + if (flat || depth >= 16) { out.push(to); return; } + const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + const p01 = mid(from, c1); + const p12 = mid(c1, c2); + const p23 = mid(c2, to); + const p012 = mid(p01, p12); + const p123 = mid(p12, p23); + const middle = mid(p012, p123); + flattenCubic(from, p01, p012, middle, out, depth + 1); + flattenCubic(middle, p123, p23, to, out, depth + 1); +} + +function flattenPath(commands, path = '$') { + const subpaths = pathSubpaths(commands, path); + const points = []; + for (const subpath of subpaths) { + let cursor = subpath.start; + if (points.length === 0) points.push(cursor); + for (const segment of subpath.segments) { + if (segment.type === 'line') points.push(segment.to); + else flattenCubic(cursor, segment.c1, segment.c2, segment.to, points); + cursor = segment.to; + } + if (subpath.closed) points.push(subpath.start); + } + const lengths = [0]; + for (let index = 1; index < points.length; index += 1) { + lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1])); + } + return { points, lengths, total: lengths.at(-1) ?? 0, closed: subpaths.every((entry) => entry.closed) }; +} + +function alongFlattened(flat, distance) { + const { points, lengths, total } = flat; + if (total === 0) return { x: points[0]?.[0] ?? 0, y: points[0]?.[1] ?? 0, tangent: 0 }; + const target = Math.max(0, Math.min(total, distance)); + let index = 1; + while (index < lengths.length - 1 && lengths[index] < target) index += 1; + const span = lengths[index] - lengths[index - 1]; + const t = span === 0 ? 0 : (target - lengths[index - 1]) / span; + const from = points[index - 1]; + const to = points[index]; + return { + x: from[0] + (to[0] - from[0]) * t, + y: from[1] + (to[1] - from[1]) * t, + tangent: Math.atan2(to[1] - from[1], to[0] - from[0]) / DEGREES_TO_RADIANS + }; +} + +// --------------------------------------------------------------------------- +// Depth (as a type and as a sub-block) +// --------------------------------------------------------------------------- + +/** 18.3: three inverse functions, both non-uniform curves biasing toward `near`. */ +function depthValue(block, u) { + const near = block.near ?? 0; + const far = block.far ?? 0; + if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A depth distribution needs far above near.', '$'); + const curve = block.curve ?? 'uniform'; + if (curve === 'linear') return near + (far - near) * u * u; + if (curve === 'exponential') return near + (far - near) * ((1 - Math.exp(-3 * u)) / (1 - Math.exp(-3))); + return near + (far - near) * u; +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +const CONTINUOUS_ILLEGAL = new Set(['grid']); + +/** + * 18.3: `even` on `line`, `ring`, or `path`, and the `grid` type itself, place + * item `i` of `n` by index and are ERR_INVALID_DISTRIBUTION on a continuous + * `rate` emission, where `n` is unknown at creation time. + */ +function requiresKnownCount(distribution) { + if (!distribution) return false; + if (CONTINUOUS_ILLEGAL.has(distribution.type)) return true; + return distribution.mode === 'even' && ['line', 'ring', 'path'].includes(distribution.type); +} + +/** + * Place item `index` of `count`. Samples are drawn from `stream` in exactly the + * order the table of 18.3 fixes; an optional `depth` sub-block draws its `u` + * after the host distribution's own samples. + */ +function placeItem(distribution, { index = 0, count = 1, stream = null, path = '$' } = {}) { + const type = distribution?.type ?? 'point'; + const place = { x: 0, y: 0, z: 0, rotation: undefined }; + const next = () => { + if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'This distribution requires an owning random stream.', path); + return stream.nextFloat(); + }; + + switch (type) { + case 'point': { + const at = pointOf(distribution?.at); + place.x = at.x; place.y = at.y; place.z = at.z; + break; + } + case 'uniform': { + const min = pointOf(distribution.min); + const max = pointOf(distribution.max); + if (max.x < min.x || max.y < min.y) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A uniform box needs max above min.', path); + place.x = min.x + next() * (max.x - min.x); + place.y = min.y + next() * (max.y - min.y); + const carriesZ = distribution.min?.z !== undefined || distribution.max?.z !== undefined; + if (carriesZ) place.z = min.z + next() * (max.z - min.z); + break; + } + case 'line': { + const from = pointOf(distribution.from); + const to = pointOf(distribution.to); + const t = (distribution.mode ?? 'random') === 'even' ? fraction(index, count) : next(); + place.x = from.x + (to.x - from.x) * t; + place.y = from.y + (to.y - from.y) * t; + place.z = from.z + (to.z - from.z) * t; + break; + } + case 'rectangle': { + const center = pointOf(distribution.center); + const width = distribution.size?.width ?? 0; + const height = distribution.size?.height ?? 0; + if ((distribution.fill ?? 'area') === 'perimeter') { + // One sample mapped to a distance around the perimeter, clockwise from + // the top-left corner. + const perimeter = 2 * (width + height); + let walk = next() * perimeter; + const left = center.x - width / 2; + const top = center.y - height / 2; + if (walk < width) { place.x = left + walk; place.y = top; break; } + walk -= width; + if (walk < height) { place.x = left + width; place.y = top + walk; break; } + walk -= height; + if (walk < width) { place.x = left + width - walk; place.y = top + height; break; } + walk -= width; + place.x = left; place.y = top + height - walk; + break; + } + place.x = center.x + (next() - 0.5) * width; + place.y = center.y + (next() - 0.5) * height; + break; + } + case 'ellipse': { + const center = pointOf(distribution.center); + const [rx, ry] = distributionRadii(distribution.radius); + const angle = next() * 360 * DEGREES_TO_RADIANS; + if ((distribution.fill ?? 'area') === 'perimeter') { + place.x = center.x + rx * Math.cos(angle); + place.y = center.y + ry * Math.sin(angle); + break; + } + const radial = Math.sqrt(next()); + place.x = center.x + rx * radial * Math.cos(angle); + place.y = center.y + ry * radial * Math.sin(angle); + break; + } + case 'ring': { + const center = pointOf(distribution.center); + const outer = distribution.radius ?? 0; + const inner = distribution.innerRadius ?? 0; + if (inner >= outer) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A ring needs innerRadius below radius.', path); + const startAngle = distribution.startAngle ?? 0; + const endAngle = distribution.endAngle ?? 360; + const { sweep, sign } = directedSweep(startAngle, endAngle, distribution.direction ?? 'clockwise', path); + const full = sweep === 360; + let degrees; + let radial; + if ((distribution.mode ?? 'random') === 'even') { + degrees = startAngle + sweep * sign * (full ? index / Math.max(1, count) : fraction(index, count)); + // An even angular placement with a random radius would be neither even + // nor sample-free, so the radius is the mid-annulus. + radial = (inner + outer) / 2; + } else { + degrees = startAngle + sweep * sign * next(); + radial = Math.sqrt(inner * inner + next() * (outer * outer - inner * inner)); + } + place.x = center.x + radial * Math.cos(degrees * DEGREES_TO_RADIANS); + place.y = center.y + radial * Math.sin(degrees * DEGREES_TO_RADIANS); + break; + } + case 'path': { + const flat = flattenPath(distribution.path, `${path}.path`); + const even = (distribution.mode ?? 'random') === 'even'; + const t = even ? (flat.closed ? index / Math.max(1, count) : fraction(index, count)) : next(); + const at = alongFlattened(flat, t * flat.total); + place.x = at.x; + place.y = at.y; + if (distribution.align === true) place.rotation = at.tangent; + break; + } + case 'grid': { + const origin = pointOf(distribution.origin); + const columns = distribution.columns ?? 1; + const rows = distribution.rows ?? 1; + const cells = Math.max(1, columns * rows); + const cell = ((index % cells) + cells) % cells; + const column = cell % columns; + const row = Math.floor(cell / columns); + place.x = origin.x + column * (distribution.spacing?.x ?? 0); + place.y = origin.y + row * (distribution.spacing?.y ?? 0); + const jitter = distribution.jitter; + const jx = jitter?.x ?? 0; + const jy = jitter?.y ?? 0; + if (jx !== 0 || jy !== 0) { + place.x += (next() * 2 - 1) * jx; + place.y += (next() * 2 - 1) * jy; + } + break; + } + case 'depth': { + place.z = depthValue(distribution, next()); + break; + } + default: + throw new RuntimeFault('ERR_INVALID_DISTRIBUTION_TYPE', `Unknown placement distribution '${type}'.`, path); + } + + // 18.3: the optional depth sub-block draws its sample after the host's own. + if (type !== 'depth' && distribution?.depth) { + place.z = depthValue(distribution.depth, next()); + } + return place; +} + +/** The documented number of samples a placement draws, for the traces of 18.10. */ +function sampleCount(distribution, mode = 'random') { + const type = distribution?.type ?? 'point'; + const selected = distribution?.mode ?? mode; + let count = 0; + if (type === 'uniform') count = 2 + ((distribution.min?.z !== undefined || distribution.max?.z !== undefined) ? 1 : 0); + else if (type === 'line') count = selected === 'even' ? 0 : 1; + else if (type === 'rectangle') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2; + else if (type === 'ellipse') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2; + else if (type === 'ring') count = selected === 'even' ? 0 : 2; + else if (type === 'path') count = selected === 'even' ? 0 : 1; + else if (type === 'grid') count = (distribution.jitter?.x ?? 0) !== 0 || (distribution.jitter?.y ?? 0) !== 0 ? 2 : 0; + else if (type === 'depth') count = 1; + if (type !== 'depth' && distribution?.depth) count += 1; + return count; +} + +/* src/runtime/visual-fields.js */ +// Procedural fields (18.7): named vector functions over scene space that other +// systems read. A field draws nothing and depends only on position and logical +// time, so there is no evaluation order to fix and no cycle to detect. + + + + + +class FieldSet { + constructor(declarations = {}, { rng = null } = {}) { + this.fields = new Map(); + for (const [id, declaration] of Object.entries(declarations)) { + const entry = { id, spec: declaration, table: null }; + if (declaration.type === 'noise') { + if (!rng) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise field requires a seeded stream.', `$.visuals.fields.${id}`); + // 18.7: the permutation table is derived at field instantiation from + // the field's own stream, consuming exactly 255 samples. + entry.table = permutationTable(rng.stream('visual', id)); + } + this.fields.set(id, entry); + } + this.evaluations = 0; + } + + has(id) { return this.fields.has(id); } + + /** The field's vector at a scene point at logical time `t` seconds. */ + sample(id, x, y, t) { + const entry = this.fields.get(id); + if (!entry) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual field '${id}' is declared.`, `$.visuals.fields.${id}`); + const spec = entry.spec; + if (spec.enabled === false) return [0, 0]; + const box = spec.bounds; + if (box && (x < box.x || y < box.y || x > box.x + box.width || y > box.y + box.height)) return [0, 0]; + if (this.evaluations >= (this.budget ?? Infinity)) { this.refused = true; return [0, 0]; } + this.evaluations += 1; + switch (spec.type) { + case 'directional': { + const radians = (spec.direction ?? 0) * DEGREES_TO_RADIANS; + const strength = spec.strength ?? 0; + return [strength * Math.cos(radians), strength * Math.sin(radians)]; + } + case 'noise': { + const region = spec.center && spec.size; + if (region) { + const halfWidth = (spec.size.width ?? 0) / 2; + const halfHeight = (spec.size.height ?? 0) / 2; + if (Math.abs(x - spec.center.x) > halfWidth || Math.abs(y - spec.center.y) > halfHeight) return [0, 0]; + } + const scale = spec.scale ?? 100; + return noiseVector(entry.table, x / scale, y / scale, t * (spec.speed ?? 0), { + mode: spec.mode ?? 'curl', + octaves: spec.octaves ?? 1, + persistence: spec.persistence ?? 0.5, + amplitude: spec.amplitude ?? 1, + direction: spec.direction ?? 0 + }); + } + default: { + const center = { x: spec.center?.x ?? 0, y: spec.center?.y ?? 0 }; + const dx = x - center.x; + const dy = y - center.y; + const distance = Math.hypot(dx, dy); + if (spec.maxDistance !== undefined && distance > spec.maxDistance) return [0, 0]; + const clamped = Math.max(spec.minDistance ?? 1, distance); + const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, spec.maxDistance); + if (distance === 0) return [0, 0]; + const ux = dx / distance; + const uy = dy / distance; + switch (spec.type) { + // 18.7: the outward ray, with negative strength pointing inward. + case 'radial': return [magnitude * ux, magnitude * uy]; + case 'attractor': return [-magnitude * ux, -magnitude * uy]; + case 'repulsor': return [magnitude * ux, magnitude * uy]; + // 18.7: counter-clockwise as seen on the display, in y-down space. + case 'vortex': return [magnitude * uy, -magnitude * ux]; + default: throw new RuntimeFault('ERR_INVALID_FIELD_TYPE', `Unknown field type '${spec.type}'.`, `$.visuals.fields.${id}`); + } + } + } + } + + /** Sum a system's declared fields into one acceleration, in array order. */ + sum(ids, x, y, t) { + let ax = 0; + let ay = 0; + for (const id of ids ?? []) { + const [fx, fy] = this.sample(id, x, y, t); + ax += fx; + ay += fy; + } + return [ax, ay]; + } +} + +/* src/runtime/visual-systems.js */ +// Procedural systems (18.2, 18.4, 18.5) with their trails and links (18.8). +// +// A system owns a bounded population of items. Its fields divide into three +// classes (18.2): system-instantiation values resolved once before any item +// exists, live system channels whose base is resolved there, and per-item +// values resolved at each item's creation from the stream child key +// `#`. Motion integrates on the fixed logical tick of +// 9.1 with the normative semi-implicit Euler order, never on the frame. + + + + + + + +const SYSTEM_LIMITS = VISUAL_LIMITS.authoring; + +/** 18.2: fields resolved once per item, at that item's creation. */ +const PER_ITEM_KEYS = new Set(['distribution', 'velocity', 'size', 'rotation', 'angularVelocity', 'opacity', 'color', 'z', 'lifetime', 'align']); + +function isLifeRamp(value) { + return isRecord(value) && Object.hasOwn(value, 'from') && Object.hasOwn(value, 'to'); +} + +function seconds(duration, path) { + return duration === undefined ? null : parseDuration(duration, path) / 1000; +} + +class ProceduralSystem { + constructor(id, spec, options) { + this.id = id; + this.spec = spec; + this.type = spec.type; + this.path = options.systemPath ?? `visuals.systems.${id}`; + this.resolver = options.resolver; + this.rng = options.rng; + this.noiseTable = options.noiseTable; + this.scene = options.scene ?? { x: 0, y: 0, width: 0, height: 0 }; + this.fields = options.fields ?? null; + this.instantiateItemNode = options.instantiateItemNode; + this.layer = spec.layer ?? null; + this.time = 0; + this.ordinal = 0; + this.created = 0; + this.accumulator = 0; + this.burstsFired = new Set(); + this.items = []; + + const systemStream = this.rng ? this.rng.stream('visual', this.path) : null; + this.systemStream = systemStream; + const sample = (value, key) => this.resolver && value !== undefined + ? this.resolver.evaluate(value, systemStream, `${this.path}.${key}`) + : value; + + // ---- system-instantiation values (18.2) -------------------------------- + this.capacity = spec.capacity ?? (this.type === 'emitter' ? 64 : 256); + this.limit = spec.limit; + const resolvedCount = sample(spec.count, 'count') ?? (this.type === 'repeater' ? 0 : 0); + const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity; + if (resolvedCount > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path); + // C18: Throw ERR_TYPE_MISMATCH on non-integer resolved count (§18.2). + if (Number.isFinite(resolvedCount) && !Number.isInteger(resolvedCount)) { + throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved count ${resolvedCount} is not an integer.`, this.path); + } + this.count = Math.round(resolvedCount); + this.rate = sample(spec.rate, 'rate') ?? 0; + this.bursts = (spec.burst ?? []).map((entry, index) => { + const resolvedBurstCount = sample(entry.count, `burst[${index}].count`) ?? 0; + // C18: Throw ERR_TYPE_MISMATCH on non-integer resolved burst count. + if (Number.isFinite(resolvedBurstCount) && !Number.isInteger(resolvedBurstCount)) { + throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved burst count ${resolvedBurstCount} is not an integer.`, `${this.path}.burst[${index}].count`); + } + return { + at: seconds(entry.at, `${this.path}.burst[${index}].at`) ?? 0, + count: Math.round(resolvedBurstCount) + }; + }); + this.itemLifetime = spec.lifetime; + this.trailSpec = spec.trail ?? null; + this.linksSpec = spec.links ?? null; + this.fieldIds = spec.fields ?? []; + this.distribution = spec.distribution ? sampleBehavior(spec.distribution, this.resolver, systemStream, `${this.path}.distribution`) : undefined; + + // ---- live system channels: base resolved here (19.1 drives them) ------- + this.position = { x: sample(spec.position?.x, 'position.x') ?? 0, y: sample(spec.position?.y, 'position.y') ?? 0 }; + this.acceleration = { + x: sample(spec.acceleration?.x, 'acceleration.x') ?? 0, + y: sample(spec.acceleration?.y, 'acceleration.y') ?? 0, + z: sample(spec.acceleration?.z, 'acceleration.z') ?? 0 + }; + this.drag = sample(spec.drag, 'drag') ?? 0; + + this.validate(); + if (this.type === 'repeater') this.createRepeaterCopies(); + else for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count); + } + + validate() { + if (!Number.isSafeInteger(this.capacity) || this.capacity < 1) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Capacity must be a positive bounded integer.', this.path); + if (!Number.isFinite(this.rate) || this.rate < 0 || this.rate > Number.MAX_SAFE_INTEGER) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Rate exceeds finite safe arithmetic.', this.path); + const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity; + if (!Number.isFinite(this.count) || this.count < 0 || this.count > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path); + if (!Number.isFinite(this.drag)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Drag must be finite.', this.path); + this.drag = Math.max(0, Math.min(1, this.drag)); + const emits = this.rate > 0 || this.bursts.length > 0; + // 18.2/18.4: emission with neither a per-item lifetime nor a total limit + // would create items without bound. + if (emits && this.itemLifetime === undefined && this.limit === undefined) { + throw new RuntimeFault('ERR_UNBOUNDED_EMISSION', 'Emission declares neither a lifetime nor a limit.', this.path); + } + if (this.type === 'particles' && this.capacity > SYSTEM_LIMITS.particleCapacity) { + throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A particle capacity above ${SYSTEM_LIMITS.particleCapacity}.`, this.path); + } + if (this.type === 'emitter' && this.capacity > SYSTEM_LIMITS.emitterCapacity) { + throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `An emitter capacity above ${SYSTEM_LIMITS.emitterCapacity}.`, this.path); + } + if (this.type === 'repeater' && this.count > SYSTEM_LIMITS.repeaterCount) { + throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A repeater count above ${SYSTEM_LIMITS.repeaterCount}.`, this.path); + } + // 18.3: an index-driven placement needs a known count, so it is illegal on + // a continuous rate emission. + if (this.rate > 0 && requiresKnownCount(this.spec.distribution)) { + throw new RuntimeFault('ERR_INVALID_DISTRIBUTION', 'An index-driven placement has no count on a continuous rate emission.', `${this.path}.distribution`); + } + // 18.8: a pairwise link rule over a statically known population above 256. + const rule = this.linksSpec?.rule ?? 'distance'; + if (this.linksSpec && rule !== 'index') { + const population = this.type === 'repeater' ? this.count : this.capacity; + if (population > SYSTEM_LIMITS.pairwiseLinkedPopulation) { + throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A '${rule}' link rule over a population above ${SYSTEM_LIMITS.pairwiseLinkedPopulation}.`, `${this.path}.links`); + } + } + if (this.linksSpec?.fadeWithDistance === true && this.linksSpec.maxDistance === undefined) { + throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'fadeWithDistance needs a maxDistance to normalize against.', `${this.path}.links`); + } + } + + // ------------------------------------------------------------------------- + // Item creation + // ------------------------------------------------------------------------- + + itemTemplateKey() { + return this.type === 'particles' ? 'render' : this.type === 'emitter' ? 'emit' : 'repeat'; + } + + /** + * 18.2: per-item fields are sampled in the system's own property-document + * order, from the stream child key `#`, so one seed + * creates identical items in identical order however the ticks fall. + */ + createItem(index, count, { burstIndex = null } = {}) { + const ordinal = this.ordinal; + this.ordinal += 1; + this.created += 1; + const stream = this.rng ? this.rng.stream('visual', `${this.path}#${ordinal}`) : null; + const spec = this.spec; + const evaluate = (value, key) => value === undefined ? undefined : this.resolver.evaluate(value, stream, `${this.path}.${key}`); + const templateKey = this.itemTemplateKey(); + + const item = { + ordinal, index, burstIndex, + birth: this.time, + lifetime: null, + base: { x: this.position.x, y: this.position.y, z: 0, rotation: 0 }, + acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 }, + fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 }, + scale: { x: 1, y: 1 }, + opacityMultiplier: 1, + channels: {}, + vx: 0, vy: 0, vz: 0, + angularVelocity: 0, + size: 1, opacity: 1, color: null, + ramps: {}, + behaviors: [], + trail: [], + node: null, + points: null + }; + + for (const key of Object.keys(spec)) { + if (!PER_ITEM_KEYS.has(key) && key !== templateKey && key !== 'behaviors') continue; + switch (key) { + case 'distribution': { + const place = placeItem(this.distribution, { index, count, stream, path: `${this.path}.distribution` }); + item.base.x += place.x; + item.base.y += place.y; + item.base.z += place.z; + if (place.rotation !== undefined) item.base.rotation = place.rotation; + break; + } + case 'velocity': { + item.vx = evaluate(spec.velocity?.x, 'velocity.x') ?? 0; + item.vy = evaluate(spec.velocity?.y, 'velocity.y') ?? 0; + item.vz = evaluate(spec.velocity?.z, 'velocity.z') ?? 0; + break; + } + case 'size': item.ramps.size = this.rampOrValue(spec.size, evaluate, 'size', 1); break; + case 'opacity': item.ramps.opacity = this.rampOrValue(spec.opacity, evaluate, 'opacity', 1); break; + case 'color': item.ramps.color = this.rampOrValue(spec.color, evaluate, 'color', null); break; + case 'rotation': item.base.rotation = evaluate(spec.rotation, 'rotation') ?? item.base.rotation; break; + case 'angularVelocity': item.angularVelocity = evaluate(spec.angularVelocity, 'angularVelocity') ?? 0; break; + case 'z': item.base.z += evaluate(spec.z, 'z') ?? 0; break; + case 'lifetime': item.lifetime = seconds(evaluate(spec.lifetime, 'lifetime'), `${this.path}.lifetime`); break; + case 'align': item.align = spec.align === true; break; + case 'behaviors': { + item.behaviors = (spec.behaviors ?? []).map((behavior, position) => createBehavior( + this.resolver.evaluate ? sampleBehavior(behavior, this.resolver, stream, `${this.path}.behaviors[${position}]`) : behavior, + { stream, noiseTable: this.noiseTable, path: `${this.path}.behaviors[${position}]` } + )); + break; + } + default: { + if (key === templateKey) item.node = this.instantiateItemNode?.(spec[templateKey], stream, `${this.path}.${templateKey}#${ordinal}`, this.repeatScope(index, count)); + break; + } + } + } + + // 18.2: a life ramp on an item with no lifetime has no normalized age. + for (const [field, ramp] of Object.entries(item.ramps)) { + if (ramp?.isRamp && item.lifetime === null) { + throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `A ${field} life ramp needs a lifetime.`, this.path); + } + } + if (item.align && (item.vx !== 0 || item.vy !== 0)) item.base.rotation = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS; + this.items.push(item); + return item; + } + + repeatScope(index, count) { + if (this.type !== 'repeater') return null; + // 18.5: `repeat.*` is construct-scoped and grants no 8.1 capability. + return { repeat: { index, count, fraction: count <= 1 ? 0 : index / (count - 1) } }; + } + + rampOrValue(value, evaluate, key, fallback) { + if (value === undefined) return { isRamp: false, value: fallback }; + if (isLifeRamp(value)) { + return { + isRamp: true, + from: evaluate(value.from, `${key}.from`), + to: evaluate(value.to, `${key}.to`), + curve: value.curve ?? 'linear' + }; + } + return { isRamp: false, value: evaluate(value, key) }; + } + + createRepeaterCopies() { + for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count); + } + + // ------------------------------------------------------------------------- + // Advance + // ------------------------------------------------------------------------- + + advance(dt, env = {}) { + this.time += dt; + if (this.type !== 'repeater' && !this.releasing) this.emit(dt); + const fieldEnv = { + ...env, + scene: this.scene, + duration: (value) => parseDuration(value, this.path), + alongPath: alongFlattened, + resolvePath: (value) => flattenPath(value, this.path), + sampleField: (id, x, y, t) => this.fields?.sample(id, x, y, t) + }; + const survivors = []; + for (const item of this.items) { + const age = this.time - item.birth; + if (item.lifetime !== null && age >= item.lifetime) continue; + if (!env.deferItems) this.advanceItem(item, dt, age, fieldEnv); + survivors.push(item); + } + this.items = survivors; + return fieldEnv; + } + + emit(dt) { + const reachedLimit = () => this.limit !== undefined && this.created >= this.limit; + // 18.4: a burst emits its whole count on the first tick at or after its `at`. + this.bursts.forEach((burst, index) => { + if (this.burstsFired.has(index) || this.time < burst.at) return; + this.burstsFired.add(index); + const count = Math.min(burst.count, this.limit === undefined ? burst.count : Math.max(0, this.limit - this.created)); + const skipped = Math.max(0, count - this.capacity); + this.ordinal += skipped; this.created += skipped; + for (let position = skipped; position < count; position += 1) { + if (reachedLimit()) return; + this.admit(() => this.createItem(position, burst.count, { burstIndex: index })); + } + }); + if (this.rate <= 0) return; + // 18.4: a fractional accumulator, so the cumulative count after t seconds + // is exactly floor(rate * t), with no drift and no tick-alignment term. + this.accumulator += this.rate * dt; + const due = Math.floor(this.accumulator); + this.accumulator -= due; + const count = Math.min(due, this.limit === undefined ? due : Math.max(0, this.limit - this.created)); + const skipped = Math.max(0, count - this.capacity); + this.ordinal += skipped; this.created += skipped; + for (let position = skipped; position < count; position += 1) { + if (reachedLimit()) return; + this.admit(() => this.createItem(this.ordinal, 1)); + } + } + + /** 18.2/18.4: at capacity the oldest live item is evicted and replaced. */ + admit(create) { + if (this.items.length >= this.capacity) { + let oldest = 0; + for (let index = 1; index < this.items.length; index += 1) { + if (this.items[index].ordinal < this.items[oldest].ordinal) oldest = index; + } + this.items.splice(oldest, 1); + } + create(); + } + + advanceItem(item, dt, age, env) { + // Fresh contributions are recomputed every tick; accumulated ones persist. + item.fresh.offset.x = 0; item.fresh.offset.y = 0; item.fresh.offset.z = 0; + item.fresh.rotation = 0; + item.scale.x = 1; item.scale.y = 1; + item.opacityMultiplier = 1; + + if (this.type !== 'repeater') { + const [fx, fy] = this.fields ? this.fields.sum(this.fieldIds, item.base.x + item.acc.offset.x, item.base.y + item.acc.offset.y, this.time) : [0, 0]; + // 18.2: v <- (v + a*dt) * (1 - drag)^dt, then p <- p + v*dt. + const decay = (1 - this.drag) ** dt; + item.vx = (item.vx + (this.acceleration.x + fx) * dt) * decay; + item.vy = (item.vy + (this.acceleration.y + fy) * dt) * decay; + item.vz = (item.vz + this.acceleration.z * dt) * decay; + item.base.x += item.vx * dt; + item.base.y += item.vy * dt; + item.base.z += item.vz * dt; + item.base.rotation += (item.angularVelocity ?? 0) * dt; + } + + for (const instance of item.behaviors) { + const bucket = instance.accumulating ? item.acc : item.fresh; + const view = { + base: item.base, points: item.points, channels: item.channels, + offset: bucket.offset, rotationOffset: 0, + scale: item.scale, opacityMultiplier: 1, + vx: item.vx, vy: item.vy, vz: item.vz + }; + advanceBehavior(instance, view, { dt, time: age, env }); + bucket.rotation += view.rotationOffset; + item.opacityMultiplier *= view.opacityMultiplier; + item.vx = view.vx; item.vy = view.vy; item.vz = view.vz; + } + + this.sampleTrail(item); + } + + /** 18.8: history is sampled on the logical clock, never on the frame. */ + sampleTrail(item) { + if (!this.trailSpec) return; + const length = this.trailSpec.length ?? 16; + if (length > SYSTEM_LIMITS.trailLength) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A trail length above ${SYSTEM_LIMITS.trailLength}.`, `${this.path}.trail`); + const interval = this.trailSpec.interval ? parseDuration(this.trailSpec.interval) / 1000 : null; + const last = item.trail.at(-1); + if (interval !== null && last && this.time - last.t < interval) return; + const position = this.itemPosition(item); + item.trail.push({ x: position.x, y: position.y, z: position.z, t: this.time }); + while (item.trail.length > length) item.trail.shift(); + } + + itemPosition(item) { + return { + x: item.base.x + item.acc.offset.x + item.fresh.offset.x, + y: item.base.y + item.acc.offset.y + item.fresh.offset.y, + z: item.base.z + item.acc.offset.z + item.fresh.offset.z, + rotation: item.base.rotation + item.acc.rotation + item.fresh.rotation + }; + } + + /** Normalized age drives every life ramp (18.2). */ + itemAge(item) { + if (item.lifetime === null || item.lifetime === 0) return 0; + return Math.max(0, Math.min(1, (this.time - item.birth) / item.lifetime)); + } + + rampValue(ramp, age) { + if (!ramp) return undefined; + if (!ramp.isRamp) return ramp.value; + const amount = curveValue(ramp.curve, age); + if (typeof ramp.from === 'string') return mixColor(parseColor(ramp.from), parseColor(ramp.to), amount); + return ramp.from + (ramp.to - ramp.from) * amount; + } + + // ------------------------------------------------------------------------- + // Links (18.8) + // ------------------------------------------------------------------------- + + /** + * 18.8: pairs are enumerated in ascending (lower index, higher index) order + * and de-duplicated, so a pair is drawn once regardless of rule. The live + * ordering is re-indexed densely each tick, so a death closes a chain's gap. + */ + linkPairs() { + if (!this.linksSpec) return []; + const ordered = [...this.items].sort((a, b) => a.ordinal - b.ordinal); + const positions = ordered.map((item) => this.itemPosition(item)); + const rule = this.linksSpec.rule ?? 'distance'; + const maxDistance = this.linksSpec.maxDistance; + const pairs = new Set(); + if (rule === 'index') { + const stride = this.linksSpec.stride ?? 1; + for (let index = 0; index + stride < ordered.length; index += 1) pairs.add(`${index}:${index + stride}`); + if (this.linksSpec.closed === true && ordered.length > 2) pairs.add(`0:${ordered.length - 1}`); + } else if (rule === 'nearest') { + const count = this.linksSpec.count ?? 1; + for (let index = 0; index < ordered.length; index += 1) { + const candidates = []; + for (let other = 0; other < ordered.length; other += 1) { + if (other === index) continue; + const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y); + if (maxDistance !== undefined && distance > maxDistance) continue; + candidates.push({ other, distance }); + } + // Equidistant neighbours break by ascending creation ordinal, which the + // dense live ordering already expresses. + candidates.sort((a, b) => (a.distance - b.distance) || (a.other - b.other)); + for (const candidate of candidates.slice(0, count)) { + const low = Math.min(index, candidate.other); + const high = Math.max(index, candidate.other); + pairs.add(`${low}:${high}`); + } + } + } else { + for (let index = 0; index < ordered.length; index += 1) { + for (let other = index + 1; other < ordered.length; other += 1) { + const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y); + if (distance <= (maxDistance ?? Infinity)) pairs.add(`${index}:${other}`); + } + } + } + const sorted = [...pairs].map((key) => key.split(':').map(Number)).sort((a, b) => (a[0] - b[0]) || (a[1] - b[1])); + const maxLinks = this.linksSpec.maxLinks ?? 256; + const kept = sorted.slice(0, maxLinks); + return kept.map(([low, high]) => { + const distance = Math.hypot(positions[high].x - positions[low].x, positions[high].y - positions[low].y); + const fade = this.linksSpec.fadeWithDistance === true && maxDistance ? Math.max(0, 1 - distance / maxDistance) : 1; + return { from: positions[low], to: positions[high], opacity: fade, distance }; + }); + } + + /** 17.6: a procedural system is one atomic sortable unit. */ + representativeDepth() { + if (this.items.length === 0) return 0; + let lowest = Infinity; + for (const item of this.items) lowest = Math.min(lowest, this.itemPosition(item).z); + return lowest; + } +} + +function sampleBehavior(behavior, resolver, stream, path) { + if (Array.isArray(behavior)) return behavior.map((entry, index) => isRecord(entry) || Array.isArray(entry) ? sampleBehavior(entry, resolver, stream, `${path}[${index}]`) : entry); + const result = {}; + for (const key of Object.keys(behavior)) { + const value = behavior[key]; + if (isRecord(value) && (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose') || (Object.hasOwn(value, 'op') && Array.isArray(value.args)))) { + result[key] = resolver.evaluate(value, stream, `${path}.${key}`); + } else if (isRecord(value)) { + result[key] = sampleBehavior(value, resolver, stream, `${path}.${key}`); + } else { + result[key] = value; + } + } + return result; +} + /* src/runtime/visual-validation.js */ // Structural and semantic validation of the `visuals` block, sections 17-19 at // Format Specification revision 0.8. @@ -3156,7 +4544,12 @@ function validateLayers(document, layers, errors, { validateValueSpec, pushError if (!isRecord(layer)) { fail('ERR_SCHEMA_VALIDATION', path, 'Layer must be an object.'); continue; } for (const field of Object.keys(layer)) if (!LAYER_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized layer field '${field}'.`); if (layer.opacity !== undefined) validateValueSpec(document, layer.opacity, `${path}.opacity`, errors); - if (layer.visible !== undefined) validateValueSpec(document, layer.visible, `${path}.visible`, errors); + if (layer.visible !== undefined) { + validateValueSpec(document, layer.visible, `${path}.visible`, errors); + if (typeof layer.visible !== 'boolean' && !isRecord(layer.visible)) { + fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.'); + } + } if (layer.blend !== undefined && !BLEND_MODES.includes(layer.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.blend`, `Unsupported blend mode '${layer.blend}'.`); if (layer.parallax !== undefined && !Number.isFinite(layer.parallax)) fail('ERR_TYPE_MISMATCH', `${path}.parallax`, 'parallax must be a finite number.'); } @@ -3204,7 +4597,12 @@ function validateEffects(document, effects, errors, { validateValueSpec, pushErr fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Effect '${entry.type}' declares no parameter '${field}'.`); } } - if (entry.enabled !== undefined) validateValueSpec(document, entry.enabled, `${path}.enabled`, errors); + if (entry.enabled !== undefined) { + validateValueSpec(document, entry.enabled, `${path}.enabled`, errors); + if (typeof entry.enabled !== 'boolean' && !isRecord(entry.enabled)) { + fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.'); + } + } for (const [parameter, range] of Object.entries(definition.numeric)) { const value = entry[parameter]; if (value === undefined) continue; @@ -3237,7 +4635,12 @@ function validateFields(document, fields, errors, { validateValueSpec, pushError if (!VISUAL_FIELD_TYPES.includes(field.type)) { fail('ERR_INVALID_FIELD_TYPE', `${path}.type`, `Unsupported field type '${field.type}'.`); continue; } const allowed = new Set([...FIELD_COMMON, ...FIELD_TYPE_FIELDS[field.type]]); for (const key of Object.keys(field)) if (!allowed.has(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Field type '${field.type}' declares no '${key}'.`); - if (field.enabled !== undefined) validateValueSpec(document, field.enabled, `${path}.enabled`, errors); + if (field.enabled !== undefined) { + validateValueSpec(document, field.enabled, `${path}.enabled`, errors); + if (typeof field.enabled !== 'boolean' && !isRecord(field.enabled)) { + fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.'); + } + } if (field.type === 'noise') { const mode = field.mode ?? 'curl'; if (!['curl', 'gradient', 'value'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported noise mode '${field.mode}'.`); @@ -3276,6 +4679,10 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa } if (system.visible !== undefined) validateValueSpec(document, system.visible, `${path}.visible`, errors); + // C11: Type-check boolean leaves (§2: no coercion). + if (system.visible !== undefined && typeof system.visible !== 'boolean' && !isRecord(system.visible)) { + fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.'); + } const lifecycle = system.lifecycle ?? 'persistent'; if (!LIFECYCLE_MODES.includes(lifecycle)) fail('ERR_SCHEMA_VALIDATION', `${path}.lifecycle`, `Unsupported lifecycle '${system.lifecycle}'.`); @@ -3290,15 +4697,50 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa fail('ERR_UNKNOWN_FIELD', `${path}.lifetime`, `A '${system.type}' system declares no lifetime; a spawned instance's duration is spawn.lifetime.`); } validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError }); + // C9: Enforce per-type required fields at import (§18.2/18.4). + if (system.type === 'particles' && system.render === undefined) { + fail('ERR_SCHEMA_VALIDATION', `${path}.render`, "A 'particles' system requires 'render'."); + } + if (system.type === 'emitter' && system.emit === undefined) { + fail('ERR_SCHEMA_VALIDATION', `${path}.emit`, "An 'emitter' system requires 'emit'."); + } + // C9: Enforce the static half of the unbounded-emission rule at import (§18.2/18.4). + const emitsStatically = (typeof system.rate === 'number' ? system.rate > 0 : system.rate !== undefined) || (Array.isArray(system.burst) && system.burst.length > 0); + if ((system.type === 'particles' || system.type === 'emitter') && emitsStatically && system.lifetime === undefined && system.limit === undefined) { + fail('ERR_UNBOUNDED_EMISSION', path, 'Emission declares neither a lifetime nor a limit.'); + } if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.'); + // 18.8: an emitter's population changes continuously, so its link set + // would have to be rebuilt every tick at the cost the ceiling prevents. + if (system.type === 'emitter' && system.links !== undefined) { + fail('ERR_UNKNOWN_FIELD', `${path}.links`, 'An emitter declares no links.'); + } + if (system.type === 'graphic') { + for (const field of ['render', 'emit', 'repeat', 'capacity', 'count', 'rate', 'burst', 'limit', 'distribution', 'velocity', 'acceleration', 'drag', 'trail', 'links']) { + if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A graphic system declares no '${field}'.`); + } + } + if (system.type === 'repeater') { + // C8: Reject emitter/particle-only fields on repeaters (§18.5). + for (const field of ['rate', 'burst', 'limit', 'capacity', 'trail']) { + if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A repeater system declares no '${field}'.`); + } + } + if (Array.isArray(system.behaviors) && system.behaviors.length > AUTHORING.behaviorsPerObject) { + fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors.`); + } + if (Array.isArray(system.behaviors)) { + // C2: Run semantic validation on system-level behavior arrays at import. + validateBehaviors(document, system, path, errors, { validateValueSpec, pushError }, { fields }); + } // The drawn object tree. 18.2, 18.4, and 18.5 each say the owning system // owns the item's placement, so those fields are unknown on the template. - if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError }); - if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] }); - if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] }); - if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'] }); + if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError }, { fields }); + if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields }); + if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields }); + if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'], fields }); if (Array.isArray(system.fields)) { if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`); system.fields.forEach((reference, index) => { @@ -3320,7 +4762,12 @@ function validateSpawn(document, system, lifecycle, path, errors, { validateValu } for (const field of ['lifetime', 'release']) { const value = spawn[field]; - if (value !== undefined && (typeof value !== 'string' || !DURATION_PATTERN.test(value))) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal.`); + if (value === undefined) continue; + // C5 (6.1): a duration is a literal string, or a non-negative finite number + // already expressed in milliseconds. Both forms reach `parseDuration`. + const literal = typeof value === 'string' && DURATION_PATTERN.test(value); + const milliseconds = typeof value === 'number' && Number.isFinite(value) && value >= 0; + if (!literal && !milliseconds) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal or a non-negative number of milliseconds.`); } if (spawn.ownership !== undefined && spawn.ownership !== 'persistent') fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.ownership`, "spawn.ownership accepts only 'persistent'."); if (spawn.cancelWithScenario !== undefined) { @@ -3380,7 +4827,7 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers) // so the strictly-increasing rule stays decidable at import. if (typeof point.at !== 'string' || !DURATION_PATTERN.test(point.at)) fail('ERR_INVALID_DURATION', `${pointPath}.at`, 'Point at must be a duration literal.'); else { - const milliseconds = durationMilliseconds(point.at); + const milliseconds = trackPointMilliseconds(point.at); if (milliseconds <= previous) fail('ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation point times must be strictly increasing.'); previous = milliseconds; } @@ -3392,12 +4839,23 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers) const resolution = resolveAutomationTarget(track.target, scope); if (resolution.code) fail(resolution.code, `${location}.target`, resolution.message); else if (written.has(resolution.key)) fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, `More than one track controls '${track.target}'.`); - else written.add(resolution.key); + else { + written.add(resolution.key); + if (scope.system?.type === 'graphic') { + const parts = track.target.split('.'); + let object = scope.system.content?.[parts.shift()]; + while (object?.children?.[parts[0]]) object = object.children[parts.shift()]; + const property = parts.join('.'); + if ((object?.behaviors ?? []).some(behavior => behaviorChannels(behavior).some(channel => channel === property || (channel === 'points[*].x' && /^points\[\d+\]\.x$/.test(property)) || (channel === 'points[*].y' && /^points\[\d+\]\.y$/.test(property))))) { + fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, 'A behavior and automation write the same channel.'); + } + } + } }); return { tracks: tracks.length, points }; } -function durationMilliseconds(value) { +function trackPointMilliseconds(value) { const [, scalar, unit] = DURATION_PATTERN.exec(value); return Number(scalar) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit]; } @@ -3530,7 +4988,9 @@ function validateVisualObjectMap(document, container, path, errors, helpers, opt if (Object.keys(container).length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'An empty object container is not a container.'); for (const [key, object] of Object.entries(container)) { if (!ID_PATTERN.test(key)) pushError(errors, 'ERR_INVALID_ID', `${path}.${key}`, `Object key '${key}' is invalid.`); - validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1 }); + // 18.6: a `morph` and a `follow-path` name a *sibling* key, so the + // container travels with the object being validated. + validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1, siblings: container }); } } @@ -3597,9 +5057,14 @@ function validateVisualObject(document, object, path, errors, helpers, options = } validateVisualStyle(document, object, type, path, errors, helpers); + // C11: Type-check boolean leaves for visual objects. + if (object.visible !== undefined && typeof object.visible !== 'boolean' && !isRecord(object.visible)) { + fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.'); + } + validateBehaviors(document, object, path, errors, helpers, options); if (type === 'group') { - validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [] }); + validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [], siblings: object.children }); if (object.style?.mask !== undefined && object.style.mask !== null) { if (!isRecord(object.children) || !Object.hasOwn(object.children, object.style.mask)) { fail('ERR_INVALID_REFERENCE', `${path}.style.mask`, `mask '${object.style.mask}' names no child of this group.`); @@ -3696,6 +5161,417 @@ function validatePathCommands(commands, path, errors, { pushError }) { }); } +// --------------------------------------------------------------------------- +// Behaviors (18.6) +// --------------------------------------------------------------------------- + +/** 18.6: the channels `oscillate`, `pulse`, and `twinkle` may name. */ +const BEHAVIOR_PROPERTY_CHANNELS = new Set([ + 'position.x', 'position.y', 'z', + 'transform.rotation', 'transform.scale.x', 'transform.scale.y', + 'style.opacity', 'style.strokeWidth', 'style.pointSize', + 'size.width', 'size.height', 'radius' +]); + +/** Which primitives actually have each conditional channel (18.6). */ +const CHANNEL_OWNERS = Object.freeze({ + 'size.width': new Set(['rectangle', 'rounded-rectangle']), + 'size.height': new Set(['rectangle', 'rounded-rectangle']), + radius: new Set(['ellipse', 'arc', 'ring', 'rounded-rectangle']), + 'style.pointSize': new Set(['point']) +}); + +/** 18.6: `point-wander` needs an owner with an addressable point list. */ +const POINT_LIST_TYPES = new Set(['spline', 'polyline', 'polygon']); + +/** 18.6/V15: morph interpolates a point list, so only these types participate. */ +const MORPH_TYPES = new Set(['polyline', 'polygon', 'spline']); +const MORPH_PATH_OPS = new Set(['move', 'line', 'close']); + +function pathOps(object) { + return (object.commands ?? []).map((command) => command?.op); +} + +function morphPointCount(object) { + if (object.type === 'path') return pathOps(object).filter((op) => op !== 'close').length; + return (object.points ?? []).length; +} + +function validateBehaviors(document, owner, path, errors, helpers, options = {}) { + const { pushError } = helpers; + const fail = (code, location, message) => pushError(errors, code, location, message); + const behaviors = owner.behaviors; + if (!Array.isArray(behaviors)) return; + behaviors.forEach((behavior, index) => { + const location = `${path}.behaviors[${index}]`; + if (!isRecord(behavior)) return fail('ERR_SCHEMA_VALIDATION', location, 'A behavior must be an object.'); + if (!VISUAL_BEHAVIOR_TYPES.includes(behavior.type)) { + return fail('ERR_INVALID_BEHAVIOR_TYPE', `${location}.type`, `'${behavior.type}' is outside Visual Behavior Set 0.1.`); + } + if (['oscillate', 'pulse', 'twinkle'].includes(behavior.type)) { + const channel = behavior.property ?? (behavior.type === 'twinkle' ? 'style.opacity' : undefined); + if (channel === undefined) fail('ERR_SCHEMA_VALIDATION', `${location}.property`, `A '${behavior.type}' behavior requires a property.`); + else if (!BEHAVIOR_PROPERTY_CHANNELS.has(channel)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `'${channel}' is outside the behavior channel set.`); + else { + const owners = CHANNEL_OWNERS[channel]; + if (owners && !owners.has(owner.type)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `A '${owner.type}' has no '${channel}'.`); + } + } + if (behavior.type === 'point-wander' && !POINT_LIST_TYPES.has(owner.type)) { + fail('ERR_INVALID_BEHAVIOR_TARGET', location, `point-wander requires a point list; a '${owner.type}' has none.`); + } + if (behavior.type === 'follow-path') { + const hasSpeed = behavior.speed !== undefined; + const hasDuration = behavior.duration !== undefined; + if (hasSpeed === hasDuration) fail('ERR_SCHEMA_VALIDATION', location, 'follow-path requires exactly one of speed and duration.'); + } + if (behavior.type === 'field-follow') { + if (!options.fields?.has(behavior.field)) fail('ERR_INVALID_REFERENCE', `${location}.field`, `Field '${behavior.field}' is not declared.`); + } + if (behavior.type === 'morph') { + const siblings = options.siblings ?? {}; + const target = siblings[behavior.to]; + if (!isRecord(target)) fail('ERR_INVALID_REFERENCE', `${location}.to`, `morph target '${behavior.to}' names no sibling.`); + else if (!MORPH_TYPES.has(owner.type) || !MORPH_TYPES.has(target.type)) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'morph is defined only between point-list geometries.'); + } else if (owner.type !== target.type) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same primitive type.'); + } else if (morphPointCount(owner) !== morphPointCount(target)) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same point count.'); + } else if (owner.type === 'spline' && (owner.mode ?? 'catmull-rom') !== (target.mode ?? 'catmull-rom')) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same spline mode.'); + } else if (owner.type === 'path') { + const ours = pathOps(owner); + const theirs = pathOps(target); + if (ours.some((op) => !MORPH_PATH_OPS.has(op)) || theirs.some((op) => !MORPH_PATH_OPS.has(op))) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'a curved path command carries control points a point list cannot express.'); + } else if (ours.join(',') !== theirs.join(',')) { + fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires an identical path command sequence.'); + } + } + // 18.6/V15: target points are read live, so a mutual pair has no order. + const back = (target?.behaviors ?? []).some((entry) => entry?.type === 'morph' && siblings[entry.to] === owner); + if (back) fail('ERR_CYCLIC_DEPENDENCY', location, 'two objects cannot morph onto each other.'); + } + }); +} + +/* src/runtime/cadence-validation.js */ +const CADENCE_CLASSES = Object.freeze([ + 'ambient', + 'routine', + 'intermittent', + 'occasional', + 'rare', + 'scenario' +]); + +const SOUND_USAGE = Object.freeze([ + 'automatic', + 'manual', + 'scenario' +]); + +const ALLOWED_CADENCE_FIELDS = Object.freeze([ + 'intensity', + 'minGap', + 'clocks' +]); + +const ALLOWED_CLOCK_CLASSES = Object.freeze([ + 'routine', + 'intermittent', + 'occasional', + 'rare' +]); + +const ALLOWED_SOUND_CADENCE_FIELDS = Object.freeze([ + 'class', + 'weight', + 'cooldown', + 'overlap', + 'when' +]); + +const ALLOWED_EVENT_FIELDS = Object.freeze([ + 'inputs', + 'actions' +]); + +const ALLOWED_EVENT_INPUT_FIELDS = Object.freeze([ + 'type', + 'default', + 'min', + 'max', + 'step', + 'values', + 'label', + 'unit' +]); + +function validateCadenceSubsystem(document, errors, helpers = {}) { + const pushError = helpers.pushError ?? ((errs, code, path, message) => errs.push({ code, path, message })); + const validateValueSpec = helpers.validateValueSpec ?? (() => {}); + const validateCondition = helpers.validateCondition ?? (() => {}); + + // 1. Validate top-level cadence container + if (document.cadence !== undefined) { + if (!isRecord(document.cadence)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence', 'cadence must be an object.'); + } else { + for (const field of Object.keys(document.cadence)) { + if (!ALLOWED_CADENCE_FIELDS.includes(field)) { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.${field}`, `Unrecognized field '${field}' in cadence.`); + } + } + + if (document.cadence.intensity !== undefined) { + validateValueSpec(document, document.cadence.intensity, '$.cadence.intensity', errors); + } + + if (document.cadence.minGap !== undefined) { + if (typeof document.cadence.minGap !== 'string' || !DURATION_PATTERN.test(document.cadence.minGap)) { + pushError(errors, 'ERR_INVALID_DURATION', '$.cadence.minGap', 'minGap must be a valid DurationSpec string.'); + } + } + + if (document.cadence.clocks !== undefined) { + if (!isRecord(document.cadence.clocks)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence.clocks', 'clocks must be an object.'); + } else { + for (const [className, range] of Object.entries(document.cadence.clocks)) { + if (!ALLOWED_CLOCK_CLASSES.includes(className)) { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}`, `Unrecognized cadence clock class '${className}'.`); + } else if (!isRecord(range)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range must be an object.'); + } else { + for (const field of Object.keys(range)) { + if (field !== 'min' && field !== 'max') { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}.${field}`, `Unrecognized field '${field}' in clock range.`); + } + } + if (range.min === undefined || range.max === undefined) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range requires min and max.'); + } else { + const validMin = typeof range.min === 'string' && DURATION_PATTERN.test(range.min); + const validMax = typeof range.max === 'string' && DURATION_PATTERN.test(range.max); + if (!validMin) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.min`, 'min must be a valid DurationSpec.'); + if (!validMax) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.max`, 'max must be a valid DurationSpec.'); + if (validMin && validMax) { + try { + const minMs = parseDuration(range.min); + const maxMs = parseDuration(range.max); + if (minMs > maxMs) { + pushError(errors, 'ERR_INVALID_RANGE_ORDER', `$.cadence.clocks.${className}`, `Clock min (${range.min}) cannot exceed max (${range.max}).`); + } + } catch (e) { + pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}`, e.message); + } + } + } + } + } + } + } + } + } + + // 2. Validate sound cadence metadata + if (document.sounds !== undefined && isRecord(document.sounds)) { + for (const [soundId, sound] of Object.entries(document.sounds)) { + if (!isRecord(sound)) continue; + if (sound.cadence !== undefined) { + if (!isRecord(sound.cadence)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence`, 'cadence must be an object.'); + continue; + } + for (const field of Object.keys(sound.cadence)) { + if (!ALLOWED_SOUND_CADENCE_FIELDS.includes(field)) { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.sounds.${soundId}.cadence.${field}`, `Unrecognized sound cadence field '${field}'.`); + } + } + if (sound.cadence.class === undefined) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, 'cadence.class is required.'); + } else if (!CADENCE_CLASSES.includes(sound.cadence.class)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, `Invalid cadence class '${sound.cadence.class}'.`); + } + + if (sound.cadence.weight !== undefined) { + if (typeof sound.cadence.weight === 'number') { + if (!Number.isFinite(sound.cadence.weight) || sound.cadence.weight < 0) { + pushError(errors, 'ERR_OUT_OF_BOUNDS', `$.sounds.${soundId}.cadence.weight`, 'Cadence weight cannot be negative.'); + } + } else { + validateValueSpec(document, sound.cadence.weight, `$.sounds.${soundId}.cadence.weight`, errors); + } + } + + if (sound.cadence.cooldown !== undefined) { + if (typeof sound.cadence.cooldown !== 'string' || !DURATION_PATTERN.test(sound.cadence.cooldown)) { + pushError(errors, 'ERR_INVALID_DURATION', `$.sounds.${soundId}.cadence.cooldown`, 'cooldown must be a valid DurationSpec.'); + } + } + + if (sound.cadence.overlap !== undefined && typeof sound.cadence.overlap !== 'boolean') { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.overlap`, 'overlap must be a boolean.'); + } + + if (sound.cadence.when !== undefined) { + validateCondition(document, sound.cadence.when, `$.sounds.${soundId}.cadence.when`, errors); + } + } + } + } + + // 3. Validate events container & event definitions + const eventGraph = new Map(); + if (document.events !== undefined) { + if (!isRecord(document.events)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.events', 'events must be an object.'); + } else { + for (const [eventId, eventDef] of Object.entries(document.events)) { + if (!ID_PATTERN.test(eventId)) { + pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}`, `Invalid event identifier '${eventId}'.`); + } + if (!isRecord(eventDef)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}`, 'Event definition must be an object.'); + continue; + } + + eventGraph.set(eventId, new Set()); + + for (const field of Object.keys(eventDef)) { + if (!ALLOWED_EVENT_FIELDS.includes(field)) { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.${field}`, `Unrecognized field '${field}' in event.`); + } + } + + if (eventDef.inputs !== undefined) { + if (!isRecord(eventDef.inputs)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs`, 'Event inputs must be an object.'); + } else { + for (const [inputId, inputSpec] of Object.entries(eventDef.inputs)) { + if (!ID_PATTERN.test(inputId)) { + pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}.inputs.${inputId}`, `Invalid input identifier '${inputId}'.`); + } + if (!isRecord(inputSpec)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}`, 'Input spec must be an object.'); + } else { + for (const field of Object.keys(inputSpec)) { + if (!ALLOWED_EVENT_INPUT_FIELDS.includes(field)) { + pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.inputs.${inputId}.${field}`, `Unrecognized field '${field}' in input spec.`); + } + } + if (!PARAMETER_TYPES.includes(inputSpec.type)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}.type`, `Invalid input type '${inputSpec.type}'.`); + } + } + } + } + } + + if (!Array.isArray(eventDef.actions) || eventDef.actions.length === 0) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.actions`, 'actions must be a non-empty array.'); + } else { + validateEventActions(document, eventDef.actions, `$.events.${eventId}.actions`, eventId, errors, helpers, eventGraph, eventDef.inputs ?? {}); + } + } + + // 4. Static cycle detection in event dependency graph + const visiting = new Set(); + const visited = new Set(); + const detectCycle = (node, trail) => { + if (visiting.has(node)) { + const cycleTrail = [...trail, node].join(' -> '); + pushError(errors, 'ERR_CYCLIC_DEPENDENCY', `$.events.${node}`, `Cyclic event invocation detected: ${cycleTrail}.`); + return; + } + if (visited.has(node)) return; + visiting.add(node); + const targets = eventGraph.get(node) ?? new Set(); + for (const target of targets) { + if (eventGraph.has(target)) { + detectCycle(target, [...trail, node]); + } + } + visiting.delete(node); + visited.add(node); + }; + + for (const eventId of eventGraph.keys()) { + detectCycle(eventId, []); + } + } + } +} + +function validateEventActions(document, actions, path, owningEventId, errors, helpers, eventGraph, localInputs) { + const pushError = helpers.pushError ?? ((errs, code, p, m) => errs.push({ code, path: p, message: m })); + const validateValueSpec = helpers.validateValueSpec ?? (() => {}); + const validateCondition = helpers.validateCondition ?? (() => {}); + + actions.forEach((action, index) => { + const actionPath = `${path}[${index}]`; + if (!isRecord(action)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', actionPath, 'Action must be an object.'); + return; + } + if (typeof action.type !== 'string') { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.type`, 'Action requires a type.'); + return; + } + if (action.id !== undefined && !ID_PATTERN.test(action.id)) { + pushError(errors, 'ERR_INVALID_ID', `${actionPath}.id`, `Invalid action identifier '${action.id}'.`); + } + if (action.when !== undefined) { + validateCondition(document, action.when, `${actionPath}.when`, errors); + } + if (action.chance !== undefined) { + if (typeof action.chance !== 'number' || !Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) { + pushError(errors, 'ERR_OUT_OF_BOUNDS', `${actionPath}.chance`, 'Action chance must be between 0 and 1.'); + } + } + if (action.critical !== undefined && typeof action.critical !== 'boolean') { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.critical`, 'critical must be a boolean.'); + } + + if (action.type === 'event') { + if (typeof action.event !== 'string') { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.event`, 'Event action requires an event identifier.'); + } else { + if (!document.events?.[action.event]) { + pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.event`, `Referenced event '${action.event}' does not exist.`); + } else { + eventGraph.get(owningEventId)?.add(action.event); + } + if (action.with !== undefined) { + if (!isRecord(action.with)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.'); + } else { + for (const [paramName, valSpec] of Object.entries(action.with)) { + validateValueSpec(document, valSpec, `${actionPath}.with.${paramName}`, errors, { inputs: localInputs }); + } + } + } + } + } else if (action.type === 'sound') { + if (typeof action.sound !== 'string') { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.sound`, 'Sound action requires a sound identifier.'); + } else { + if (!document.sounds?.[action.sound]) { + pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.sound`, `Referenced sound '${action.sound}' does not exist.`); + } + if (action.with !== undefined && !isRecord(action.with)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.'); + } + if (action.ownership !== undefined && !['performance', 'scenario', 'persistent'].includes(action.ownership)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.ownership`, `Invalid sound action ownership '${action.ownership}'.`); + } + } + } + }); +} + /* src/runtime/validator.js */ function issue(code, path, message, context = {}) { return { code, path, message, ...context }; @@ -3772,6 +5648,7 @@ function validateExhibit(document, filename = 'document.xzbt') { validateBindings(document, errors); validateAudioSubsystem(document, errors, { validateValueSpec, pushError }); validateVisualSubsystem(document, errors, { validateValueSpec, pushError }); + validateCadenceSubsystem(document, errors, { validateValueSpec, validateCondition, pushError }); return { valid: errors.length === 0, errors, warnings: [], filename }; } @@ -4315,6 +6192,16 @@ class ResolutionEngine { this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default); } for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial); + this.visualEffects = (document.visuals?.effects ?? []).map((effect, index) => { + const stream = this.rng.stream('visual', `effects[${index}]`); + const resolved = { type: effect.type }; + for (const [key, value] of Object.entries(effect)) { + if (key === 'type') continue; + resolved[key] = this.valueResolver.evaluate(value, stream, `visuals.effects[${index}].${key}`); + if (POST_EFFECTS[effect.type]?.numeric[key]) this.visualValues.set(`visuals.effects[${index}].${key}`, resolved[key]); + } + return resolved; + }); // Bus ValueSpecs are sampled once, lazily, so references may resolve through // the same dependency graph as bindings (and cycles are diagnosed there). this.resolveAll(); @@ -4570,6 +6457,256 @@ class ResolutionEngine { } } +/* src/runtime/visual-motion.js */ +// Object-local behavior state, shared by graphics and procedural render trees. + + + + + + +function visualSetPath(object, path, value) { + const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.'); + const last = keys.pop(); + for (const key of keys) object = object[key] ??= {}; + object[last] = value; +} + +function visualGetPath(object, path) { + return path.replace(/\[(\d+)\]/g, '.$1').split('.').reduce((value, key) => value?.[key], object); +} + +function initializeVisualMotion(node, context) { + node.baseRaw = structuredClone(node.raw); + node.motion = { + base: { x: 0, y: 0, z: 0, rotation: 0 }, + acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 }, + fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 }, + scale: { x: 1, y: 1 }, opacityMultiplier: 1, channels: {}, + vx: 0, vy: 0, vz: 0, trail: [], points: null, + behaviors: (node.raw.behaviors ?? []).map((spec, index) => createBehavior(spec, { + stream: context.stream, noiseTable: context.noiseTable, path: `${node.path}.behaviors[${index}]` + })) + }; +} + +function advanceVisualMotion(node, dt, time, scene, fields, siblings = [node], visited = new Set()) { + if (visited.has(node)) return; + visited.add(node); + for (const behavior of node.baseRaw.behaviors ?? []) if (behavior.type === 'morph') { + const target = siblings.find(sibling => sibling.key === behavior.to); + if (target) advanceVisualMotion(target, dt, time, scene, fields, siblings, visited); + } + const item = node.motion; + const raw = structuredClone(node.baseRaw); + item.base = { x: raw.position?.x ?? 0, y: raw.position?.y ?? 0, z: raw.z ?? 0, rotation: raw.transform?.rotation ?? 0 }; + const pointsOf = value => value?.points ?? value?.commands?.filter(command => command.op !== 'close'); + item.points = pointsOf(raw) ?? null; + item.channels = {}; + ProceduralSystem.prototype.advanceItem.call({ type: 'repeater', sampleTrail() {} }, item, dt, time, { + scene, duration: parseDuration, alongPath: alongFlattened, + resolvePath: key => flattenPath(siblings.find(sibling => sibling.key === key)?.raw.commands ?? key), + resolveMorphTarget: key => pointsOf(siblings.find(sibling => sibling.key === key)?.raw), + sampleField: (id, x, y, t) => fields?.sample(id, x, y, t) + }); + // C3: Integrate velocity for graphic-object hosts into accumulated displacement. + item.acc.offset.x += (item.vx ?? 0) * dt; + item.acc.offset.y += (item.vy ?? 0) * dt; + item.acc.offset.z += (item.vz ?? 0) * dt; + for (const [key, value] of Object.entries(item.channels)) visualSetPath(raw, key, value); + raw.position = { x: item.base.x + item.acc.offset.x + item.fresh.offset.x, y: item.base.y + item.acc.offset.y + item.fresh.offset.y }; + raw.z = item.base.z + item.acc.offset.z + item.fresh.offset.z; + const transform = raw.transform ??= {}; + transform.rotation = item.base.rotation + item.acc.rotation + item.fresh.rotation; + transform.scale = { x: (transform.scale?.x ?? 1) * item.scale.x, y: (transform.scale?.y ?? 1) * item.scale.y }; + node.raw = raw; + node.position = raw.position; + node.z = raw.z; + node.translateZ = transform.translate?.z ?? 0; + node.matrix = localMatrix({ ...transform, position: raw.position, + translate: { x: 0, y: 0, ...transform.translate }, origin: { x: 0, y: 0, ...transform.origin }, skew: { x: 0, y: 0, ...transform.skew } }); + node.style = { ...node.style, ...raw.style }; + if (['line', 'polyline', 'arc', 'bezier'].includes(node.type)) node.style.fill = null; + node.ownOpacity = (raw.style?.opacity ?? 1) * item.opacityMultiplier; + node.geometry = primitiveSubpaths(raw, node.path); + if (raw.lifetime !== undefined && time * 1000 >= parseDuration(raw.lifetime)) node.visible = false; + for (const child of node.children) { + child.style = { ...child.style }; + for (const [key, value] of Object.entries(node.style)) if (!['opacity', 'clip', 'mask'].includes(key) && child.baseRaw.style?.[key] === undefined) child.style[key] = value; + advanceVisualMotion(child, dt, time, scene, fields, node.children, visited); + } +} + +/* src/runtime/visual-automation.js */ +function visualLocalTracks(definitions, root, evaluate, warn) { + return (definitions ?? []).map(definition => { + const track = sampleAutomationTrack(definition, evaluate, warn); + let object = root, property = definition.target; + if (Array.isArray(root)) { + const parts = property.split('.'); + const first = parts.shift(); + object = root.find(node => node.key === first); + if (!object) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${first}' not found.`); + } + while (object?.children?.some(node => node.key === parts[0])) { + const child = parts.shift(); + object = object.children.find(node => node.key === child); + } + if (!object) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${definition.target}' not found.`); + } + property = parts.join('.'); + object = object.baseRaw; + } + const authored = visualGetPath(object, property); + const base = authored ?? (/opacity|scale|zoom/.test(property) ? 1 : 0); + return { track, object, property, base }; + }); +} + +function advanceVisualTracks(tracks, milliseconds) { + for (const { track, object, property, base } of tracks ?? []) { + const value = resolveNumericStages(base, { automation: lower => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) }); + visualSetPath(object, property, property === 'drag' ? Math.max(0, Math.min(1, value)) : property === 'rate' ? Math.max(0, value) : value); + } +} + +/* src/runtime/visual-lifecycle.js */ +const VISUAL_TRANSITIONS = Object.freeze({ + CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'], + RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: [] +}); + +class VisualInstance { + constructor({ id, template, system, at, lifetime = null, release = 0, owner = 'root', origin = null, cancelWithScenario = true }) { + Object.assign(this, { id, template, system, at, lifetime, release, owner, origin, cancelWithScenario }); + this.state = 'CREATED'; this.factor = 1; this.history = ['CREATED']; + } + transition(next) { + if (!VISUAL_TRANSITIONS[this.state].includes(next)) throw new RuntimeFault('ERR_INVALID_STATE', `Cannot transition ${this.state} to ${next}.`); + this.state = next; this.history.push(next); + if (next === 'DISPOSED' || next === 'FAILED') { + this.system.objects.length = 0; + if (this.system.procedural) this.system.procedural.items.length = 0; + this.system.tracks = []; + } + } + remove(now, cleanup = false) { + if (this.state === 'CREATED') { this.transition('FINISHED'); return; } + if (this.state !== 'ACTIVE') return; + this.transition('RELEASING'); this.releaseAt = now; + this.releaseDuration = cleanup ? Math.min(this.release, 5000) : this.release; + if (this.system.procedural) this.system.procedural.releasing = true; + if (this.releaseDuration === 0) this.factor = 0; + } + advance(now) { + if (this.cleanupDeadline !== undefined && now > this.cleanupDeadline && !['DISPOSED', 'FAILED'].includes(this.state)) return 'forced'; + if (this.state === 'CREATED') this.transition('ACTIVE'); + if (this.state === 'ACTIVE' && this.lifetime !== null && now - this.at >= this.lifetime) { this.remove(now); return; } + if (this.state === 'RELEASING') { + this.factor = this.releaseDuration === 0 ? 0 : Math.max(0, 1 - (now - this.releaseAt) / this.releaseDuration); + if (this.factor === 0) this.transition('FINISHED'); + } + if (this.state === 'FINISHED') this.transition('DISPOSED'); + } +} + +/* src/runtime/visual-effects.js */ +// Frame-only post processing. No scene or procedural random stream is accessible here. + +function visualBoxBlur(source, width, height, radius) { + const maxDeviceRadius = Math.min(Math.max(width, height), 128); + const r = Math.min(maxDeviceRadius, Math.max(0, Math.round(radius))); + if (!r) return new Uint8ClampedArray(source); + const horizontal = new Float32Array(source.length), output = new Uint8ClampedArray(source.length); + // Sliding windows keep work linear in pixel count even for large projected radii. + for (let axis = 0; axis < 2; axis += 1) { + const input = axis ? horizontal : source, target = axis ? output : horizontal; + const major = axis ? width : height, minor = axis ? height : width; + const offset = (a, b, c) => ((axis ? b * width + a : a * width + b) * 4 + c); + for (let a = 0; a < major; a += 1) for (let c = 0; c < 4; c += 1) { + let sum = 0; + for (let k = -r; k <= r; k += 1) sum += input[offset(a, Math.max(0, Math.min(minor - 1, k)), c)]; + for (let b = 0; b < minor; b += 1) { + target[offset(a, b, c)] = sum / (2 * r + 1); + sum += input[offset(a, Math.min(minor - 1, b + r + 1), c)] - input[offset(a, Math.max(0, b - r), c)]; + } + } + } + return output; +} + +function applyVisualEffect(pixels, width, height, effect, seconds = 0) { + const output = new Uint8ClampedArray(pixels); + const color = parseColor(effect.color ?? '#000000'); + if (effect.type === 'blur') return visualBoxBlur(pixels, width, height, effect.deviceRadius ?? effect.radius ?? 4); + if (effect.type === 'bloom') { + const bright = new Uint8ClampedArray(pixels.length); + for (let i = 0; i < pixels.length; i += 4) { + const luminance = (pixels[i] * 0.2126 + pixels[i + 1] * 0.7152 + pixels[i + 2] * 0.0722) / 255; + const contribution = luminance > (effect.threshold ?? 0.7) ? 1 : 0; + for (let c = 0; c < 3; c += 1) bright[i + c] = pixels[i + c] * contribution; + bright[i + 3] = pixels[i + 3]; + } + const glow = visualBoxBlur(bright, width, height, effect.deviceRadius ?? effect.radius ?? 8); + for (let i = 0; i < pixels.length; i += 4) for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, pixels[i + c] + glow[i + c] * (effect.intensity ?? 0.6))); + return output; + } + const theta = (effect.hueRotate ?? 0) * Math.PI / 180, cs = Math.cos(theta), sn = Math.sin(theta); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const i = (y * width + x) * 4; + let amount = effect.amount ?? 0, target = [color.r, color.g, color.b]; + switch (effect.type) { + case 'fade': break; + case 'vignette': { + const d = Math.hypot((x + 0.5 - width / 2) / (width / 2), (y + 0.5 - height / 2) / (height / 2)) / Math.SQRT2; + const edge = Math.max(0.000001, effect.softness ?? 0.5); + const u = Math.max(0, Math.min(1, (d - (effect.radius ?? 0.75)) / edge)); + amount = (effect.amount ?? 0.5) * u * u * (3 - 2 * u); break; + } + case 'scanlines': { + const phase = ((y / (effect.spacing ?? 3) + seconds * (effect.speed ?? 0)) % 1 + 1) % 1; + amount = phase < (effect.thickness ?? 0.5) ? (effect.amount ?? 0.3) : 0; + target = [0, 0, 0]; break; + } + case 'grain': { + const cell = effect.scale ?? 1; + let hash = Math.imul(Math.floor(x / cell) + 1, 374761393) ^ Math.imul(Math.floor(y / cell) + 1, 668265263) ^ Math.imul(Math.floor(seconds * (effect.speed ?? 24)), 1274126177); + hash = Math.imul(hash ^ (hash >>> 13), 1274126177); + const noise = ((hash ^ (hash >>> 16)) >>> 0) / 4294967296 - 0.5; + for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] + noise * 255 * (effect.amount ?? 0.15); + continue; + } + case 'color-adjust': { + const [r, g, b] = [pixels[i], pixels[i + 1], pixels[i + 2]].map(v => v / 255); + const hue = [ + (0.213 + cs * 0.787 - sn * 0.213) * r + (0.715 - cs * 0.715 - sn * 0.715) * g + (0.072 - cs * 0.072 + sn * 0.928) * b, + (0.213 - cs * 0.213 + sn * 0.143) * r + (0.715 + cs * 0.285 + sn * 0.140) * g + (0.072 - cs * 0.072 - sn * 0.283) * b, + (0.213 - cs * 0.213 - sn * 0.787) * r + (0.715 - cs * 0.715 + sn * 0.715) * g + (0.072 + cs * 0.928 + sn * 0.072) * b + ]; + const l = hue[0] * 0.2126 + hue[1] * 0.7152 + hue[2] * 0.0722; + for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, (((l + (hue[c] - l) * (effect.saturation ?? 1)) * (effect.brightness ?? 1) - 0.5) * (effect.contrast ?? 1) + 0.5) * 255)); + continue; + } + default: throw new Error(`Unavailable visual effect '${effect.type}'.`); + } + for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] * (1 - amount) + target[c] * amount; + } + return output; +} + +function renderVisualEffects(context, plan, warn = () => {}) { + for (const effect of plan.effects ?? []) { + try { + const frame = context.getImageData(0, 0, plan.backing.width, plan.backing.height); + frame.data.set(applyVisualEffect(frame.data, frame.width, frame.height, effect, plan.logicalMilliseconds / 1000)); + context.putImageData(frame, 0, 0); + if (['blur', 'bloom'].includes(effect.type)) warn(effect, 'Separable blur approximation.'); + } catch { warn(effect, 'Effect unavailable on this rendering surface; skipped.'); } + } +} + /* src/runtime/visual-engine.js */ // Slice 4d — the visual renderer core. // @@ -4590,6 +6727,12 @@ class ResolutionEngine { + + + + + + const RUNTIME = VISUAL_LIMITS.runtime; const DEFAULT_STYLE = Object.freeze({ @@ -4625,15 +6768,116 @@ function isValueSpec(value) { * order, from one stream — the sampling rule 17.14 shares with 14.4 and 9.3. * Everything that is not a ValueSpec is copied through unchanged. */ -function sampleTree(raw, resolver, stream, path = '$') { - if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`)); +function sampleTree(raw, resolver, stream, path = '$', scope = null) { + if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`, scope)); if (!isRecord(raw)) return raw; - if (isValueSpec(raw)) return resolver.evaluate(raw, stream, path); + if (isValueSpec(raw)) { + // 18.1/18.5: `inputs.*` and `repeat.*` are construct-scoped. They are not + // section 1.3 document namespaces and grant no 8.1 capability, so they are + // resolved here rather than through the document resolver. + const scoped = scopedReference(raw, scope, path); + if (scoped !== undefined) return scoped; + if (scope) { + const substitute = value => { + if (Array.isArray(value)) return value.map(substitute); + if (!isRecord(value)) return value; + const scoped = scopedReference(value, scope, path); + return scoped !== undefined ? scoped : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, substitute(child)])); + }; + return resolver.evaluate(substitute(raw), stream, path); + } + return resolver.evaluate(raw, stream, path); + } const result = {}; - for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`); + for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`, scope); return result; } +function scopedReference(spec, scope, path) { + const reference = spec.ref; + if (typeof reference !== 'string') return undefined; + if (reference.startsWith('inputs.')) { + const key = reference.slice('inputs.'.length); + if (!scope?.inputs || !Object.hasOwn(scope.inputs, key)) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' resolves to no declared input in this scope.`, path); + } + return scope.inputs[key]; + } + if (reference.startsWith('repeat.')) { + const key = reference.slice('repeat.'.length); + if (!scope?.repeat || !Object.hasOwn(scope.repeat, key)) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' is legal only inside a repeater's repeat block.`, path); + } + return scope.repeat[key]; + } + return undefined; +} + +/** + * 18.1: a `component` object instantiates its sub-assembly in place and behaves + * as a group whose children are the component's `content`. Expanding it into a + * group here is what lets one instantiation path serve both. + */ +function expandComponent(object, context, path, componentDepth) { + const definition = context.components?.[object.component]; + if (!definition) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual component '${object.component}' is declared.`, path); + if (componentDepth > VISUAL_LIMITS.authoring.componentNesting) { + throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component nesting deeper than ${VISUAL_LIMITS.authoring.componentNesting} levels.`, path); + } + if (context.componentTrail.includes(object.component)) { + throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component '${object.component}' instantiates itself.`, path); + } + const declared = definition.parameters ?? {}; + const inputs = {}; + for (const key of Object.keys(object.inputs ?? {})) { + if (!Object.hasOwn(declared, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component '${object.component}' declares no parameter '${key}'.`, `${path}.inputs.${key}`); + } + // Sampled in declaration order, so a component's inputs consume the stream + // reproducibly whatever order the instantiating site wrote them in. + for (const [key, parameter] of Object.entries(declared)) { + const supplied = object.inputs?.[key]; + if (supplied === undefined && !Object.hasOwn(parameter, 'default')) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component parameter '${key}' has no default and no supplied value.`, `${path}.inputs.${key}`); + } + inputs[key] = sampleTree(supplied === undefined ? parameter.default : supplied, context.resolver, context.stream, `${path}.inputs.${key}`, context.scope); + if (!inputValueMatches(parameter.type, inputs[key])) { + throw new RuntimeFault('ERR_TYPE_MISMATCH', `Component input '${key}' is not a ${parameter.type}.`, `${path}.inputs.${key}`); + } + if (parameter.type === 'number') { + const below = parameter.min !== undefined && inputs[key] < parameter.min; + const above = parameter.max !== undefined && inputs[key] > parameter.max; + if (below || above) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Component input '${key}' is outside its declared range.`, `${path}.inputs.${key}`); + } + } + return { + group: { + type: 'group', + position: object.position, + z: object.z, + transform: mergeTransforms(definition.transform, object.transform), + style: { ...(definition.style ?? {}), ...(object.style ?? {}) }, + behaviors: [...(definition.behaviors ?? []), ...(object.behaviors ?? [])], + visible: object.visible, + lifetime: object.lifetime, + children: definition.content + }, + inputs, + component: object.component + }; +} + +function inputValueMatches(type, value) { + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'boolean') return typeof value === 'boolean'; + return typeof value === 'string'; +} + +function mergeTransforms(componentTransform, objectTransform) { + if (!componentTransform) return objectTransform; + if (!objectTransform) return componentTransform; + return { ...componentTransform, ...objectTransform }; +} + function mergeStyle(inherited, own) { if (!isRecord(own)) return inherited; const merged = { ...inherited }; @@ -4647,7 +6891,7 @@ function mergeStyle(inherited, own) { return merged; } -function vector(value, fallback = 0) { +function vectorOf(value, fallback = 0) { return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback }; } @@ -4655,10 +6899,45 @@ function vector(value, fallback = 0) { function instantiateObject(key, raw, context, parentPath, depth) { if (!isRecord(raw)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A visual object must be an object.', parentPath); const path = parentPath ? `${parentPath}.${key}` : key; + // 18.1: component nesting and group nesting are counted independently, each + // bounded at 8, so the group a component expands into costs no group depth. + const isComponentRoot = context.pendingComponentRoot === true; + context.pendingComponentRoot = false; if (depth > VISUAL_LIMITS.authoring.groupNesting) { throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path); } - const resolved = sampleTree(raw, context.resolver, context.stream, path); + if (raw.type === 'component') { + const expansion = expandComponent(raw, context, path, (context.componentDepth ?? 0) + 1); + const previousScope = context.scope; + const previousTrail = context.componentTrail; + const previousDepth = context.componentDepth ?? 0; + context.scope = { ...(context.scope ?? {}), inputs: expansion.inputs }; + context.componentTrail = [...previousTrail, expansion.component]; + context.componentDepth = previousDepth + 1; + context.pendingComponentRoot = true; + try { + // 18.1: the expansion path, not the component ID, is the stable instance + // key, so two instances sample independently and reproducibly. + const node = instantiateObject(key, expansion.group, context, parentPath, depth); + node.component = expansion.component; + return node; + } finally { + context.scope = previousScope; + context.componentTrail = previousTrail; + context.componentDepth = previousDepth; + } + } + // 17.14: an object's own fields are sampled in document order; its + // `children` are instantiated after them, whatever position the container key + // holds, because a group's style scope must exist before its descendants can + // inherit it. Sampling the whole subtree here and then instantiating the + // children from the raw tree would consume the stream twice. + const own = {}; + for (const key of Object.keys(raw)) { + if (key === 'children') continue; + own[key] = sampleTree(raw[key], context.resolver, context.stream, `${path}.${key}`, context.scope); + } + const resolved = own; const transform = resolved.transform ?? {}; const node = { key, @@ -4671,11 +6950,11 @@ function instantiateObject(key, raw, context, parentPath, depth) { visible: resolved.visible ?? true, matrix: localMatrix({ position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 }, - translate: vector(transform.translate), + translate: vectorOf(transform.translate), rotation: transform.rotation ?? 0, - skew: vector(transform.skew), + skew: vectorOf(transform.skew), scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 }, - origin: vector(transform.origin) + origin: vectorOf(transform.origin) }), style: mergeStyle(context.style, resolved.style), // 17.12: opacity multiplies down the tree rather than being inherited. @@ -4685,8 +6964,8 @@ function instantiateObject(key, raw, context, parentPath, depth) { if (resolved.type === 'group') { const previousStyle = context.style; context.style = node.style; - for (const childKey of Object.keys(resolved.children ?? {})) { - node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + 1)); + for (const childKey of Object.keys(raw.children ?? {})) { + node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + (isComponentRoot ? 0 : 1))); } context.style = previousStyle; } @@ -4695,6 +6974,7 @@ function instantiateObject(key, raw, context, parentPath, depth) { // fillable siblings. Inheritance never turns into a validation error. if (STROKE_ONLY.has(node.type)) node.style = { ...node.style, fill: null }; node.geometry = primitiveSubpaths(resolved, path); + initializeVisualMotion(node, context); return node; } @@ -4774,6 +7054,10 @@ class VisualEngine { this.cadence = new VisualDiagnosticCadence(diagnostics); this.visuals = document?.visuals ?? null; this.systems = []; + this.time = 0; + this.instances = new Map(); + this.spawnOrdinals = new Map(); + this.unregister = []; this.layers = []; this.deferred = []; this.deferredKeys = new Set(); @@ -4789,36 +7073,243 @@ class VisualEngine { : [{ id: null, implicit: true }]; const resolver = this.resolution?.valueResolver; + // 18.7: the four noise-using behaviors share one exhibit-wide permutation + // table; per-object independence comes from their offset pairs. + this.noiseTable = this.rng ? behaviorNoiseTable(this.rng) : null; + this.fieldSet = new FieldSet(sampleTree(visuals.fields ?? {}, resolver, this.rng?.stream('visual', 'fields')), { rng: this.rng }); this.systems = []; this.deferred = []; this.deferredKeys = new Set(); - let index = 0; for (const [id, system] of Object.entries(visuals.systems ?? {})) { - // 19.2: a spawned system is a template. It is validated and counted at - // import and is not instantiated or drawn at activation. - if ((system.lifecycle ?? 'persistent') === 'spawned') { - this.defer(id, 'spawned template, instantiated only by a spawn action (19.2)'); - continue; - } - if (system.type !== 'graphic') { - // 18.2-18.5 procedural systems execute in slice 4e. Their schema is - // carried and validated; nothing is drawn for them here. - this.defer(id, `'${system.type}' systems execute in slice 4e (18.2-18.5)`); - continue; - } - const stream = this.rng ? this.rng.stream('visual', `visuals.systems.${id}`) : null; - const context = { resolver, stream, style: DEFAULT_STYLE }; - const objects = []; - let objectIndex = 0; - for (const key of Object.keys(system.content ?? {})) { - const node = instantiateObject(key, system.content[key], context, `visuals.systems.${id}.content`, 1); - node.order = objectIndex; - objectIndex += 1; - objects.push(node); - } - this.systems.push({ id, index, layer: system.layer ?? null, visible: system.visible ?? true, objects }); - index += 1; + if ((system.lifecycle ?? 'persistent') !== 'spawned') this.systems.push(this.createSystem(id, system, `visuals.systems.${id}`)); } + this.scene = sampleTree(visuals.scene ?? {}, resolver, this.rng?.stream('visual', 'scene')); + this.effects = (this.resolution?.visualEffects ?? visuals.effects ?? []).map((effect, index) => ({ + ...effect, index + })); + const automationStream = this.rng?.stream('visual', 'automation'); + this.tracks = []; + for (const track of visuals.automation ?? []) { + const path = `visuals.${track.target}`; + const match = matchVisualTarget(this.document, path); + if (match && !match.reason && match.stages.automation && this.resolution) { + this.unregister.push(this.resolution.addAutomation(path, track, { startedAt: 0, stream: automationStream })); + } else this.tracks.push(...visualLocalTracks([track], { scene: this.scene, layers: Object.fromEntries(this.layers.map(layer => [layer.id, layer])) }, + (value, path) => resolver.evaluate(value, automationStream, path), warning => this.cadence.once(warning.code, warning.path, warning.message))); + } + advanceVisualTracks(this.tracks, 0); + this.enforcePopulations(); + return this; + } + + createSystem(id, spec, systemPath, scope = null) { + const resolver = this.resolution?.valueResolver; + const stream = this.rng?.stream('visual', systemPath); + const context = this.objectContext(resolver, stream, scope); + const system = { id, index: this.systems.length, spec, layer: spec.layer ?? null, visible: spec.visible ?? true, objects: [], at: this.time }; + if (spec.type === 'graphic') { + for (const [key, raw] of Object.entries(spec.content ?? {})) { + const node = instantiateObject(key, raw, context, `${systemPath}.content`, 1); + node.order = system.objects.length; system.objects.push(node); + } + } else { + system.procedural = new ProceduralSystem(id, spec, { + systemPath, resolver, rng: this.rng, noiseTable: this.noiseTable, + scene: this.sceneRectangle(), fields: this.fieldSet, + instantiateItemNode: (template, itemStream, path, itemScope) => instantiateObject('item', template, + this.objectContext(resolver, itemStream, { ...scope, ...itemScope }), path, 1) + }); + } + system.tracks = visualLocalTracks(spec.automation, system.procedural ?? system.objects, + (value, path) => sampleTree(value, resolver, stream, path, scope), warning => this.cadence.once(warning.code, `${systemPath}:${warning.path}`, warning.message)); + advanceVisualTracks(system.tracks, 0); + const visited = new Set(); + for (const node of system.objects) advanceVisualMotion(node, 0, 0, this.sceneRectangle(), this.fieldSet, system.objects, visited); + return system; + } + + spawn(template, { inputs = {}, owner = 'root', origin = owner, ownership, lifetime } = {}) { + const spec = this.visuals?.systems?.[template]; + if (!spec || spec.lifecycle !== 'spawned') throw new RuntimeFault('ERR_INVALID_REFERENCE', `No spawned template '${template}'.`); + const live = [...this.instances.values()]; + const tracks = (this.visuals.automation ?? []).length + this.systems.reduce((n, system) => n + (system.tracks?.length ?? 0), 0); + const points = (this.visuals.automation ?? []).reduce((n, track) => n + track.points.length, 0) + this.systems.reduce((n, system) => n + (system.tracks ?? []).reduce((m, entry) => m + entry.track.points.length, 0), 0); + const key = live.length >= RUNTIME.liveSpawnedInstances ? 'liveSpawnedInstances' + : tracks + (spec.automation?.length ?? 0) > RUNTIME.liveAutomationTracks ? 'liveAutomationTracks' + : points + (spec.automation ?? []).reduce((n, track) => n + track.points.length, 0) > RUNTIME.liveAutomationPoints ? 'liveAutomationPoints' : null; + if (key) { this.cadence.shed('WARN_VISUAL_CEILING', key, `Spawn of '${template}' refused by ${key}.`, this.time); return null; } + const parameters = spec.spawn?.inputs ?? {}; + for (const key of Object.keys(inputs)) if (!Object.hasOwn(parameters, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown spawn input '${key}'.`); + const bound = {}; + for (const [key, parameter] of Object.entries(parameters)) { + const value = inputs[key] ?? parameter.default; + if (!inputValueMatches(parameter.type, value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Invalid spawn input '${key}'.`); + if (typeof value === 'number' && (value < (parameter.min ?? -Infinity) || value > (parameter.max ?? Infinity))) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Spawn input '${key}' is out of range.`); + bound[key] = value; + } + if (ownership === 'persistent' && spec.spawn?.ownership !== 'persistent') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Template does not permit persistent ownership.'); + const ordinal = this.spawnOrdinals.get(template) ?? 0; + const id = `instances.${template}#${ordinal}`; + // Substitute only scoped input leaves; preserve procedural values for their own instantiation boundaries. + const substitute = value => Array.isArray(value) ? value.map(substitute) : isRecord(value) + ? (typeof value.ref === 'string' && value.ref.startsWith('inputs.') ? bound[value.ref.slice(7)] : Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v)]))) : value; + const resolvedSpec = substitute(spec); + const system = this.createSystem(template, resolvedSpec, `${template}#${ordinal}`, { inputs: bound }); + const stream = this.rng?.stream('visual', `${template}#${ordinal}`); + const duration = (value, fallback) => value === undefined ? fallback : parseDuration(sampleTree(value, this.resolution.valueResolver, stream)); + const instance = new VisualInstance({ id, template, system, at: this.time, + lifetime: duration(lifetime ?? resolvedSpec.spawn?.lifetime, null), release: duration(resolvedSpec.spawn?.release, 0), + owner: ownership === 'persistent' ? 'root' : owner, origin, cancelWithScenario: spec.spawn?.cancelWithScenario !== false }); + system.instance = instance; + this.spawnOrdinals.set(template, ordinal + 1); this.instances.set(id, instance); this.systems.push(system); + this.enforcePopulations(); + return instance; + } + + remove(id) { + const instance = this.instances.get(id); + if (!instance) return false; + instance.remove(this.time); + return true; + } + + failSystem(system, error) { + if (system.instance && !['FAILED', 'DISPOSED'].includes(system.instance.state)) { + system.instance.transition('FAILED'); this.instances.delete(system.instance.id); + } + system.objects.length = 0; + if (system.procedural) system.procedural.items.length = 0; + system.tracks = []; + this.systems = this.systems.filter(candidate => candidate !== system); + this.cadence.diagnostics?.error?.(error.code ?? 'ERR_RUNTIME', error.message, { section: 'visual', objectId: system.instance?.id ?? system.id }); + } + + cleanup(owner) { + for (const instance of this.instances.values()) if (instance.owner === owner || (instance.origin === owner && instance.cancelWithScenario)) { + instance.remove(this.time, true); instance.cleanupDeadline = this.time + 5000; + } + } + + dispose() { + for (const release of this.unregister) release(); + this.unregister = []; this.tracks = []; + for (const instance of this.instances.values()) { + if (instance.state !== 'FINISHED') instance.transition('FINISHED'); + instance.transition('DISPOSED'); + } + this.instances.clear(); this.systems = []; this.fieldSet?.fields.clear(); this.cadence.clear(); + } + + enforcePopulations() { + for (const [type, key] of [['particles', 'liveParticles'], ['emitter', 'liveEmittedItems']]) { + const systems = this.systems.map(system => system.procedural).filter(system => system?.type === type); + let total = systems.reduce((n, system) => n + system.items.length, 0); + if (total > RUNTIME[key]) this.cadence.shed('WARN_VISUAL_CEILING', key, `Exceeded ${key}; evicting oldest items in the largest system.`, this.time); + while (total > RUNTIME[key]) { + const largest = systems.reduce((a, b) => a.items.length >= b.items.length ? a : b); + largest.items.shift(); total -= 1; + } + } + const histories = this.systems.flatMap(system => (system.procedural?.items ?? []).map(item => item.trail)); + let samples = histories.reduce((n, history) => n + history.length, 0); + if (samples > RUNTIME.trailHistorySamples) this.cadence.shed('WARN_VISUAL_CEILING', 'trailHistorySamples', 'Truncating oldest samples of longest histories.', this.time); + while (samples > RUNTIME.trailHistorySamples) { histories.reduce((a, b) => a.length >= b.length ? a : b).shift(); samples -= 1; } + let links = RUNTIME.linkSegmentsPerTick; + for (const system of this.systems) if (system.procedural) { + const pairs = system.procedural.linkPairs(); + if (pairs.length > links) this.cadence.shed('WARN_VISUAL_CEILING', 'linkSegmentsPerTick', 'Dropping the tail of link pair order.', this.time); + system.links = pairs.slice(0, links); links -= system.links.length; + } + } + + effectPlan(fit, backing, zoom) { + let passes = 0; + const effects = []; + for (const effect of this.effects ?? []) { + if (effect.enabled === false) continue; + const definition = POST_EFFECTS[effect.type]; + if (passes + definition.passes > RUNTIME.postEffectPassesPerFrame) { + this.cadence.shed('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, 'Trailing effect skipped by pass ceiling.', this.time); break; + } + const resolved = { ...definition.colors, ...effect }; + for (const [parameter, range] of Object.entries(definition.numeric)) { + const value = this.resolution?.get(`visuals.effects[${effect.index}].${parameter}`) ?? effect[parameter] ?? range.default; + resolved[parameter] = clamp(value, range.min ?? -Infinity, range.max ?? Infinity); + } + if (resolved.radius !== undefined && ['blur', 'bloom'].includes(effect.type)) resolved.deviceRadius = resolved.radius * zoom * Math.sqrt(fit.sx * fit.sy) * backing.multiplier; + effects.push(resolved); passes += definition.passes; + } + return effects; + } + + objectContext(resolver, stream, scope = null) { + return { + resolver, stream, noiseTable: this.noiseTable, style: DEFAULT_STYLE, scope, + components: this.document?.components?.visual ?? {}, componentTrail: [], componentDepth: 0 + }; + } + + /** The scene rectangle in scene units, for the `scene` bounds of 18.6. */ + sceneRectangle() { + const scene = this.visuals?.scene ?? {}; + const space = scene.coordinateSpace ?? 'virtual'; + if (space === 'normalized') return { x: 0, y: 0, width: 1, height: 1 }; + if (space === 'viewport') return { x: 0, y: 0, width: this.display?.width ?? 0, height: this.display?.height ?? 0 }; + return { x: 0, y: 0, width: scene.width ?? 0, height: scene.height ?? 0 }; + } + + /** + * One logical tick (9.1). Procedural systems advance here and nowhere else: a + * frame between two ticks draws the most recent tick's state (18.2). + */ + advance(deltaMilliseconds) { + this.cadence.endTick(); + this.time += deltaMilliseconds; + const dt = deltaMilliseconds / 1000; + advanceVisualTracks(this.tracks, this.time); + for (const instance of this.instances.values()) { + if (instance.advance(this.time) === 'forced') { + this.cadence.once('WARN_CLEANUP_FORCED', instance.id, 'Visual cleanup exceeded its deadline.'); + if (instance.state !== 'FINISHED') instance.transition('FINISHED'); + instance.transition('DISPOSED'); + } + if (['DISPOSED', 'FAILED'].includes(instance.state)) { + this.instances.delete(instance.id); this.systems = this.systems.filter(system => system !== instance.system); + } + } + this.fieldSet.evaluations = 0; this.fieldSet.refused = false; this.fieldSet.budget = RUNTIME.fieldEvaluationsPerTick; + const work = []; + for (const system of this.systems) { + try { + advanceVisualTracks(system.tracks, this.time - system.at); + if (system.procedural) { + const procedural = system.procedural; + const env = procedural.advance(dt, { deferItems: true }); + for (const item of procedural.items) work.push({ system, procedural, item, env }); + } + } catch (error) { this.failSystem(system, error); } + } + work.sort((a, b) => a.procedural.itemPosition(a.item).z - b.procedural.itemPosition(b.item).z); + for (const { system, procedural, item, env } of work) { + if (!this.systems.includes(system)) continue; + try { + procedural.advanceItem(item, dt, procedural.time - item.birth, env); + if (item.node) { + advanceVisualMotion(item.node, dt, procedural.time - item.birth, this.sceneRectangle(), this.fieldSet); + for (const [key, value] of Object.entries(item.channels)) visualSetPath(item.node.raw, key, value); + item.node.style = { ...item.node.style, ...item.node.raw.style }; + item.node.geometry = primitiveSubpaths(item.node.raw, item.node.path); + } + } catch (error) { this.failSystem(system, error); } + } + for (const system of this.systems) { + try { + const visited = new Set(); + for (const node of system.objects) advanceVisualMotion(node, dt, (this.time - system.at) / 1000, this.sceneRectangle(), this.fieldSet, system.objects, visited); + } catch (error) { this.failSystem(system, error); } + } + if (this.fieldSet.refused) this.cadence.shed('WARN_VISUAL_CEILING', 'fieldEvaluationsPerTick', 'Field budget exhausted; remaining forces are zero.', this.time); + this.enforcePopulations(); return this; } @@ -4847,6 +7338,7 @@ class VisualEngine { } systemVisible(system) { + if (system.instance && !['ACTIVE', 'RELEASING'].includes(system.instance.state)) return false; if (this.resolution) { try { return this.resolution.get(`visuals.systems.${system.id}.visible`) !== false; } catch { /* authored value below */ } } @@ -4859,9 +7351,19 @@ class VisualEngine { */ planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) { if (!this.visuals) return null; - const scene = this.visuals.scene ?? {}; + this.display = { width, height }; + for (const system of this.systems) if (system.procedural) system.procedural.scene = this.sceneRectangle(); + const scene = this.scene ?? this.visuals.scene ?? {}; const fit = resolveFit(scene, width, height); const backing = resolveBackingStore(width, height, devicePixelRatio); + if (fit.space === 'viewport' && this.resolution) { + for (const [field, value] of [['x', width / 2], ['y', height / 2]]) { + const path = `visuals.camera.${field}`; + if (this.visuals.camera?.[field] === undefined && this.resolution.visualValues.get(path) !== value) { + this.resolution.visualValues.set(path, value); this.resolution.invalidate(); + } + } + } if (backing.below1) { this.cadence.once('WARN_VISUAL_APPROXIMATION', `backing-store:${backing.width}x${backing.height}`, `The display exceeds ${RUNTIME.backingStoreEdge} device pixels, so the frame is rendered at a multiplier of ${backing.multiplier.toFixed(4)} and upsampled.`); @@ -4875,8 +7377,8 @@ class VisualEngine { const focalLength = clamp(this.cameraValue('focalLength'), CAMERA_FIELDS.focalLength.min, CAMERA_FIELDS.focalLength.max); let cameraX = this.cameraValue('x'); let cameraY = this.cameraValue('y'); - if (fit.space === 'viewport' && this.visuals.camera?.x === undefined) cameraX = center[0]; - if (fit.space === 'viewport' && this.visuals.camera?.y === undefined) cameraY = center[1]; + if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.x === undefined) cameraX = center[0]; + if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.y === undefined) cameraY = center[1]; const cameraCss = fit.space === 'viewport' ? [cameraX, cameraY] : apply(fit.matrix, cameraX, cameraY); const fog = this.resolveFog(scene); @@ -4898,7 +7400,9 @@ class VisualEngine { const units = this.sortableUnits(layer); const nodes = []; for (const unit of units) { - const plan = this.emitNode(unit.node, IDENTITY, 0, 1, { ...context, view, layerId: layer.id }); + const plan = unit.system + ? this.emitSystem(unit.system, { ...context, view, layerId: layer.id }) + : this.emitNode(unit.node, IDENTITY, 0, unit.releaseFactor ?? 1, { ...context, view, layerId: layer.id }); if (!plan) continue; // The plan node is pushed by reference: buffer allocation runs after // every layer is emitted and mutates the nodes it refuses. @@ -4914,7 +7418,7 @@ class VisualEngine { } this.allocateBuffers(layers, context); - this.cadence.endTick(); + const effects = this.effectPlan(fit, backing, zoom); return { background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')), @@ -4923,7 +7427,7 @@ class VisualEngine { fit, camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center }, fog, - layers, + layers, effects, logicalMilliseconds: this.time, culled: context.stats.culled, buffers: context.bufferSummary, deferred: this.deferred, @@ -4951,8 +7455,12 @@ class VisualEngine { for (const system of this.systems) { if ((system.layer ?? null) !== (layer.id ?? null)) continue; if (!this.systemVisible(system)) continue; + if (system.procedural) { + units.push({ system, representativeDepth: system.procedural.representativeDepth(), systemIndex: system.index, objectIndex: 0 }); + continue; + } for (const node of system.objects) { - units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order }); + units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order, releaseFactor: system.instance?.factor ?? 1 }); } } return units.sort((a, b) => (b.representativeDepth - a.representativeDepth) @@ -5095,6 +7603,191 @@ class VisualEngine { return plan; } + + // ------------------------------------------------------------------------- + // Procedural systems (18.2-18.5, 18.8) + // ------------------------------------------------------------------------- + + /** Project a scene-space point at a given effective depth into device pixels. */ + projectScene(ctx, x, y, zEffective) { + let [px, py] = apply(ctx.fit.matrix, x, y); + [px, py] = apply(ctx.view, px, py); + if (ctx.projection === 'perspective') { + const factor = ctx.focalLength / (ctx.focalLength + zEffective); + px = ctx.center[0] + (px - ctx.center[0]) * factor; + py = ctx.center[1] + (py - ctx.center[1]) * factor; + } + return apply(ctx.B, px, py); + } + + sceneScalar(ctx, value, zEffective) { + const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1; + return value * ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy) * ctx.backing.multiplier; + } + + /** + * 17.6: a procedural system draws as one atomic unit. Within it, links draw + * before their items (18.8) and items sort by depth then creation ordinal. + */ + emitSystem(system, ctx) { + const procedural = system.procedural; + const unit = { + kind: 'group', path: procedural.path, type: system.spec?.type ?? procedural.type, + z: procedural.representativeDepth(), perspective: 1, alpha: 1, blend: 'normal', + fill: null, stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter', + strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, + filters: [], clip: null, fogFraction: 0, children: [], mask: null, + needsBuffer: false, system: procedural.id, itemCount: procedural.items.length + }; + + for (const link of system.links ?? procedural.linkPairs()) { + const node = this.emitLink(procedural, link, ctx); + if (node) unit.children.push(node); + } + + const ordered = [...procedural.items].sort((a, b) => { + const az = procedural.itemPosition(a).z; + const bz = procedural.itemPosition(b).z; + return (bz - az) || (a.ordinal - b.ordinal); + }); + for (const item of ordered) { + const trail = this.emitTrail(procedural, item, ctx); + if (trail) unit.children.push(trail); + const node = this.emitItem(procedural, item, ctx); + if (node) unit.children.push(node); + } + if (system.instance) { + const fade = node => { node.alpha *= system.instance.factor; for (const child of node.children ?? []) fade(child); }; + for (const child of unit.children) fade(child); + } + return unit; + } + + emitItem(procedural, item, ctx) { + if (!item.node) return null; + const position = procedural.itemPosition(item); + const age = procedural.itemAge(item); + const size = procedural.rampValue(item.ramps.size, age) ?? 1; + const opacity = (procedural.rampValue(item.ramps.opacity, age) ?? 1) * item.opacityMultiplier; + const color = procedural.rampValue(item.ramps.color, age); + // 18.2: `color` replaces the render object's resolved fill, and its stroke + // where that is non-null. + if (color !== undefined && color !== null) { + const text = typeof color === 'string' ? color : formatColor(color); + item.node.style = { ...item.node.style, fill: text, stroke: item.node.style.stroke === null ? null : text }; + } + // V29: `size` is a uniform local geometry scale, applied innermost, before + // the render object's own transform. + const matrix = compose(translation(position.x, position.y), rotation(position.rotation), scaling(size * item.scale.x, size * item.scale.y)); + const node = this.emitNode(item.node, matrix, position.z, opacity, ctx); + if (!node) return null; + // A `point` and a `text` carry scalar dimensions that a geometry scale + // cannot reach, so `size` multiplies them explicitly. + if (node.kind === 'point') node.diameter *= size; + if (node.kind === 'text') { node.size *= size; node.letterSpacing *= size; } + node.ordinal = item.ordinal; + return node; + } + + /** 18.8: a trail is the item's recent positions, drawn under its head. */ + emitTrail(procedural, item, ctx) { + const spec = procedural.trailSpec; + if (!spec || item.trail.length < 2) return null; + const style = spec.style ?? item.node?.style ?? DEFAULT_STYLE; + const head = item.trail.at(-1); + const width = spec.width ?? style.strokeWidth ?? 1; + const taper = spec.taper ?? 1; + const fade = spec.fade ?? 1; + const mode = spec.mode ?? 'line'; + const samples = item.trail; + const project = (sample) => this.projectScene(ctx, sample.x, sample.y, sample.z ?? 0); + + if (mode === 'points') { + return { + kind: 'group', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0, + perspective: 1, alpha: 1, blend: 'normal', fill: null, stroke: null, strokeWidth: 0, + strokeCap: 'butt', strokeJoin: 'miter', glow: null, shadow: null, blur: 0, filters: [], + clip: null, fogFraction: 0, mask: null, needsBuffer: false, + children: samples.map((sample, index) => ({ + kind: 'point', path: `${procedural.path}#${item.ordinal}.trail[${index}]`, type: 'point', + z: sample.z ?? 0, perspective: 1, + alpha: fade + (1 - fade) * (index / Math.max(1, samples.length - 1)), + blend: style.blend ?? 'normal', + fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null, + stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter', glow: null, + shadow: null, blur: 0, filters: [], clip: null, fogFraction: 0, children: [], mask: null, + needsBuffer: false, + center: project(sample), diameter: this.sceneScalar(ctx, style.pointSize ?? 1, sample.z ?? 0) + })) + }; + } + + const base = { + kind: 'shape', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0, + perspective: 1, alpha: 1, blend: style.blend ?? 'normal', + strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter', + strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [], + clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero' + }; + + if (mode === 'ribbon') { + // 18.8: a ribbon is the same history buffer drawn with area instead of a + // stroke, its half-width interpolating from the head to `width * taper`. + const left = []; + const right = []; + for (let index = 0; index < samples.length; index += 1) { + const previous = samples[Math.max(0, index - 1)]; + const next = samples[Math.min(samples.length - 1, index + 1)]; + const angle = Math.atan2(next.y - previous.y, next.x - previous.x); + const fraction = index / Math.max(1, samples.length - 1); + const half = (width * (taper + (1 - taper) * fraction)) / 2; + const nx = -Math.sin(angle) * half; + const ny = Math.cos(angle) * half; + left.push(this.projectScene(ctx, samples[index].x + nx, samples[index].y + ny, samples[index].z ?? 0)); + right.push(this.projectScene(ctx, samples[index].x - nx, samples[index].y - ny, samples[index].z ?? 0)); + } + const outline = [...left, ...right.reverse()]; + return { + ...base, + fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null, + stroke: null, strokeWidth: 0, alpha: (1 + fade) / 2, + subpaths: [{ closed: true, start: outline[0], segments: outline.slice(1).map((point) => ({ type: 'line', to: point })) }] + }; + } + + const points = samples.map(project); + return { + ...base, + fill: null, + stroke: style.stroke || style.fill ? { type: 'color', color: typeof (style.stroke ?? style.fill) === 'string' ? (style.stroke ?? style.fill) : '#ffffff' } : null, + strokeWidth: this.sceneScalar(ctx, width, head.z ?? 0), + alpha: (1 + fade) / 2, + subpaths: [{ closed: false, start: points[0], segments: points.slice(1).map((point) => ({ type: 'line', to: point })) }] + }; + } + + /** 18.8: link geometry borrows the item template's resolved style. */ + emitLink(procedural, link, ctx) { + const spec = procedural.linksSpec; + const style = spec.style ?? procedural.items[0]?.node?.style ?? DEFAULT_STYLE; + const paint = style.stroke ?? style.fill; + return { + kind: 'shape', path: `${procedural.path}.links`, type: 'link', z: Math.min(link.from.z, link.to.z), + perspective: 1, alpha: link.opacity, blend: style.blend ?? 'normal', + fill: null, + stroke: typeof paint === 'string' ? { type: 'color', color: paint } : null, + strokeWidth: this.sceneScalar(ctx, style.strokeWidth ?? 1, link.from.z), + strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter', + strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [], + clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero', + subpaths: [{ + closed: false, + start: this.projectScene(ctx, link.from.x, link.from.y, link.from.z), + segments: [{ type: 'line', to: this.projectScene(ctx, link.to.x, link.to.y, link.to.z) }] + }] + }; + } + /** The single affine chain for a constant-depth object: `B x P x V x F x M`. */ deviceMatrix(matrix, zEffective, ctx) { const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1; @@ -5219,6 +7912,7 @@ class VisualEngine { // stroke from being distorted by a nonuniform `stretch` fit. Text is the one // exception and carries its own matrix. + const BLEND_OPERATIONS = Object.freeze({ normal: 'source-over', add: 'lighter', screen: 'screen', multiply: 'multiply', overlay: 'overlay', lighten: 'lighten', darken: 'darken', difference: 'difference' @@ -5297,6 +7991,33 @@ function applyClip(context, clip) { */ function drawNode(context, node, surface) { if (node.alpha === 0) return; + + // B1: When node.buffered is true, rasterize into a pool surface with default + // state, then composite once with the node's alpha/blend (§17.12 stage order). + if (node.buffered && surface && typeof surface.create === 'function') { + const target = surface.create(); + if (target?.context) { + target.context.save(); + target.context.globalAlpha = 1; + target.context.globalCompositeOperation = 'source-over'; + const unbuffered = { ...node, buffered: false, alpha: 1, blend: 'normal', clip: null }; + drawNode(target.context, unbuffered, surface); + target.context.restore(); + + context.save(); + context.globalAlpha = node.alpha; + context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over'; + applyClip(context, node.clip); + const filters = filterString(node); + if (filters && 'filter' in context) context.filter = filters; + context.drawImage(target.canvas, 0, 0); + context.restore(); + + surface.release(target); + return; + } + } + context.save(); context.globalAlpha = node.alpha; context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over'; @@ -5369,25 +8090,44 @@ function drawNode(context, node, surface) { } } - // 17.12: glow is added around the drawn result. Canvas 2D expresses it as a - // zero-offset shadow, which is the documented realization, not a substitution. + // 17.12: glow is drawn *around* the result, not over it. We offset the + // geometry far off-screen so only the blurred shadow (the halo) appears. if (node.glow && node.glow.strength > 0) { context.save(); context.globalAlpha = node.alpha * node.glow.strength; context.shadowColor = node.glow.color; context.shadowBlur = node.glow.radius; - context.shadowOffsetX = 0; + // Offset geometry far away; counter-offset the shadow so it lands in place. + const glowShift = 1e5; + context.shadowOffsetX = -glowShift; context.shadowOffsetY = 0; + context.translate(glowShift, 0); if (node.kind === 'point') { context.beginPath(); context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2); - context.fillStyle = node.glow.color; + context.fillStyle = 'rgba(0,0,0,1)'; context.fill(); - } else if (node.kind !== 'text') { + } else if (node.kind === 'text') { + const matrix = node.matrix; + context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + glowShift, matrix[5]); + context.shadowOffsetX = -glowShift; + context.shadowOffsetY = 0; + context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`; + context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left'; + context.textBaseline = node.baseline; + context.fillStyle = 'rgba(0,0,0,1)'; + context.fillText(node.text, 0, 0, node.maxWidth); + } else { tracePath(context, node); - context.strokeStyle = node.glow.color; - context.lineWidth = Math.max(node.strokeWidth, 1); - context.stroke(); + if (node.fill) { + context.fillStyle = 'rgba(0,0,0,1)'; + context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero'); + } + if (node.stroke && node.strokeWidth > 0) { + context.strokeStyle = 'rgba(0,0,0,1)'; + context.lineWidth = node.strokeWidth; + context.stroke(); + } } context.restore(); } @@ -5425,7 +8165,7 @@ class SurfacePool { * an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a * detached ``. */ -function renderFrame(context, plan, { createSurface } = {}) { +function renderFrame(context, plan, { createSurface, warnEffect } = {}) { const { width, height } = plan.backing; context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; @@ -5450,6 +8190,7 @@ function renderFrame(context, plan, { createSurface } = {}) { surfaces.release(target); } } + renderVisualEffects(context, plan, warnEffect); return { allocated: surfaces?.allocated ?? 0 }; } @@ -5481,6 +8222,7 @@ class VisualSubsystem { /** 17.14: activation is the instantiation boundary of every persistent system. */ activate(document, { resolution, rng }) { + this.engine?.dispose(); if (!document?.visuals) { this.engine = null; return null; @@ -5490,6 +8232,11 @@ class VisualSubsystem { return this.engine; } + /** One logical tick (9.1): procedural systems advance, nothing draws. */ + advance(deltaMilliseconds) { + this.engine?.advance(deltaMilliseconds); + } + displayRectangle() { const canvas = this.canvas; if (!canvas) return { width: 0, height: 0 }; @@ -5508,6 +8255,7 @@ class VisualSubsystem { if (this.canvas.width !== plan.backing.width) this.canvas.width = plan.backing.width; if (this.canvas.height !== plan.backing.height) this.canvas.height = plan.backing.height; renderFrame(this.context, plan, { + warnEffect: (effect, message) => this.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message), createSurface: (surfaceWidth, surfaceHeight) => { if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(surfaceWidth, surfaceHeight); const surface = globalThis.document?.createElement('canvas'); @@ -5523,6 +8271,7 @@ class VisualSubsystem { } deactivate() { + this.engine?.dispose(); if (this.context && this.canvas) { this.context.setTransform(1, 0, 0, 1, 0, 0); this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); @@ -5538,14 +8287,90 @@ class ActionExecutor { this.engine = engine; this.diagnostics = diagnostics; this.invocationOrdinal = 0; + this.visual = null; + this.audio = null; + this.dispatchUnits = 0; + } + + resetBudget() { + this.dispatchUnits = 0; + } + + consumeUnit(context = {}) { + if (context.isTerminationHook) { + if ((context.terminationUnits = (context.terminationUnits ?? 0) + 1) > 256) { + throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Termination hook action limit (256) exceeded.'); + } + } else { + if (this.dispatchUnits >= 1024) { + throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Ordinary event/action work exceeded the per-tick dispatch budget (1024).'); + } + this.dispatchUnits += 1; + } + } + + executeAction(action, context = {}) { + try { + const res = this.execute([action], context); + return res[0]?.status === 'executed'; + } catch (error) { + const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message); + this.diagnostics?.error?.(fault.code, fault.message); + return false; + } + } + + get tickActionCount() { + return this.dispatchUnits; + } + + resetTickBudget() { + this.resetBudget(); + } + + evaluateValue(spec, stream, path, inputScope = null) { + if (inputScope) { + const resolver = new ValueResolver((ref) => { + if (typeof ref === 'string' && ref.startsWith('inputs.')) { + const inputName = ref.slice('inputs.'.length); + if (inputScope && Object.hasOwn(inputScope, inputName)) { + return inputScope[inputName]; + } + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path); + } + return this.engine.get(ref); + }); + return resolver.evaluate(spec, stream, path); + } + return this.engine.evaluateValue(spec, stream, path); + } + + evaluateCondition(spec, stream, path, inputScope = null) { + if (inputScope) { + const resolver = new ValueResolver((ref) => { + if (typeof ref === 'string' && ref.startsWith('inputs.')) { + const inputName = ref.slice('inputs.'.length); + if (inputScope && Object.hasOwn(inputScope, inputName)) { + return inputScope[inputName]; + } + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path); + } + return this.engine.get(ref); + }); + const evaluator = new ConditionEvaluator(resolver); + return evaluator.evaluate(spec, stream, path); + } + return this.engine.evaluateCondition(spec, stream, path); } execute(actions, context = {}) { if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.'); - const stream = this.engine.rng.stream('scenario', `actions:${++this.invocationOrdinal}`); + const domain = context.domain ?? 'scenario'; + const stream = this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`); const results = []; for (let index = 0; index < actions.length; index += 1) { const action = actions[index]; + this.consumeUnit(context); try { results.push(this.executeOne(action, stream, context, `$.actions[${index}]`)); } catch (error) { @@ -5560,27 +8385,423 @@ class ActionExecutor { executeOne(action, stream, context, path) { if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path); - if (action.when !== undefined && !this.engine.evaluateCondition(action.when, stream, `${path}.when`)) return { status: 'skipped', reason: 'condition' }; + if (action.when !== undefined && !this.evaluateCondition(action.when, stream, `${path}.when`, context.inputScope)) return { status: 'skipped', reason: 'condition' }; if (action.chance !== undefined) { if (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Action chance must be from 0 through 1.', `${path}.chance`); if (stream.nextFloat() >= action.chance) return { status: 'skipped', reason: 'chance' }; } if (action.type === 'set') { - const value = this.engine.evaluateValue(action.value, stream, `${path}.value`); + const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope); this.engine.setState(action.target, value, action.transition); return { status: 'executed', type: 'set', target: action.target, value }; } if (action.type === 'override') { - const value = this.engine.evaluateValue(action.value, stream, `${path}.value`); + const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope); const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority }); this.engine.resolveAll(); return { status: 'executed', type: 'override', target: action.target, value, instanceId }; } + if (action.type === 'spawn' && this.visual) { + const inputs = Object.fromEntries(Object.entries(action.with ?? {}).map(([key, value]) => [key, this.evaluateValue(value, stream, `${path}.with.${key}`, context.inputScope)])); + const lifetime = action.lifetime === undefined ? undefined : this.evaluateValue(action.lifetime, stream, `${path}.lifetime`, context.inputScope); + const instance = this.visual.spawn(action.target?.replace(/^visuals\.systems\./, ''), { ...context, inputs, lifetime, ownership: action.ownership }); + return { status: instance ? 'executed' : 'refused', type: 'spawn', instanceId: instance?.id ?? null }; + } + if (action.type === 'remove' && this.visual) { + // C20: Return refused/failed when the instance ID is unknown. + const removed = this.visual.remove(action.target); + return { status: removed ? 'executed' : 'refused', type: 'remove', target: action.target }; + } + if (action.type === 'event') { + this.consumeUnit(context); + const depth = (context.depth ?? 0) + 1; + if (depth > 16) { + throw new RuntimeFault('ERR_DISPATCH_BUDGET', `Event nesting depth (${depth}) exceeded maximum allowed depth (16).`, `${path}.event`); + } + const eventDef = this.engine.document.events?.[action.event]; + if (!eventDef) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced event '${action.event}' does not exist.`, `${path}.event`); + } + + const inputScope = {}; + for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) { + if (inputSpec.default !== undefined) { + inputScope[inputId] = inputSpec.default; + } + } + for (const [inputId, inputValue] of Object.entries(action.with ?? {})) { + const inputSpec = eventDef.inputs?.[inputId]; + if (!inputSpec) { + throw new RuntimeFault('ERR_UNKNOWN_FIELD', `Unknown input '${inputId}' for event '${action.event}'.`, `${path}.with.${inputId}`); + } + const evaluated = this.evaluateValue(inputValue, stream, `${path}.with.${inputId}`, context.inputScope); + if (!valueMatchesType(inputSpec.type, evaluated, inputSpec)) { + throw new RuntimeFault('ERR_TYPE_MISMATCH', `Input '${inputId}' value does not match declared type '${inputSpec.type}'.`, `${path}.with.${inputId}`); + } + inputScope[inputId] = evaluated; + } + for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) { + if (!Object.hasOwn(inputScope, inputId) && inputSpec.default === undefined) { + throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Required input '${inputId}' not provided for event '${action.event}'.`, `${path}.with.${inputId}`); + } + } + + this.diagnostics?.info?.('INFO_EVENT_INVOKED', `Event '${action.event}' invoked.`, { exhibitId: this.engine.document.meta?.id, section: 'events', objectId: action.event }); + const results = this.execute(eventDef.actions, { + ...context, + depth, + inputScope, + owner: context.owner + }); + return { status: 'executed', type: 'event', event: action.event, results }; + } + if (action.type === 'sound') { + const soundId = action.sound; + const targetSound = this.engine.document.sounds?.[soundId]; + if (!targetSound) { + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced sound '${soundId}' does not exist.`, `${path}.sound`); + } + const allowedUsage = targetSound.usage ?? ['automatic']; + const callerUsage = context.usage ?? (context.domain === 'cadence' ? 'automatic' : 'manual'); + if (!allowedUsage.includes(callerUsage)) { + throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Sound '${soundId}' does not permit usage '${callerUsage}'.`, `${path}.sound`); + } + const inputs = action.with ? Object.fromEntries(Object.entries(action.with).map(([k, v]) => [k, this.evaluateValue(v, stream, `${path}.with.${k}`, context.inputScope)])) : undefined; + const audio = this.audio ?? this.engine.audio; + const voice = audio ? audio.play(soundId, { inputs, owner: action.ownership ?? context.owner }) : null; + return { status: voice ? 'executed' : 'refused', type: 'sound', sound: soundId, voiceId: voice?.id ?? null }; + } throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`); } } +/* src/runtime/cadence.js */ +const DEFAULT_CLASS_INTERVALS = Object.freeze({ + routine: Object.freeze({ min: 10_000, max: 45_000 }), + intermittent: Object.freeze({ min: 45_000, max: 240_000 }), + occasional: Object.freeze({ min: 180_000, max: 900_000 }), + rare: Object.freeze({ min: 900_000, max: 3_600_000 }) +}); + +const CADENCE_PRIORITY = Object.freeze(['rare', 'occasional', 'intermittent', 'routine']); +const RECENCY_MULTIPLIERS = Object.freeze([0.0, 0.25, 0.50, 0.75]); + +class CadenceSubsystem { + constructor({ document, rng, resolution, audio = null, diagnostics = null } = {}) { + this.document = document; + this.rng = rng; + this.resolution = resolution; + this.audio = audio; + this.diagnostics = diagnostics; + + this.intensity = 1.0; + this.minGapMs = 1500; + if (document?.cadence?.minGap) { + try { + this.minGapMs = parseDuration(document.cadence.minGap); + } catch { + this.minGapMs = 1500; + } + } + + this.logicalMilliseconds = 0; + this.lastAutomaticSoundTime = -Infinity; + this.lastFiredTimes = new Map(); + this.ambientInstances = new Map(); // soundId -> voice instance + + this.classes = {}; + for (const className of CADENCE_PRIORITY) { + let range = DEFAULT_CLASS_INTERVALS[className]; + if (document?.cadence?.clocks?.[className]) { + try { + const authored = document.cadence.clocks[className]; + range = { + min: parseDuration(authored.min), + max: parseDuration(authored.max) + }; + } catch { + range = DEFAULT_CLASS_INTERVALS[className]; + } + } + this.classes[className] = { + name: className, + range, + recencyHistory: [], + ordinal: 0, + timer: 0, + due: false + }; + } + + this.initializeTimers(); + } + + setAudio(audio) { + this.audio = audio; + } + + initializeTimers() { + this.updateIntensity(); + for (const className of CADENCE_PRIORITY) { + const cls = this.classes[className]; + cls.ordinal += 1; + const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`); + const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min); + cls.timer = this.intensity > 1e-6 ? baseMs / this.intensity : Infinity; + cls.due = false; + } + } + + setIntensity(value) { + this.intensity = clamp(value, 0, 10); + } + + getEffectiveInterval(className) { + const range = this.classes[className]?.range ?? DEFAULT_CLASS_INTERVALS[className]; + const intensity = Math.max(1e-6, this.intensity); + return { + min: Math.max(100, Math.round(range.min / intensity)), + max: Math.max(100, Math.round(range.max / intensity)) + }; + } + + updateIntensity() { + if (this.document?.cadence?.intensity !== undefined && this.resolution) { + try { + const stream = this.rng.stream('cadence', `intensity:${this.logicalMilliseconds}`); + const resolved = this.resolution.evaluateValue(this.document.cadence.intensity, stream, '$.cadence.intensity'); + if (Number.isFinite(resolved)) { + this.intensity = clamp(resolved, 0, 1); + } + } catch { + this.intensity = 1.0; + } + } else { + this.intensity = 1.0; + } + } + + hasActiveVoice(soundId) { + if (!this.audio) return false; + const activeStates = ['SCHEDULED', 'ACTIVE', 'RELEASING']; + if (typeof this.audio.getActiveSounds === 'function') { + for (const active of this.audio.getActiveSounds()) { + if (active.soundId === soundId) return true; + } + } + if (this.audio.activeInstances instanceof Map) { + for (const inst of this.audio.activeInstances.values()) { + if (inst.soundId === soundId && !inst.disposed) return true; + } + } + if (this.audio.voices) { + for (const voice of this.audio.voices) { + if (voice.soundId === soundId && activeStates.includes(voice.state)) return true; + } + } + for (const voice of this.audio.oneshotVoices ?? []) { + if (voice.soundId === soundId && activeStates.includes(voice.state)) return true; + } + for (const voice of this.audio.continuousVoices ?? []) { + if (voice.soundId === soundId && activeStates.includes(voice.state)) return true; + } + return false; + } + + update(timeMs) { + const step = timeMs - this.logicalMilliseconds; + this.advance(Math.max(0, step)); + } + + getLastEvaluatedPool(className) { + return this.classes[className]?.lastEvaluatedPool ?? []; + } + + advance(stepMs) { + this.logicalMilliseconds += stepMs; + this.updateIntensity(); + + // 1. Advance ambient maintenance + this.maintainAmbience(); + + // 2. If intensity <= 0, pause automatic one-shot scheduling + if (this.intensity <= 1e-6) { + return; + } + + // 3. Advance class timers + for (const className of CADENCE_PRIORITY) { + const cls = this.classes[className]; + if (!cls.due) { + cls.timer -= stepMs; + if (cls.timer <= 1e-7) { + cls.due = true; + } + } + } + + // 4. Check minGap and service highest priority due class + if (this.logicalMilliseconds - this.lastAutomaticSoundTime >= this.minGapMs) { + for (const className of CADENCE_PRIORITY) { + const cls = this.classes[className]; + if (cls.due) { + const fired = this.fireClass(cls); + if (fired) { + this.lastAutomaticSoundTime = this.logicalMilliseconds; + cls.due = false; + // Reschedule next timer + cls.ordinal += 1; + const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`); + const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min); + cls.timer = baseMs / Math.max(1e-6, this.intensity); + break; // Only one class sound per gap window + } + } + } + } + } + + maintainAmbience() { + if (!this.audio) return; + if (this.audio.unlocked === false) return; + const sounds = this.document?.sounds ?? {}; + for (const [soundId, sound] of Object.entries(sounds)) { + if (sound.cadence?.class === 'ambient') { + const allowedUsage = sound.usage ?? ['automatic']; + if (!allowedUsage.includes('automatic')) continue; + + const currentVoice = this.ambientInstances.get(soundId); + const isRunning = currentVoice && !currentVoice.disposed && (currentVoice.state === undefined || currentVoice.state === 'ACTIVE' || currentVoice.state === 'SCHEDULED'); + if (!isRunning) { + const voice = this.audio.play(soundId, { owner: 'performance' }); + if (voice) { + this.ambientInstances.set(soundId, voice); + } + } + } + } + } + + fireClass(cls) { + const className = cls.name; + const sounds = this.document?.sounds ?? {}; + const eligible = []; + + for (const [soundId, sound] of Object.entries(sounds)) { + if (sound.cadence?.class !== className) continue; + const allowedUsage = sound.usage ?? ['automatic']; + if (!allowedUsage.includes('automatic')) continue; + + // Check condition + if (sound.cadence.when !== undefined && this.resolution) { + const condStream = this.rng.stream('cadence', `${className}:when:${soundId}:${cls.ordinal}`); + if (!this.resolution.evaluateCondition(sound.cadence.when, condStream, `$.sounds.${soundId}.cadence.when`)) { + continue; + } + } + + // Check cooldown + const lastFired = this.lastFiredTimes.get(soundId) ?? -Infinity; + let cooldownMs = 0; + if (sound.cadence.cooldown) { + try { cooldownMs = parseDuration(sound.cadence.cooldown); } catch { cooldownMs = 0; } + } + if (this.logicalMilliseconds - lastFired < cooldownMs) { + continue; + } + + // Check overlap + if (sound.cadence.overlap === false) { + if (this.hasActiveVoice(soundId)) { + continue; + } + } + + eligible.push({ id: soundId, sound }); + } + + if (eligible.length === 0) { + return null; + } + + // Evaluate base weights + const weightStream = this.rng.stream('cadence', `${className}:weight:${cls.ordinal}`); + const evaluated = []; + for (const candidate of eligible) { + let baseWeight = 1.0; + if (candidate.sound.cadence.weight !== undefined) { + if (typeof candidate.sound.cadence.weight === 'number') { + baseWeight = candidate.sound.cadence.weight; + } else if (this.resolution) { + baseWeight = this.resolution.evaluateValue(candidate.sound.cadence.weight, weightStream, `$.sounds.${candidate.id}.cadence.weight`); + } + } + baseWeight = Math.max(0, Number.isFinite(baseWeight) ? baseWeight : 0); + + // Anti-repetition multiplier + const historyIndex = cls.recencyHistory.indexOf(candidate.id); + let multiplier = 1.0; + if (historyIndex >= 0 && historyIndex < RECENCY_MULTIPLIERS.length) { + multiplier = RECENCY_MULTIPLIERS[historyIndex]; + } + + evaluated.push({ + id: candidate.id, + sound: candidate.sound, + baseWeight, + effectiveWeight: baseWeight * multiplier + }); + } + + let totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0); + + // Pool relaxation: if all effective weights are 0, relax penalties to base weights + if (totalWeight <= 1e-9) { + for (const item of evaluated) { + item.effectiveWeight = item.baseWeight; + } + totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0); + } + + cls.lastEvaluatedPool = evaluated; + + if (totalWeight <= 1e-9) { + return null; + } + + // Weighted random selection + const selectStream = this.rng.stream('cadence', `${className}:select:${cls.ordinal}`); + const roll = selectStream.nextFloat() * totalWeight; + let accumulated = 0; + let chosen = null; + for (const item of evaluated) { + if (item.effectiveWeight <= 1e-9) continue; + accumulated += item.effectiveWeight; + if (roll <= accumulated) { + chosen = item; + break; + } + } + if (!chosen) { + chosen = evaluated.find(item => item.effectiveWeight > 1e-9) ?? evaluated[0]; + } + + // Play chosen sound + this.lastFiredTimes.set(chosen.id, this.logicalMilliseconds); + cls.recencyHistory.unshift(chosen.id); + if (cls.recencyHistory.length > 4) { + cls.recencyHistory.pop(); + } + + if (this.audio) { + this.audio.play(chosen.id, { owner: 'performance' }); + } + + return chosen.id; + } +} + /* src/runtime/performance.js */ class CommonGrammarPerformance { constructor(record, rootSeed, options = {}) { @@ -5592,12 +8813,22 @@ class CommonGrammarPerformance { // Called once per animation frame, after any logical ticks. A frame draws // the most recent tick's state and advances nothing (9.1, 18.2). this.onFrame = options.onFrame ?? (() => {}); + // Called once per logical tick, with the fixed step of 9.1. Procedural + // motion advances here and nowhere else. + this.onTick = options.onTick ?? (() => {}); this.engine = new ResolutionEngine(record.document, this.rng, { diagnostics: this.diagnostics, initialParameters: options.initialParameters, onParameterChange: options.onParameterChange }); this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics }); + this.cadence = new CadenceSubsystem({ + document: record.document, + rng: this.rng, + resolution: this.engine, + audio: options.audio ?? null, + diagnostics: this.diagnostics + }); this.state = 'prepared'; this.resources = new Set(); this.frameRequest = null; @@ -5637,7 +8868,10 @@ class CommonGrammarPerformance { const step = 1000 / 60; let ticks = 0; while (this.accumulator + 1e-9 >= step && ticks < 8) { + this.actions.resetBudget(); this.engine.advance(step); + this.cadence.advance(step); + this.onTick(step, this.engine); this.accumulator -= step; ticks += 1; } @@ -5650,6 +8884,11 @@ class CommonGrammarPerformance { this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); } + setAudio(audio) { + this.actions.audio = audio; + this.cadence.setAudio(audio); + } + setParameter(id, value) { const result = this.engine.setParameter(id, value); this.onUpdate(this.engine); @@ -5857,10 +9096,10 @@ function instantiateSoundGraph(document, soundId, options = {}) { const resolver = new ValueResolver((reference) => { if (typeof reference === 'string' && reference.startsWith('inputs.')) { const scope = resolver.currentScope; - const values = componentValues.get(scope); + const values = scope ? componentValues.get(scope) : options.inputs; const name = reference.slice('inputs.'.length); if (!values || !Object.hasOwn(values, name)) { - throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`); + throw new RuntimeFault('ERR_INVALID_REFERENCE', `Input '${name}' is not available here.`); } return values[name]; } @@ -6339,7 +9578,8 @@ class SoundInstance { subsystem = null, bus = null, context = null, - creationOrder = 0 + creationOrder = 0, + owner = null } = {}) { this.soundId = soundId; this.mode = mode; @@ -6350,6 +9590,7 @@ class SoundInstance { this.bus = bus; this.context = context; this.creationOrder = creationOrder; + this.owner = owner; this.state = 'CREATED'; this.realized = null; this.startTime = null; @@ -6692,7 +9933,7 @@ class AudioSubsystem { return next; } - play(soundId, { resolveReference = (path) => this.resolution.get(path) } = {}) { + play(soundId, { resolveReference = (path) => this.resolution.get(path), inputs = null, owner = null } = {}) { if (!this.unlocked) return null; const sound = this.document?.sounds?.[soundId]; if (!sound) return null; @@ -6749,7 +9990,8 @@ class AudioSubsystem { rng: this.rng, ordinal: this.nextOrdinal(soundId), resolveReference, - expansion + expansion, + inputs }); for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId }); if (plan.errors.length > 0) return null; @@ -6764,7 +10006,8 @@ class AudioSubsystem { subsystem: this, bus: this.busFor(soundId), context: this.context, - creationOrder: this.nextCreationOrder++ + creationOrder: this.nextCreationOrder++, + owner }); pool.add(instance); @@ -6846,6 +10089,10 @@ class XZBTApplication { onUpdate: (engine) => { if (this.activation.current?.record.id === record.id) this.renderValues(engine); }, + onTick: (step) => { + if (this.activation.current?.record.id !== record.id) return; + this.visual.advance(step); + }, // The renderer runs at display rate; the simulation stays on the fixed // logical tick (9.1), so a frame draws the most recent tick's state. onFrame: (engine) => { @@ -6992,6 +10239,7 @@ class XZBTApplication { rng: current.performance.rng }); canvas.hidden = engine === null; + current.performance.actions.visual = engine; if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds }); } catch (error) { canvas.hidden = true; @@ -7057,11 +10305,14 @@ class XZBTApplication { this.audio.setMasterVolume(Number(element('master-volume').value)); } await this.audio.unlock(); + current.performance.setAudio(this.audio); this.renderAudio(); } async disposeAudio() { if (!this.audio) return; + const current = this.activation.current; + if (current) current.performance.setAudio(null); await this.audio.dispose(); this.audio = null; this.renderAudio(); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index bf34247..12e0623 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,10 @@ # XZBT implementation status **Updated:** September 6, 2026 -**State:** Phase 3 audio engine implemented through slice 3c-4 and automatically verified; the Phase 4 visual subsystem contract is complete across slices 4a, 4b, and 4c, and no visual runtime code exists yet. Slice 3c-4 hardware measurement/listening acceptance, the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending +**State:** Phase 3 audio engine implemented through slice 3c-4 and automatically verified; Phase 4 visual implementation is complete through 4g, with automated verification and a standalone visual challenge page; Phase 5 Events and Cadence implemented and verified across all 14 traces and reference exhibits A–D. The Phase 4d–4g and Phase 5 review triages ([reviews/02-triage.md](../reviews/02-triage.md), [reviews/03-phase5-triage.md](../reviews/03-phase5-triage.md)) have been worked through: every Tier-1 finding is fixed and covered by a test on the production code path, and the residual items are tracked in [reviews/04-followup-debt.md](../reviews/04-followup-debt.md). Slices 3c-4/4h hardware measurement/listening acceptance and Phase 1 direct-file import/restart observation remain pending; Phase 6 (scenario director) is unblocked **Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128` -**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, Phase 2 common grammar, and audio engine through Phase 3c slice 4's implementation pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and real-browser measured master protection remain open. The visual subsystem contract is complete at Phase 4a-4c and its aggregate ceilings are provisional pending the slice 4h GC6 measurement; visual implementation, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. +**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, Phase 2 common grammar, audio engine through Phase 3c slice 4's implementation, visual engine through 4g, and Phase 5 Events and Cadence pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and real-browser measured master protection remain open. The visual subsystem contract is complete at Phase 4a-4c and its aggregate ceilings are provisional pending the slice 4h GC6 measurement; visual implementation through 4g is present; scenario director, final generated-UI, measured performance, and soak work remain assigned to later phases. The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed. @@ -14,8 +14,8 @@ The user requested sequential implementation with a stop on problems. The [manua | 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. | | 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. | | 3 — Audio engine | Slices 3c-1–3 complete; 3c-4 implementation verified, user acceptance pending | [Contract](evidence/phase3/2026-09-05-phase3c-contract.md), [lifecycle/voices](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md), [automation](evidence/phase3/2026-09-05-phase3c-automation.md), and [protection evidence](evidence/phase3/2026-09-05-phase3c-protection.md). Slice 4 requires real-browser measurement and user listening; PRD 129 and real GC4 audio acceptance remain open. | -| 4 — Visual engine | Contract slices 4a, 4b, and 4c complete; 4d–4h pending | [Slice-4a](evidence/phase4/2026-09-06-phase4a-contract.md), [slice-4b](evidence/phase4/2026-09-06-phase4b-contract.md), and [slice-4c contract evidence](evidence/phase4/2026-09-06-phase4c-contract.md). Format Specification section 17 covers PRD 69–76, section 18 covers PRD 77–84, and section 19 covers PRD 85–89 and 119–120. Slice 4d is the first slice that writes renderer code. Slice 4h and the PRD 130 visual challenge are user-observed. | -| 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts | +| 4 — Visual engine | Implemented through 4g; user acceptance pending | [4e procedural evidence](evidence/phase4/2026-09-06-phase4e-procedural.md), [4f execution evidence](evidence/phase4/2026-09-06-phase4f-execution.md), [4g fixtures](evidence/phase4/2026-09-06-phase4g-challenges.md). Fourteen challenge fixtures and first visuals for A–D execute without subject-specific renderer code. Slice 4h and PRD 130 display judgment remain open. | +| 5 — Events and cadence | Complete | [Automated evidence](evidence/phase5/2026-09-06-phase5-events-cadence.md) covers contract Section 20, schema/validation with static cycle detection, 1024-action budget, 16-level nesting, priority cadence clocks, minGap, recency relaxation, ambient voices, and manual SAMPLE PRNG isolation. | | 6 — Scenario director | Not started | Earlier phases and scenario contracts | | 7 — Dynamic UI | Not started | Earlier phases and UI contracts | | 8 — Library and persistence hardening | Not started | Earlier phases and library contracts | @@ -141,3 +141,19 @@ The visual contract now contains no unresolved forward reference, and `npm test` with zero failures — all three slices are documentation-only. `src/runtime` has no renderer module, the JSON Schema has no visual definitions, and nothing has been drawn from any build. Whether sections 17-19 pass a multi-model review before slice 4d begins remains the one open decision, and 4d is the next slice. + + +## Phase 4e–4g implementation + +Final verification: **223 checks pass**, including **109 Phase 4 tests**. Both standalone builds are deterministic; see the [4f evidence](evidence/phase4/2026-09-06-phase4f-execution.md) for exact test execution and artifact hashes. + +The procedural runtime advances graphic, component, emitter and repeater object behaviors on logical ticks, including point motion and morph geometry. System-local automation updates the render inputs; exhibit automation uses the shared resolution pipeline for public visual targets. Spawned instances have bounded ownership, release, disposal and atomic refusal. Seven post-effects execute on frame pixels; aggregate ceilings remain provisional pending measurement. + +All fourteen PRD 130 challenge fixtures and first visual compositions for Exhibits A–D are authored data. The standalone [visual review page](../prototypes/phase4/XZBT-visual-acceptance.html) supplies reproducible test signals and observation export. Automated checks establish execution and deterministic decisions, not visual quality, live audio acceptance, full reference-exhibit completion, or the 4h benchmark. Earlier contract-only checkpoints below remain historical evidence. + +## Phase 5 events and cadence + +Final verification: **229 checks pass**, including **14 Phase 5 tests**. Both standalone builds are deterministic; the production artifact hash is `f3f634d7d33b2f8a57cae541fc31b4dcedce88b2ee2648093f92a2cfc168d718`. See the [Phase 5 evidence](evidence/phase5/2026-09-06-phase5-events-cadence.md) for detailed trace coverage and implementation details. + +Format Specification revision 0.9 adds Section 20, completing the Cadence and Event subsystems contract with 14 acceptance traces. The JSON Schema and semantic validator enforce DAG event cycle detection (`ERR_CYCLIC_DEPENDENCY`), cadence clock range ordering (`ERR_INVALID_RANGE_ORDER`), and sound cadence metadata. The Action Model enforces the 1024-unit/tick dispatch budget (`ERR_DISPATCH_BUDGET`), 16-level event nesting depth limit, event parameter scoping and strict type validation (`ERR_TYPE_MISMATCH`, `ERR_UNKNOWN_FIELD`), and sound usage gating (`ERR_UNSUPPORTED_TARGET`). The CadenceSubsystem schedules four class clocks (`routine`, `intermittent`, `occasional`, `rare`), enforces the 1.5s minimum gap with class priority ordering, applies anti-repetition recency multipliers (`[0.0, 0.25, 0.50, 0.75]`) with pool relaxation on deadlock, maintains continuous ambient voices, and runs on an isolated PRNG stream (`cadence` domain). Exhibits A–D include complete audio buses, sound recipes, cadence clocks, and lifecycle events. + diff --git a/docs/XZBT_0-1_Format_Specification.md b/docs/XZBT_0-1_Format_Specification.md index 927c26e..59a7561 100644 --- a/docs/XZBT_0-1_Format_Specification.md +++ b/docs/XZBT_0-1_Format_Specification.md @@ -1,8 +1,8 @@ # XZBT Format Specification 0.1 **XZBT format version:** 0.1 -**Document revision:** 0.8 -**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem contract is complete (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses, Phase 3c automation/lifecycle/protection); master-protection values remain provisional pending GC6 measurement; the Visual subsystem contract is complete (Phase 4a scene/primitives/transforms/appearance in section 17, Phase 4b components/procedural systems/behaviors/fields in section 18, and Phase 4c automation/lifecycle/camera/post-effects/ceilings in section 19); the aggregate visual ceilings of 19.5 remain provisional pending the slice 4h GC6 measurement; revision 0.8 applies the §§17-19 multi-model review triage in full, resolving thirty repair packages and three additional defects across the visual contract before renderer implementation begins; and the remaining subsystem contracts are in progress +**Document revision:** 0.9 +**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem contract is complete (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses, Phase 3c automation/lifecycle/protection); master-protection values remain provisional pending GC6 measurement; the Visual subsystem contract is complete (Phase 4a scene/primitives/transforms/appearance in section 17, Phase 4b components/procedural systems/behaviors/fields in section 18, and Phase 4c automation/lifecycle/camera/post-effects/ceilings in section 19); the aggregate visual ceilings of 19.5 remain provisional pending the slice 4h GC6 measurement; the Cadence and Event Subsystems contract is complete in section 20 (Phase 5); and the remaining subsystem contracts are in progress **Related resources:** [PRD](../XZBT_0-1_MVP_Product_Requirements_Document.md), [decisions](XZBT_0-1_Gap_Closure_Decisions.md), [verification](XZBT_0-1_Verification_Gates.md) This document defines normative syntax and runtime semantics for XZBT 0.1 exhibits. An exhibit is a UTF-8 JSON document that configures generic procedural visual, audio, cadence, and orchestration primitives. It does not contain executable JavaScript. @@ -243,6 +243,8 @@ Durations are expressed as strings containing a non-negative finite number and a Compound formats (e.g., `"1m30s"`) are strictly invalid (`ERR_INVALID_DURATION`). Internally, the runtime converts all durations to millisecond floating-point numbers. +A duration that is already a **non-negative finite number** is that millisecond value and is accepted wherever this contract says DurationSpec. The literal string is the authored form and the only form a hand-written document uses; the numeric form exists because a DurationSpec may be the resolved output of a ValueSpec or a bounded `TimeSpec` (6.2), which produces milliseconds rather than a literal. A negative or non-finite number is `ERR_INVALID_DURATION`, as is any other type. This applies at every DurationSpec field, `spawn.lifetime` and `spawn.release` (19.2) included; the one exception is an automation track's `at`, which 19.1 fixes as a duration literal only so that point ordering stays decidable at import. + ### 6.2 Procedural Bounded Duration (`TimeSpec`) Where procedural timing is permitted (e.g., cadence intervals, scenario wait steps), a bounded random TimeSpec may be used: ```json @@ -531,9 +533,9 @@ Complete shared contracts before implementing dependent subsystems. Use PRD sect | References and bindings | 13, 17, 31-32 | Target-capability table, instance/input scope, evaluation order, cycles, disabled bindings, exact smoothing | **Complete (Rev 0.3 / GC3)** | | Actions and transitions | 22-30 | Shared `set`/`override` fields and defaults, override target matrix, interrupted transitions, instance IDs; subsystem action matrices remain with their subsystems | **Shared contract complete (Rev 0.3 / GC3)** | | Audio | 34-60 | Complete recipe/component shapes, node defaults, automation timing, clamp implementation, one-shot endings and tails, unlock behavior, master protection contract | **Complete (Rev 0.4 / Phase 3a-3c):** units, graph objects, all sixteen node types, routing, modulation, graph legality, authoring limits, components, sounds/recipes, buses, automation tracks and precedence, lifecycle states and release, determinable one-shot endings, voice ceilings, unlock and pause behavior, and the master-protection contract shape. Master-protection *values* (peak ceiling, tolerance, release behavior) are provisional pending GC6 measurement | -| Cadence | 61-68 | Exact selection/cooldown/overlap fields, clocks, pool collisions, pending audio, manual sampling isolation and continuous-sample termination | Subsystem contract (Phase 5) | +| Cadence | 61-68 | Exact selection/cooldown/overlap fields, clocks, pool collisions, pending audio, manual sampling isolation and continuous-sample termination | **Complete (Rev 0.9 / Phase 5):** section 20 fixes cadence classes, clocks, interval ranges, selection algorithm, cooldown, overlap policy, anti-repetition relaxation, minimum automatic gap, ambient maintenance, and manual SAMPLE PRNG stream isolation | | Visuals | 69-89 | Primitive/system/behavior fields, angle and motion units, transform order, depth projection, field behavior, morph compatibility, effect approximation and limits | **Complete (Rev 0.7 / Phase 4a-4c):** section 17 fixes the pipeline, canonical units and the angle convention, the `visuals` container, layers, the scene model and coordinate/fit modes, depth sign and sorting, the fourteen primitives, common properties, transform composition order, appearance and the safe blend set, paths and splines, and the once-at-instantiation resolution boundary. Section 18 fixes visual components and their `inputs` scope, particle systems and their normative integrator, the nine placement distributions, emitters and exact emission timing, repeaters and the `repeat.*` scope, the seventeen-behavior vocabulary and its channel set, the six procedural fields and their normative coherent-noise function, and trails, ribbons, and links. Section 19 fixes visual automation and its two declaration scopes and loop modes, the four visual rows it adds to the section 8.1 table and nothing beyond them, the persistent and spawned system lifecycle and its ownership, the camera matrix and both projection modes, the seven post-effects and the boundary of permitted approximation, and the centralized runtime ceilings for the whole engine. Aggregate ceiling *values* are provisional pending the slice 4h GC6 measurement | -| Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; full trigger/timeline shapes remain for Phase 6 | **Shared lifecycle contract complete (Rev 0.3 / GC5)** | +| Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; full trigger/timeline shapes remain for Phase 6 | **Shared lifecycle contract complete (Rev 0.3 / GC5); Event Model 0.1 complete (Rev 0.9 / Phase 5 in section 20); scenario triggers/timelines Phase 6** | | Generated UI | 103-107 | Widget compatibility, button actions, parameter validation and override display, group/control ordering | Subsystem contract (Phase 7) | | Runtime/library | 108-112, 117-127 | Clock/audio synchronization, stalls, seed/stream derivation complete in GC4; import equality, update compatibility, transactions, failure recovery, persistence schema remain | **Shared clock/PRNG contract complete (Rev 0.3 / GC4); subsystem remainder Phase 1/8** | @@ -2010,7 +2012,7 @@ Because `w` comes from the viewer's own generic font, the condensation factor is ### 17.14 Visual field resolution scope -Numeric, boolean, string, and color fields on visual objects are authored with ValueSpec 0.1 (section 4) and DurationSpec 0.1 (section 6) exactly where each field table says so. Every such field is resolved **once**, at its owning object's instantiation boundary, producing that field's **sampled base**, which is constant for the object's lifetime. The base is not necessarily the value the frame draws: a behavior (18.6) or an automation track (19.1) composes over it, and for the four external target families of 8.1 the shared pipeline does. What is fixed at instantiation is the base — the sampling, and therefore every procedural decision — not the effective value. Nested ValueSpecs within one system are sampled in depth-first, property-document order from the system instance's own stream, per section 9.3 — the same rule section 14.4 fixes for audio node fields. +Numeric, boolean, string, and color fields on visual objects are authored with ValueSpec 0.1 (section 4) and DurationSpec 0.1 (section 6) exactly where each field table says so. Every such field is resolved **once**, at its owning object's instantiation boundary, producing that field's **sampled base**, which is constant for the object's lifetime. The base is not necessarily the value the frame draws: a behavior (18.6) or an automation track (19.1) composes over it, and for the four external target families of 8.1 the shared pipeline does. What is fixed at instantiation is the base — the sampling, and therefore every procedural decision — not the effective value. Nested ValueSpecs within one system are sampled in depth-first, property-document order from the system instance's own stream, per section 9.3 — the same rule section 14.4 fixes for audio node fields. One tie-break completes that rule for containers: an object's **own** fields are sampled in document order, and its `children` (or, for a `component`, the expanded `content`) are sampled **after** all of them, wherever the container key sits in the object. A group's style scope has to exist before its descendants can inherit it (17.12), so the two orders cannot be interleaved, and stating the tie-break is what keeps two renderers at the same stream position. The instantiation boundary is: activation, for a persistent system declared in `visuals.systems`; the spawn moment, for a spawned system (19.2); and the emission or repetition moment, for an object created by an emitter or repeater (18.4, 18.5). @@ -2161,7 +2163,7 @@ A `particles` system maintains a bounded pool of lightweight items that share on | --- | --- | :---: | --- | --- | | `render` | visual object or `component` object | Yes | — | The object drawn for each particle (17.9, 18.1). Its own `position`, `z`, and `visible` are `ERR_UNKNOWN_FIELD`; the particle owns those. | | `capacity` | number | No | `256` | Integer, `1` to `4096`. Maximum concurrent particles in the pool. | -| `count` | ValueSpec\ | No | `0` | Integer. Particles created at the system's instantiation boundary (17.14). | +| `count` | ValueSpec\ | No | `0` | Integer, `0` through `capacity`; a larger resolved initial population is `ERR_VISUAL_LIMIT_EXCEEDED`. Particles created at the system's instantiation boundary (17.14). | | `rate` | ValueSpec\ | No | `0` | Particles per logical second, continuous. Emission timing is 18.4. | | `burst` | array | No | `[]` | Burst entries (18.4), `0` to `16`. | | `limit` | number | No | — | Integer. Total particles this system may ever create. Absent is unbounded. | @@ -2539,7 +2541,8 @@ with `curve` the four-curve set of 18.2 evaluated from `0` to `1`. The contribut | `drift` | **Accumulating.** Its contribution grows by `velocity * dt` each tick, with `velocity` scaled by `(1 - damping)^dt` first. | | `rotate` | **Accumulating** on `transform.rotation`, by `speed * dt` each tick. | | `wander`, `bounce`, `attract`, `repel`, `field-follow` in `force` or `velocity` mode | **Accumulating** through velocity: they write the item's velocity, which the integrator of 18.2 turns into displacement. | -| `oscillate`, `pulse`, `twinkle`, `orbit`, `follow-path`, `noise-displace`, `point-wander`, `field-follow` in `direct` mode, `morph` | **Fresh.** The contribution is a pure function of `t` and the resolved configuration, recomputed from scratch every tick and never integrated. | +| `wrap` | **Accumulating.** A wrap is a permanent relocation, not a per-tick displacement: crossing an edge adds the bounds extent to the object's accumulated offset once and it stays added. | +| `oscillate`, `pulse`, `twinkle`, `orbit`, `follow-path`, `noise-displace`, `point-wander`, `face-motion`, `field-follow` in `direct` mode, `morph` | **Fresh.** The contribution is a pure function of `t` and the resolved configuration, recomputed from scratch every tick and never integrated. `field-follow` in `direct` mode displaces by `field x strength` scene units with **no** `dt` factor — an instantaneous displacement, which is what makes it the mode that does not integrate. | For `orbit` in particular the contribution is an absolute placement expressed as an offset from the object's resolved base position `p0`: @@ -2821,7 +2824,7 @@ This section closes the Visual contract sections 17 and 18 opened. It covers vis Sections 17 and 18 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time and what it refuses to do, which is the same division sections 14-15 and 16 use for audio. Every forward reference to 19.1, 19.2, and 19.5 left open by sections 17 and 18 resolves here; after this section the visual contract contains no unresolved forward reference. -**Implementation status.** No visual runtime code exists. Sections 17, 18, and 19 are contract only: the JSON Schema has no visual definitions, `src/runtime` has no renderer module, and nothing has been drawn from any build. Slice 4d begins the renderer; 4f implements this section. The ceilings of 19.5 are provisional in the same sense the master-protection values of 16.7 are provisional — their *shape* is normative now, their *values* are confirmed by the slice 4h GC6 measurement. +**Implementation status.** Slices 4d–4g implement sections 17–19 in the production visual runtime, with automated contract/execution tests and authored challenge fixtures. Display judgment and slice 4h measurement remain pending. The ceilings of 19.5 remain provisional: their shape is normative, and their values require GC6 measurement. ### 19.1 Visual automation @@ -3115,7 +3118,7 @@ r_device = r_scene * zoom * sqrt(sx * sy) * dpr_effective This is the uniform appearance factor `g` of 19.3 with `dpr_effective` (19.5) folded in, and with the two terms a post-effect **cannot** have deliberately absent: an effect has no object, so the perspective factor `k` is `1`; and it runs after every layer is composited, so no layer's `parallax` enters it. Borrowing either — taking the depth of the nearest object, or the parallax of the last layer drawn — would make a full-frame effect depend on scene content and is specifically excluded. Under nonuniform `stretch` the geometric mean `sqrt(sx * sy)` is what a radius with no axis can take, exactly as for a stroke width. Where the effect is computed at a reduced resolution factor `q` (below), the radius used inside that buffer is `r_device * q`, so the upsampled result matches the full-resolution one to within the approximation the warning already declares. -**Effect parameters resolve once, at activation.** Every ValueSpec in `visuals.effects`, including each entry's `enabled`, is resolved at exhibit activation — the effects chain is exhibit-scoped, so activation is its instantiation boundary (17.14). Sampling order is array order, and within one entry document field order after `type`, so a fixture's stream position after the chain is fixed. `color` parameters and `type` are authored literals, not ValueSpecs (17.12). The resolved value is the base the shared pipeline of 8.1 and the automation of 19.1 then compose over; `enabled` has no automation stage, being boolean, but is a binding and `override` target through its entry's row. +**Effect parameters resolve once, at activation.** Every ValueSpec in `visuals.effects`, including each entry's `enabled`, is resolved at exhibit activation — the effects chain is exhibit-scoped, so activation is its instantiation boundary (17.14). Sampling order is array order, and within one entry document field order after `type`, so a fixture's stream position after the chain is fixed. `color` parameters and `type` are authored literals, not ValueSpecs (17.12). The resolved value is the base the shared pipeline of 8.1 and the automation of 19.1 then compose over; `enabled` resolves once and has no external target capability. Section 19.1 exposes only numeric effect parameters; enabling an effect dynamically requires a future contract revision. **Filter parameter names map to the 17.12 operations without renaming.** `color-adjust`'s `brightness`, `contrast`, and `saturation` are the `brightness`, `contrast`, and `saturate` operations of 17.12, and its `hueRotate` is `hue-rotate`, with identical ranges and semantics. The authored spellings differ because one is an effect parameter and the other a filter `type` token; the operations are the same and a runtime implements them once. @@ -3280,3 +3283,296 @@ User-observed, and **not** satisfiable by the above: Trace 6 of 17.16 — the perspective factor at three depths — stays in 4d. Depending on 19.3's static contract is what makes it testable; dropping it to avoid the dependency would remove the one automated check on the composition chain, which is where the unit-mismatch defects of this review lived. A parsed stub is never a passed runtime trace: a slice reports a trace as passed only when the behavior it names actually ran. Traces 1-19 belong to slice 4f. Traces 20 and 21 close slice 4h. Phase 4 is not accepted until both do, no matter how many automated traces pass, and the ceilings of 19.5 are not measured until trace 21 says so. + +--- + +## 20. Cadence and Event Subsystems Contract (Phase 5) + +### 20.1 Pipeline and scope + +The Cadence and Event subsystems provide the temporal orchestration and procedural trigger mechanisms that animate an exhibit between continuous modulation and autonomous scenarios. + +Cadence operates on the engine's fixed logical clock ($60\text{ Hz}$, $1000/60\text{ ms}$ fixed step, section 9.1). It drives automatic recurring sound activity through independent class schedulers, maintaining aesthetic variety through weighted random selection, cooldowns, overlap prevention, and recency-based anti-repetition penalties. + +Events provide reusable, parameterized bundles of actions that execute atomically within a logical tick. Events may be invoked by cadence, by manual UI interactions (soundboard / buttons), by scenarios, or by other events up to a bounded nesting depth. + +### 20.2 Canonical cadence classes and usage separation + +#### 20.2.1 Cadence classes (PRD 61) +Every sound in an exhibit that participates in automatic scheduling or ambient maintenance is assigned a canonical cadence class in its `cadence.class` field: + +| Class | Semantic Meaning | Scheduling Policy | +| :--- | :--- | :--- | +| `ambient` | Persistent background audio beds, drones, or continuous atmospheres. | Maintained automatically while audio is unlocked. Not scheduled by pulse intervals. | +| `routine` | Frequent, predictable automatic background activity. | Independent periodic scheduler (default 10s–45s). | +| `intermittent` | Periodic recurring activity with noticeable gaps. | Independent periodic scheduler (default 45s–4m). | +| `occasional` | Infrequent, prominent exhibit events. | Independent periodic scheduler (default 3m–15m). | +| `rare` | Unusual or special exhibit events. | Independent periodic scheduler (default 15m–60m). Highest priority during simultaneous firings. | +| `scenario` | Orchestrated audio reserved exclusively for scenario timelines. | **Never** scheduled by the automatic cadence engine. | + +Any other class value is a semantic validation error (`ERR_SCHEMA_VALIDATION`). + +#### 20.2.2 Usage and cadence separation (PRD 62) +A sound's `usage` array defines which execution surfaces are authorized to invoke it: +* `"automatic"`: Authorized for invocation by the automatic cadence engine. +* `"manual"`: Authorized for invocation by direct user actions (soundboard / UI buttons). +* `"scenario"`: Authorized for invocation by scenario actions or timelines. + +Defaults to `["automatic"]` if omitted. + +A sound action executed from a manual context naming a sound whose `usage` does not include `"manual"` is rejected with `ERR_UNSUPPORTED_TARGET`. A sound action executed from a scenario naming a sound whose `usage` does not include `"scenario"` is rejected with `ERR_UNSUPPORTED_TARGET`. The automatic cadence engine considers only sounds whose `usage` includes `"automatic"`. + +### 20.3 Cadence configuration container (`cadence`) + +The top-level `cadence` object configures exhibit-wide cadence behavior: + +```json +{ + "cadence": { + "intensity": { "ref": "parameters.activity" }, + "minGap": "1.5s", + "clocks": { + "routine": { "min": "8s", "max": "30s" }, + "intermittent": { "min": "30s", "max": "3m" }, + "occasional": { "min": "2m", "max": "10m" }, + "rare": { "min": "10m", "max": "45m" } + } + } +} +``` + +| Field | Type | Required | Default | Description | +| :--- | :--- | :---: | :--- | :--- | +| `intensity` | ValueSpec 0.1 | No | `1.0` | Global cadence intensity. Resolves to a number clamped to `[0, 1]`. | +| `minGap` | DurationSpec 0.1 | No | `"1.5s"` | Minimum time separation between consecutive automatic one-shot sound starts. | +| `clocks` | object | No | Default ranges | Map of class ID (`routine`, `intermittent`, `occasional`, `rare`) to `{ min, max }` ranges. | + +Unrecognized fields in `cadence` produce `ERR_UNKNOWN_FIELD`. In `clocks.`, `min` and `max` must be valid positive DurationSpec 0.1 strings with `min <= max`; inverted bounds produce `ERR_INVALID_RANGE_ORDER`. + +### 20.4 Sound cadence metadata (`sounds..cadence`) + +A sound's optional `cadence` property configures its behavior within the cadence engine: + +```json +{ + "sounds": { + "relay-click": { + "name": "Relay Click", + "usage": ["automatic", "manual"], + "cadence": { + "class": "routine", + "weight": 1.5, + "cooldown": "5s", + "overlap": false, + "when": { "op": ">", "left": { "ref": "state.pressure" }, "right": 0.2 } + }, + "recipe": { ... } + } + } +} +``` + +| Field | Type | Required | Default | Description | +| :--- | :--- | :---: | :--- | :--- | +| `class` | enum | **Yes** | — | One of the six canonical classes. | +| `weight` | numeric ValueSpec 0.1 | No | `1.0` | Base selection weight. Evaluated from the cadence stream. Must be $\ge 0$. | +| `cooldown` | DurationSpec 0.1 | No | `"0ms"` | Minimum time after firing before this sound may be selected again. | +| `overlap` | boolean | No | `true` | If `false`, cannot be selected if any voice of this sound is active in the engine. | +| `when` | ConditionSpec 0.1 | No | — | Optional eligibility condition. Must evaluate true for selection. | + +Unrecognized fields in `sounds..cadence` produce `ERR_UNKNOWN_FIELD`. Negative literal weight produces `ERR_OUT_OF_BOUNDS`. + +### 20.5 Cadence selection algorithm and anti-repetition (PRD 64, 65) + +Each automatic class (`routine`, `intermittent`, `occasional`, `rare`) maintains an independent timer and a recency history queue of depth $4$. + +#### 20.5.1 Firing step +When a class timer expires: +1. **Eligible Pool:** Collect all declared sounds where `cadence.class` matches the firing class, `usage` contains `"automatic"`, and any declared `when` condition evaluates to `true`. +2. **Cooldown Filter:** Exclude any sound where $\text{currentLogicalTime} - \text{lastFiredTime} < \text{cooldown}$. +3. **Overlap Filter:** For each sound with `overlap === false`, inspect the audio subsystem's active voices (`SCHEDULED`, `ACTIVE`, `RELEASING`). Exclude the sound if any voice of that sound is currently active. +4. **Base Weights:** Evaluate `weight` for each remaining sound using `rng.stream('cadence', ':weight:')`. Clamp to $\ge 0$. +5. **Anti-Repetition Multipliers:** For each remaining candidate, look up its position in the class's recency queue: + * Most recently selected ($1$ selection ago): multiplier $0.0$ + * $2$ selections ago: multiplier $0.25$ + * $3$ selections ago: multiplier $0.50$ + * $4$ selections ago: multiplier $0.75$ + * Older / not in recency queue: multiplier $1.0$ + $\text{effectiveWeight} = \text{baseWeight} \times \text{multiplier}$. +6. **Pool Relaxation (PRD 65):** If the sum of effective weights for all remaining candidates is $0$ (which occurs when all eligible sounds are penalized to zero, such as in single-sound pools): + * Relax anti-repetition penalties: set all multipliers to $1.0$ ($\text{effectiveWeight} = \text{baseWeight}$). + * If the sum of base weights is still $0$, the class firing is skipped with no sound played. +7. **Weighted Selection:** Draw a pseudo-random value $u \in [0, 1)$ from `rng.stream('cadence', ':select:')` and pick the winning sound proportional to its effective weight. +8. **Execution:** Instantiate and play the winning sound via the audio subsystem. +9. **History Update:** Record the winning sound's firing timestamp $\text{lastFiredTime} = \text{currentLogicalTime}$. Append the winning sound ID to the front of the class recency queue, trimming the queue to maximum depth $4$. +10. **Reschedule:** Sample the next interval uniformly from $[T_{\min}, T_{\max}]$ using `rng.stream('cadence', ':interval:')`, scale by $1 / \text{intensity}$, and set the next class timer. + +### 20.6 Minimum automatic gap and priority scheduling (PRD 67) + +To prevent simultaneous or jarringly close auditory collisions between different automatic classes: +* The runtime tracks $\text{lastAutomaticSoundTime}$, the logical timestamp of the most recent automatic one-shot start. +* A class firing is permitted only if $\text{currentLogicalTime} - \text{lastAutomaticSoundTime} \ge \text{minGap}$ (default $1.5\text{s}$). +* If multiple classes become due simultaneously or while a gap hold is in effect, they are queued and serviced in strict canonical priority order: + $$\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$$ +* Deferred classes retain their firing opportunity: when the required $\text{minGap}$ has elapsed, the highest-priority deferred class fires immediately and resets the gap timer. + +### 20.7 Cadence intensity (PRD 66) + +The `cadence.intensity` ValueSpec resolves on each logical tick to a finite number clamped to $[0, 1]$. +* Effective interval: $T_{\text{effective}} = T_{\text{base}} / \text{intensity}$. +* When $\text{intensity} \le 0$ (or $< 10^{-6}$): + * All automatic one-shot class scheduling pauses. + * No class timers advance or fire. + * Existing playing voices play out their natural release or ending. +* When $\text{intensity}$ rises above zero, timers resume with intervals scaled by the new intensity. +* Continuous `ambient` sounds are unaffected by cadence intensity unless explicitly bound to a parameter. + +### 20.8 Ambient sound maintenance (PRD 61, 139) + +Sounds with `cadence.class === "ambient"` and `usage` containing `"automatic"` represent persistent exhibit beds: +* When the audio subsystem is active and unlocked, the runtime ensures that continuous ambient sounds are instantiated and playing. +* If an ambient voice finishes, releases, or fails unexpectedly, the runtime restarts it at the next logical tick. +* Pausing the performance pauses ambient voices; stopping or deactivating disposes them cleanly. + +### 20.9 Manual SAMPLE isolation semantics (PRD 68) + +Manual soundboard playback (SAMPLE buttons in the UI) allows auditioning sounds independently of the performance: +* Manual SAMPLE playback uses exclusively the isolated PRNG stream `sample` (`rng.stream('sample', ':')`). +* SAMPLE playback: + * Does **not** advance cadence clocks. + * Does **not** alter class recency history. + * Does **not** alter sound cooldown timestamps. + * Does **not** alter automatic selection weights. + * Does **not** reset or perturb the minimum automatic gap timer. +* Two runs of an exhibit with arbitrary manual SAMPLE buttons clicked in between produce identical automatic cadence sequences and visual frames. + +### 20.10 Event Model 0.1 (`events.`) (PRD 90, 91) + +The top-level `events` container defines reusable, named action bundles: + +```json +{ + "events": { + "minor-disturbance": { + "inputs": { + "intensity": { "type": "number", "default": 0.5 }, + "pitch": { "type": "number", "default": 440 } + }, + "actions": [ + { + "type": "sound", + "sound": "warning-tone", + "with": { "pitch": { "ref": "inputs.pitch" } } + }, + { + "type": "override", + "target": "parameters.activity", + "value": { "ref": "inputs.intensity" }, + "scope": "duration", + "duration": "2s" + } + ] + } + } +} +``` + +* `inputs`: Optional map of input identifier to parameter-like declaration (`type`, `default`, optional `min`, `max`, `step`, `values`). +* `actions`: Required array of Action Model 0.1 action objects. Must contain at least one action. + +Unrecognized properties in `events.` or `inputs.` produce `ERR_UNKNOWN_FIELD`. Invalid identifiers produce `ERR_INVALID_ID`. + +### 20.11 Event action execution (`type: "event"`) (PRD 27, 91) + +An `event` action invokes a declared event: + +```json +{ + "type": "event", + "event": "minor-disturbance", + "with": { + "intensity": 0.8, + "pitch": 880 + }, + "when": { "op": ">", "left": { "ref": "state.power" }, "right": 0.5 }, + "chance": 0.9, + "critical": false +} +``` + +* `event`: ID of the target declared in `events`. Undeclared target is `ERR_INVALID_REFERENCE`. +* `with`: Map of input values. Evaluated in the caller's context before event entry. Values must match declared input types without implicit coercion (`ERR_TYPE_MISMATCH`). Undeclared input names in `with` produce `ERR_UNKNOWN_FIELD`. Omitted inputs take declared defaults; missing required inputs without defaults fail with `ERR_SCHEMA_VALIDATION`. +* **Input Scoping:** Within the invoked event actions, references matching `inputs.` resolve to the evaluated input value. Inputs are strictly local to that event invocation instance. +* **Context Inheritance:** Nested actions inherit the caller's ownership context (scenario instance owner or performance root) and critical propagation setting (section 10.1). + +### 20.12 Sound action execution (`type: "sound"`) (PRD 26) + +A `sound` action triggers an audio voice: + +```json +{ + "type": "sound", + "sound": "relay-click", + "with": { + "pitch": 440 + }, + "ownership": "scenario" +} +``` + +* `sound`: ID of the target declared in `sounds`. Undeclared target is `ERR_INVALID_REFERENCE`. +* `with`: Optional map of input values for sound component/recipe expressions. +* `ownership`: Optional ownership assignment (`"performance"` | `"scenario"` | `"persistent"`). Continuous sounds invoked by scenarios inherit scenario ownership and release at scenario completion (section 10.1); `"persistent"` keeps resources active across scenario boundaries. +* **Usage Check:** The sound must permit the caller's execution mode: + * Dispatched from manual UI: sound `usage` must include `"manual"`. + * Dispatched from a scenario: sound `usage` must include `"scenario"`. + * Dispatched from cadence: sound `usage` must include `"automatic"`. + Violations produce `ERR_UNSUPPORTED_TARGET`. + +### 20.13 Dispatch budget, nesting limits, and static cycle detection (PRD 91, Section 10.5) + +#### 20.13.1 Static cycle detection +The static dependency graph of event-to-event invocations (direct or indirect) must be a Directed Acyclic Graph (DAG). Any cycle (e.g. $A \to B \to A$) is rejected during semantic validation at import with `ERR_CYCLIC_DEPENDENCY`. + +#### 20.13.2 Runtime nesting depth limit +Event execution tracks call stack nesting depth. If an event invocation reaches depth $> 16$, execution halts with `ERR_DISPATCH_BUDGET`. + +#### 20.13.3 Per-tick ordinary dispatch budget +To prevent infinite data-dependent feedback loops between events, state mutations, and triggers: +* A hard ceiling of $1024$ ordinary dispatch units is enforced per logical tick. +* Entering an event consumes $1$ dispatch unit. +* Attempting an action consumes $1$ dispatch unit (including actions skipped due to `when` or `chance`). +* When the $1025\text{th}$ unit would be consumed, the runtime emits `ERR_DISPATCH_BUDGET`, discards the remaining ordinary queue for that tick, and terminates any affected scenario owner as `FAILED`. +* Mandatory termination hooks (`onComplete`, `onCancel`) are exempt from the ordinary budget and use their own isolated $256$-action ceiling (section 10.2). + +### 20.14 Diagnostic codes + +Phase 5 reuses existing standard codes from the Section 7 table: +* `ERR_SCHEMA_VALIDATION`: Missing required fields, invalid types, or invalid enum values (e.g. unknown cadence class). +* `ERR_UNKNOWN_FIELD`: Undeclared property in `cadence`, `sounds..cadence`, `events.`, or event action `with` map. +* `ERR_INVALID_ID`: Invalid event identifier. +* `ERR_INVALID_REFERENCE`: Target sound or event does not exist, or dangling reference in input expression. +* `ERR_UNSUPPORTED_TARGET`: Sound action invokes a sound whose `usage` excludes the caller's context. +* `ERR_TYPE_MISMATCH`: Action `with` value type does not match declared event input type. +* `ERR_CYCLIC_DEPENDENCY`: Static event invocation dependency cycle detected. +* `ERR_INVALID_RANGE_ORDER`: `clocks.` `min` duration exceeds `max` duration. +* `ERR_OUT_OF_BOUNDS`: Negative cadence weight. +* `ERR_DISPATCH_BUDGET`: Per-tick ordinary dispatch budget ($1024$) or event nesting depth ($16$) exceeded. + +### 20.15 Required traces before Phase 5 implementation is accepted + +1. **Schema & Validation:** Valid and malformed `cadence`, `sounds..cadence`, and `events` blocks validate or reject with documented error codes. +2. **Static Cycle Detection:** Direct self-invocation ($A \to A$) and transitive cycles ($A \to B \to C \to A$) fail semantic validation with `ERR_CYCLIC_DEPENDENCY`. Valid acyclic event hierarchies pass. +3. **Event Execution & Scoping:** Event action executes member actions in order, resolves `inputs.` from passed `with` values, falls back to declared defaults, and isolates inputs across concurrent invocations. +4. **Event Nesting & Depth Limit:** Nested events pass inputs through multiple levels up to depth 16; depth 17 raises `ERR_DISPATCH_BUDGET`. +5. **Per-Tick Dispatch Budget:** Ordinary actions and events exceeding $1024$ units in a single tick halt with `ERR_DISPATCH_BUDGET` and discard remaining ordinary queue while preserving scenario termination cleanup. +6. **Sound Action Execution:** Sound actions validate sound references, pass inputs to recipes, check caller usage permissions, and reject unauthorized usage with `ERR_UNSUPPORTED_TARGET`. +7. **Cadence Eligibility & Cooldown:** A sound with `when: false` or active cooldown ($\text{elapsed} < \text{cooldown}$) is excluded from selection. +8. **Cadence Overlap Policy:** A sound with `overlap: false` is excluded from selection while any voice of that sound is active in the audio engine; a sound with `overlap: true` admits overlapping voices up to voice ceilings. +9. **Cadence Anti-Repetition Multipliers:** Consecutive firings verify multipliers $0.0$, $0.25$, $0.50$, $0.75$, $1.0$ against the class recency history. +10. **Pool Relaxation:** When all eligible sounds have effective weight zero, anti-repetition penalties relax to base weights and selection succeeds rather than permanently stalling. +11. **Cadence Intensity Scaling:** Decreasing intensity increases interval spacing ($T / \text{intensity}$); setting intensity to $0$ stops one-shot class scheduling completely. +12. **Minimum Automatic Gap & Priority:** Simultaneous class firings enforce `minGap` (default $1.5\text{s}$) between audio starts and service deferred classes in priority order ($\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$). +13. **Ambient Maintenance:** Ambient sounds (`cadence.class: "ambient"`) auto-start on audio unlock and are maintained by the runtime. +14. **Manual SAMPLE Isolation:** Manual SAMPLE playback draws exclusively from `rng.stream('sample', ...)` and leaves cadence clocks, cooldowns, recency history, and automatic PRNG sequences byte-identical. + diff --git a/docs/XZBT_0-1_Implementation_Plan.md b/docs/XZBT_0-1_Implementation_Plan.md index 0e88f5c..9e7d24b 100644 --- a/docs/XZBT_0-1_Implementation_Plan.md +++ b/docs/XZBT_0-1_Implementation_Plan.md @@ -44,15 +44,15 @@ Phase 4 is the largest milestone in this plan: twenty-one PRD sections (69-89) p | 4a — Contract I | **Complete at Revision 0.5.** Format Specification section 17: pipeline and canonical units, the `visuals` container, layers, scene model and coordinate/fit modes (PRD 70), 2.5D depth (PRD 71), the fourteen geometry primitives (PRD 72), common visual properties (PRD 73), the transform model (PRD 74), appearance and the safe blend set (PRD 75), and paths and splines (PRD 76). Documentation only; no runtime change. | Sections 1-13 | — | | 4b — Contract II | Format Specification section 18: visual components (PRD 77), particle systems (PRD 78), placement distributions (PRD 79), emitters (PRD 80), repeaters (PRD 81), the behavior vocabulary (PRD 82), procedural fields (PRD 83), and trails, ribbons, and links (PRD 84). Documentation only. **Complete at Revision 0.6.** | 4a | — | | 4c — Contract III | **Complete at Revision 0.7.** Format Specification section 19: visual automation and loop modes (PRD 85), visual lifecycle and ownership (PRD 86), camera and projection (PRD 87), post-processing (PRD 88), visual safety limits (PRD 89), the centralized runtime ceilings of PRD 120, and the four system-level visual rows added to the section 8.1 target-capability table. Documentation only. | 4a, 4b | — | -| 4d — Renderer core | Canvas 2D backend, the scene/coordinate/fit resolution, layers, the transform stack, the fourteen primitives, appearance and paint, paths and splines, and depth sorting. Widest new-code surface in the phase. | 4a | — | -| 4e — Procedural systems | Visual components, particles, distributions, emitters, repeaters, behaviors, fields, trails/ribbons, and distance links. | 4b, 4d | — | -| 4f — Automation, lifecycle, and effects | Visual automation into the shared section 8 resolution path, spawned-system lifecycle and ownership, camera projection, post-effect passes, and enforcement of the centralized ceilings. Touches the Phase 2 and Phase 3c resolution path, so it carries the widest regression surface in Phase 4. | 4c, 4e | — | -| 4g — Challenge fixtures and exhibit visuals | The fourteen PRD 130 challenge fixtures, proven to need no subject-specific renderer code, and the first visuals for Exhibits A-D. | 4d, 4e, 4f | — | +| 4d — Renderer core | **Implemented; automated checks pass.** Canvas 2D backend, the scene/coordinate/fit resolution, layers, the transform stack, the fourteen primitives, appearance and paint, paths and splines, and depth sorting. Widest new-code surface in the phase. | 4a | — | +| 4e — Procedural systems | **Implemented; automated checks pass.** Visual components, particles, distributions, emitters, repeaters, behaviors, fields, trails/ribbons, and distance links. | 4b, 4d | — | +| 4f — Automation, lifecycle, and effects | **Implemented; automated checks pass.** Visual automation into the shared section 8 resolution path, spawned-system lifecycle and ownership, camera projection, post-effect passes, and enforcement of the centralized ceilings. Touches the Phase 2 and Phase 3c resolution path, so it carries the widest regression surface in Phase 4. | 4c, 4e | — | +| 4g — Challenge fixtures and exhibit visuals | **Implemented; automated checks pass.** The fourteen PRD 130 challenge fixtures, proven to need no subject-specific renderer code, and the first visuals for Exhibits A-D. | 4d, 4e, 4f | — | | 4h — Early combined GC6 benchmark | Freeze the benchmark ID, version, seed, and counts; run the 30-second warm-up and 120-second measurement window; record frame intervals, runtime counters, and memory samples; derive the workload ceilings that fix later optimization strategy. | 4g | **User-observed.** Requires a real reference computer and browser at 1920 x 1080; cannot be closed by automated tests. | Slices 4a through 4g are implementable and verifiable without a display measurement. Slice 4h and the PRD 130 visual acceptance challenge are not: the challenge's judgement is visual, and the benchmark is a hardware measurement, which the recorded test boundary assigns to user-performed runs. Phase 4 is not accepted until 4h and the visual challenge close, regardless of the state of the first seven slices. -The three contract slices are complete. They landed before any implementation slice because the section 17-19 contract fixes decisions that 4d through 4f would otherwise have to relitigate: the resolution boundary for visual ValueSpec fields, the transform composition order, depth semantics, and which visual properties are externally addressable at all — which section 19.1 settles at exactly four system-level target families and nothing per-object. Whether the finished contract also passes a multi-model review before 4d begins — the pass that found thirteen defects in sections 14-16, nine of which any single reviewer would have missed — is the one open decision, and it is now due rather than pending, because 4d is the next slice. +The contract and implementation slices through 4g are complete. The renderer baseline and procedural work were extended with executable automation, lifecycle, effects, and eighteen authored compositions. Evidence records under `docs/evidence/phase4` distinguish automated execution from pending display judgment and 4h hardware measurement. Phase 3 remains unaccepted while its user-observed gates (traces 15-17 of section 16.11 and the Phase 1 direct-file restart observation) are open. Those gates are pending measurement, not failed, and no Phase 4 slice depends on them; Phase 4 nonetheless carries that debt forward and cannot close it. diff --git a/package.json b/package.json index 624282e..3c9fad3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "test:phase3": "node --test test/phase3-*.test.mjs", "test:phase3c3": "node test/phase3-automation.test.mjs", "test:phase3c4": "node test/phase3-protection.test.mjs", - "test": "node --test test/*.test.mjs" + "test": "node --test test/*.test.mjs", + "build:visual-acceptance": "node tools/build-visual-acceptance.mjs", + "test:phase4": "node --test test/phase4-*.test.mjs", + "test:phase5": "node test/phase5-cadence.test.mjs" } } diff --git a/reviews/.completed-artifacts/00-triage.md b/reviews/.completed-artifacts/00-triage.md new file mode 100644 index 0000000..83178ec --- /dev/null +++ b/reviews/.completed-artifacts/00-triage.md @@ -0,0 +1,408 @@ +# XZBT 0.1 Format Specification §§14–16 — review triage + +Inputs: `01-grok-4.6.md`, `02-kimi-k3.md` (two outputs, same seven findings), `03-deepseek-v4-flash.md`. +GLM review not received. + +Every claim below was checked against `docs/XZBT_0-1_Format_Specification.md` as it stands on disk +(1499 lines). Line numbers are that file's. Nothing in the spec was edited. + +**Result: 13 distinct defects. All 21 reported findings are real** — none is a false positive — +but only **4 of 13 were found by all three reviewers**, and **5 of 13 were found by exactly one**. +Four proposed fixes are wrong or incomplete and are corrected below. Two defects that no reviewer +found are added. + +--- + +## Scorecard + +| | Grok 4.6 | Kimi K3 | DeepSeek v4 Flash | +| --- | :---: | :---: | :---: | +| Findings reported | 6 | 7 | 8 | +| Real | 6 | 7 | 8 | +| Overstated | 0 | 0 | 1 (D8) | +| Unique catches | 0 | 1 | 2 | +| Of the 13 confirmed | 6 | 7 | 8 | +| Proposed fix wrong/incomplete | 2 | 1 | 2 | + +DeepSeek is the strongest pass: it is the only reviewer to unify the bus-gain defect (T1) instead of +splitting it, and the only one to catch the eviction defect (T3) and the modulated-delay bound (T8). +Kimi is the only one to catch the component-parameter defect (T2), which is the second-largest hole +in the section. Grok found nothing the other two missed, and split T1 into two half-findings whose +fixes contradict each other. + +The overlap pattern is the argument for having run multiple models: **9 of 13 defects would have been +missed by any single reviewer.** + +--- + +## Tier 1 — Unimplementable: a required acceptance trace cannot pass as written + +### T1. Bus gain claims automation and modulation with no authoring surface; no target in the document exposes both automation and override; trace 16.11.8 cannot be implemented + +*Reported by: DeepSeek F1 (complete). Grok #4 and #5 are the same defect split in two. Kimi missed it.* + +**Confirmed.** Three independent statements are orphaned: + +- 8.1 (line 314) gives `audio.buses..gain` Automation = Yes and Additive modulation = Yes. + 15.17 (line 1230) and 16.2 (line 1330) repeat both claims. +- Automation tracks live only in a graph object's `automation` array (14.3 line 606, 16.1 line 1265). + Graph objects exist at exactly three places (lines 610–612); a bus is not one of them, and 15.17's + bus table (line 1228) allows only `gain`, so an `automation` key on a bus is `ERR_UNKNOWN_FIELD` + under the strict unknown-field policy. +- 16.1 line 1286 restricts `target` to `.` "resolved in the same graph", and + line 1293 restricts it to the 15.13 registry. A bus is not a node and bus gain is not in that + registry (lines 1073–1085). **No legal `target` can name a bus.** +- Modulation has the same problem: the only modulation syntax is a 15.12 route whose `to` is + `.` in the same graph. **Nothing can modulate a bus gain either.** + +Consequences: + +1. 16.2's second consequence (line 1332, override masks automation, releases to the track's current + value) describes a situation no legal document can produce. Node properties take automation + (16.1) but are barred from override (14.4 line 631, `ERR_UNSUPPORTED_TARGET`); `parameters.` + and `state.` take override but have Automation = No (8.1 lines 312–313); bus gain is the only + row with both, and its automation is unauthorable. **No target exposes both stages.** +2. Required trace **16.11.8** (line 1485) is therefore unimplementable, and Phase 3c cannot be + accepted as written. + +**Fix — all three reviewers get this wrong.** + +- Grok #4 proposes keeping override-vs-automation "only for `audio.buses..gain`". That fails for + the same reason: bus gain has no automation surface. +- Grok #5 offers "add an `automation` array on the bus object, or strike automation from 16.2" and + says not to leave both standing — correct as far as it goes, but it treats only the automation half + and leaves the modulation half untouched. +- DeepSeek F1 correctly identifies both halves and both options, then says **"Option (a) is the + smaller change."** *That is wrong.* Deleting additive modulation from bus gain would orphan + **§8.5 required trace 6** (line 409, "A target has legal additive modulation → Modulation follows + the winning override; safety clamp runs last"), because bus gain is the **only** row in the entire + 8.1 table with Additive modulation = Yes. Option (a) does not shrink the problem; it moves it into + §8, a contract §11 line 513 already marks **Complete (Rev 0.3 / GC3)**. + §8.3 line 370 ("Underlying binding and **automation** stages continue to evaluate while masked") + is orphaned by the same gap. + +**Recommended fix:** option (b). Give `audio.buses..gain` a real authoring surface for automation +and modulation, and make 16.1's target rule name it. Concretely: allow an `automation` array on the +bus object with `target` fixed to `gain`, and add a bus-addressed modulation form. This keeps 8.1, +8.3, 8.5, 15.17, 16.2 and traces 16.11.8 and 8.5-6 all consistent, and it is the only option that +does not reopen a completed contract. + +**Out of scope but worth recording:** §8.5 trace 6 and §8.3's masking sentence are *already* +unimplementable today, independent of §§14–16. All three reviewers scoped to 14–16 and so could not +see this. It should be logged against §8. + +--- + +### T2. Component exposed parameters are legal modulation and automation targets with no defined effect; trace 15.19.4 cannot be implemented + +*Reported by: Kimi #1 only. Grok and DeepSeek both missed it.* + +**Confirmed.** 15.13 line 1085 lists `component` / `` in the modulatable +registry; 15.11 line 1045 repeats it; 16.1 line 1293 inherits it for automation. But the only path by +which a component's internals read a parameter is `{ "ref": "inputs." }` in a node field +(15.15 line 1166), and 14.4 line 629 resolves every node field **once** at instantiation, "constant +for that node instance's lifetime". 15.11 line 1039 resolves the supplied `values` once as well. + +So a modulation sum or automation curve applied to an exposed parameter changes a value that nothing +ever reads again. Required trace **15.19.4** (line 1250, "an exposed parameter is a legal modulation +target") cannot be satisfied without inventing behavior. + +**Fix — Kimi offers two options and correctly says the first is smaller. It is also the only correct +one, for a reason Kimi does not give.** Option two (make `{ "ref": "inputs." }` fields track the +parameter live, as an exception to 14.4) would let a modulation route reach *any* internal node field +through a parameter — including `reverb.decay`, `compressor.threshold`, `waveshaper.amount` and every +other property 15.13 line 1087 deliberately excludes ("Merely being numeric does not grant modulation +support"). That silently defeats the registry's whole purpose and breaks 15.11's encapsulation claim. + +**Recommended fix:** delete the `component` row from the 15.13 registry (line 1085), delete the +modulation-target sentence in 15.11 (line 1045), and drop the exposed-parameter clause from trace +15.19.4. + +--- + +### T3. 16.6 eviction step 3 frees no budget slot; the outcome is undefined and trace 16.11.10 is ambiguous + +*Reported by: DeepSeek F2 only.* + +**Confirmed.** Line 1406: "A voice counts against its ceiling from `CREATED` until `DISPOSED`." +The eviction policy (lines 1408–1413) "stops at the first candidate": + +1. Dispose the oldest `FINISHED` instance — frees a slot. +2. Evict the oldest `RELEASING` instance by advancing its ramp to completion **and disposing it** — + frees a slot. +3. For a one-shot request only: evict the oldest `ACTIVE` one-shot **by starting its release** — + moves it `ACTIVE` → `RELEASING`, which still counts (line 1406, reinforced by line 1372 and trace + 16.11.11). **Frees nothing.** +4. Otherwise refuse. + +With the ceiling full of `ACTIVE` one-shots, the policy stops at step 3 having freed no slot. +Admitting the new instance puts the count at 65; refusing it contradicts having stopped before step 4. +Neither is defined. Step 2's explicit "and disposing it" makes step 3's omission conspicuous rather +than incidental. + +A weak defense exists — line 1399 calls the ceilings "approximate" — but line 1406's counting rule and +trace 16.11.11 both insist on exact accounting, so this does not resolve it. + +**Fix — DeepSeek offers two options without ranking them; the first is correct.** Making step 3 +behave like step 2 (complete the release immediately and dispose) contradicts line 1415, "Eviction +always releases; it never hard-stops an active voice" — an instant ramp on an audible voice *is* the +click that 16.4's single-code-path design exists to prevent. Step 2 does it only to voices already +decaying. + +**Recommended fix:** state that an `ACTIVE` one-shot evicted under step 3 stops counting against the +ceiling at the moment of eviction, its release running outside the budget, so the new instance is +admitted and the ceiling is never exceeded. Add the matching exception to line 1406. + +--- + +## Tier 2 — Real defects with clear fixes + +### T4. `ERR_INVALID_RANGE_ORDER` is filed Semantic-only but the sample-hold rule needs instantiation-time values + +*Reported by: all three (Grok #1, Kimi #2, DeepSeek F4). Unanimous, and unanimously right.* + +**Confirmed.** 14.12 lines 815–816 make `min`/`max` full ValueSpecs in 14.4 scope — resolved once at +instantiation. Line 819 makes inverted resolution `ERR_INVALID_RANGE_ORDER`. §7 line 286 files that +code **Semantic** only, with a cause that says "after resolution" — the table concedes the problem in +its own text. 14.5 line 645 states semantic validation checks only values that resolve to a literal +and never opens an `AudioContext`; 9.3 line 445 puts the root seed at performance start, not import. + +`{ "min": { "random": {...} }, "max": { "random": {...} } }` is import-legal and its order is +undecidable at the only stage the code is filed under. + +**Fix:** all three converge and all three are right. Refile as `Semantic / Runtime` in §7 (mirroring +`ERR_OUT_OF_BOUNDS`, line 272, which is already dual-filed), and state in 14.12 that literal bounds +are rejected at import and resolved bounds at instantiation. + +**Note:** §7's cause text mentions only `sample-hold` `min`/`max`. 16.1 line 1299 uses the same code +for automation `at` ordering, which *is* purely semantic because `at` is a literal by construction +(line 1297). The revised cause text must cover both without implying `at` is a runtime check. + +--- + +### T5. Automation point `value` draws from a seeded stream with no documented sampling boundary or order + +*Reported by: all three (Grok #3, Kimi #4, DeepSeek F6).* + +**Confirmed.** 16.1 line 1289 makes each point `{ "at": , "value": ValueSpec }`, and +line 1297 says "A track's *values* remain full ValueSpecs and may be random." + +9.3 line 457 requires sampling at "the containing object's **documented** instantiation or invocation +boundary". No such documentation exists for `automation`: + +- 14.4 line 629 documents the boundary for fields on **node objects**. A track is on the graph object. +- 15.12 line 1067 documents the order for route `depth` only. +- 14.12 line 825 is the only documented child key, and it is `sample-hold` only. + +Two conforming implementations can consume the sound instance's stream in different orders and +diverge for the same seed, breaking 9.3's reproducibility promise and 14.6's restatement of it. +16.8 line 1445's guarantee that a partially elapsed continuous sound has "a procedural sequence +identical to an unbroken run" has no defined meaning for automated tracks. + +**Fix:** all three propose the same one-sentence addition to 16.1 and all are adequate. Kimi's and +DeepSeek's are the more precise, because they fix the position of `automation` in the depth-first +order relative to `nodes` and `routes`, which Grok's leaves open: + +> A track's point values are resolved once at the owning sound instance's instantiation boundary, +> sampled from that instance's stream in depth-first, property-document order, with `automation` +> taken after `nodes` and `routes`. + +--- + +### T6. 15.14 calls the automation track/point limits runtime ceilings; 16.1 enforces them with a Semantic code + +*Reported by: all three (Grok #6, Kimi #3, DeepSeek F5).* + +**Confirmed.** Line 1132 groups automation tracks (`64`) and points (`256`) with the voice ceilings as +"runtime ceilings rather than document properties". Line 1318 makes exceeding either +`ERR_NODE_LIMIT_EXCEEDED`, filed **Semantic** at §7 line 285, and trace 16.11.6 (line 1483) sits in +the "Automated, and executable without an audio device" group. Track and point counts are decidable +after component expansion, which trace 15.19.7 confirms runs with no `AudioContext`. The voice +ceilings in the same sentence *are* correctly runtime. + +**Fix — Grok's first option is wrong.** Grok #6 offers "move tracks/points into the authoring-limit +table with expanded nodes/routes". That table is headed "The authoring-time limits enforced in +**Phase 3b**" (line 1122), and automation is Phase 3c — 16.1 line 1261 states that the current +validator rejects any document declaring `automation` with `ERR_UNKNOWN_FIELD`. Putting them in the +Phase 3b table asserts enforcement that cannot exist. DeepSeek F5 offers the same wrong option +alongside the right one. + +**Recommended fix (Kimi's):** delete "automation tracks (`64`), and automation points (`256`)" from +line 1132, leaving the voice ceilings. Optionally add a pointer that 16.1 enforces them semantically. +Separately, extend §7 line 285's cause parenthetical — it currently reads "(oscillator partials, +resonator modes, expanded nodes or routes per sound)" and omits the automation limits entirely. + +--- + +### T7. 16.5's resonator contribution is undefined when every mode is omitted at instantiation + +*Reported by: all three (Grok #2, Kimi #5, DeepSeek F7).* + +**Confirmed.** 16.5 line 1387 gives a resonator "the longest `decay` among its **retained** modes". +15.10 line 1024 omits, at instantiation and with no diagnostic, any mode whose resolved frequency +exceeds `audioMaxFrequency`. Mode entries are omitted rather than clamped — 14.7 line 690 is explicit +that omission "is not the `WARN_AUDIO_RATE_CLAMP` case of 14.5, which applies to the node's own +`frequency`". + +A single-mode resonator can therefore retain nothing, and "longest decay" becomes a maximum over an +empty set — feeding the one-shot ending bound, which 16.5 line 1376 requires to be computable "from +the resolved graph alone". + +All three examples check out arithmetically. The cleanest is Grok's: a mode with absolute +`frequency: 20000` is import-legal against the device-independent ceiling `24000` (14.5 line 645) and +omitted on a 44.1 kHz device, where `audioMaxFrequency = min(24000, 19845) = 19845`. + +**Fix:** DeepSeek's wording is the tightest — extend the row to "the longest `decay` among its +retained modes, **or zero when no mode is retained**". + +**Downgrade:** Kimi #5 and DeepSeek F7 also raise the all-partials-omitted custom oscillator. That one +is materially weaker: an empty sum of partials is silence by construction, and `oscillator` is +**unbounded** in the 16.5 table regardless, so no bound is left undefined. Worth one clarifying +sentence in 14.7, not a Tier 2 defect. + +--- + +### T8. The 16.5 ending bound is not an upper bound when `delay.time` is modulated + +*Reported by: DeepSeek F3 only.* + +**Confirmed.** 16.5 line 1376 defines "determinable" as a finite upper bound computed at instantiation +"from the resolved graph alone"; line 1385 computes the delay contribution from the **resolved** +`time`. But `delay.time` is modulatable (15.6 line 943; 15.13 line 1082) and 15.6 line 949 clamps a +modulated delay only to `[0ms, 10s]`. A legal `oneshot` may route an `lfo` or `sample-hold` at +`delay.time`; the actual feedback tail can then run far past the computed bound, and the runtime tears +the instance down while it is still ringing — contradicting 16.3 line 1360, which asserts that at a +determinable ending "the envelope has already returned to zero". Trace 16.11.9 passes only for graphs +whose bound-contributing properties are unmodulated. + +`delay.feedback` is not modulatable (line 944), so `time` is the only exposure. + +**Fix — DeepSeek's is incomplete, and T9 is why.** It proposes bounding the contribution by the +largest reachable `time`, "source outputs are bounded — `lfo` by its resolved amplitude, `sample-hold` +by its resolved `min`/`max`". That list omits `oscillator`, which 15.13 line 1089 also permits as a +modulation source and whose output magnitude the spec never states (see T9). Until T9 is fixed, the +proposed bound cannot be computed for an oscillator-modulated delay. + +**Recommended fix:** fix T9 first, then bound the delay contribution over the property's reachable +range given its modulation routes. Alternatively — and this is the genuinely smaller change — exclude +a `oneshot` whose bound-contributing property is modulated from determinable-ending status, i.e. +`ERR_INDETERMINATE_ONESHOT`. Note that an *automated* `delay.time` is exactly boundable once T5 lands, +since `at` values are literals and point values resolve at instantiation; only modulation is open. + +--- + +### T9. An `oscillator` used as a modulation source has no defined output magnitude + +*Not reported by any reviewer.* + +**Confirmed.** 15.13 line 1089 permits a modulation route's `from` to be "a control source +(`constant`, `lfo`, `sample-hold`) **or an `oscillator` used at audio rate** (PRD 41)". The summation +rule at line 1094 is `value = clamp(b + sum(s_i x d_i), ...)`, and line 1097 defines `s_i` only for +control sources: "**A control source's** output is its own value in its own units before scaling: an +`lfo` with `amplitude: 1` and `polarity: "bipolar"` contributes `[-d, +d]`." + +An `oscillator` is not a control source (14.7 line 678; 15.14 rule 5 lists only `constant`, `lfo`, +`sample-hold`), and 14.7 gives it no amplitude field and no stated output range. For a `custom` +waveform with up to 64 partials at `gain: 1` each (line 685), the peak is not even conventionally +`[-1, 1]`. So `s_i` is undefined for the one source type the registry explicitly admits alongside the +control sources. + +**Recommended fix:** state the oscillator's modulation output range in 15.13 — e.g. that a non-`custom` +oscillator contributes `[-d, +d]`, and a `custom` oscillator's output is normalized to peak `1` before +scaling by `depth`. Normalization is the safer choice; without it, `depth` means something different +for every harmonic table. + +--- + +## Tier 3 — Editorial and low-severity + +### T10. 16.2's "For a node property" pipeline includes an override stage that is always absent +*Grok #4's residue, after T1 absorbs the rest.* Line 1322 introduces the pipeline diagram at line 1325 +with "For a node property:", and the diagram includes `winning override`. 14.4 line 631 makes an +override on a node field `ERR_UNSUPPORTED_TARGET`, and 16.2 line 1330 reaffirms it for binding but is +silent on override. Per 8.1 line 308 the stage is "absent, not an identity hook", so the diagram +should say so. **Fix:** annotate the diagram or add one clause to line 1330 noting that the override +stage is absent for node properties. + +### T11. 14.9's "Value at `p = 1`" column contradicts the exponential row's own annotation +*DeepSeek F8 — real, but overstated.* Line 741 defines the envelope over `0 <= p < 1`; line 749 then +says "The envelope is exactly `0` for `p >= 1`", which makes the column's three zeros defensible for +the piecewise function. The genuine inconsistency is narrower and internal to one row: the +`exponential` row's Envelope cell is annotated "(`-60` dB at `p = 1`)", i.e. `0.001a`, while its +"Value at `p = 1`" cell says `0`. The same table then supports line 749's "Because `flat` and +`exponential` do not reach zero on their own" — which is false of a column reporting `0` for both. +No implementation ambiguity survives, because the terminal fade is normative either way. +**Fix:** relabel the column "Value as `p → 1⁻`" with `a`, `0`, `a x 0.001`, or delete it. + +### T12. `sounds..name` and `tags` length caps name no diagnostic +*Kimi #6.* Lines 1197–1198 cap `name` at 128 characters and `tags` at 16 entries of 32 characters +with no code. Every other quantitative limit in §§14–16 names one, and no §7 entry covers string or +array lengths: `ERR_INVALID_ID` (line 268) is identifier-scoped, `ERR_OUT_OF_BOUNDS` (line 272) is +numeric min/max, and `ERR_SCHEMA_VALIDATION` (line 265) covers "malformed top-level shapes", which +`sounds.` is not. **Fix:** name the code — `ERR_SCHEMA_VALIDATION` is the closest fit. + +### T13. 16.2 line 1332 mis-cites §8.1 +*Kimi #7.* The cited "parameter-masking rule of section 8.1" is not in 8.1 — its only masked-override +text (line 318) is about the UI override indicator. The behavior lives in **8.3** (line 370, masked +stages continue) and **8.4** (line 392, `currentLowerValue` recomputed every tick), with the trace at +8.5 line 406. **Fix:** cite 8.3/8.4. + +### T14. An exposed component parameter with no `min`/`max` has no clamp range +*Not reported by any reviewer.* 15.15 line 1163 makes `min` and `max` optional on an exposed +parameter, but 15.13 line 1094 clamps a modulated property to "the property's declared range from its +node contract". For `{ "type": "number", "default": 220 }` there is no such range. +**Moot if T2's recommended fix lands** (deleting the `component` row removes the only path here); if +option two is chosen instead, this must be fixed alongside it. + +--- + +## Findings checked and cleared + +- **Class 1 (prose vs JSON in the same section):** all three reviewers reported none, and that holds. + Every example and invalid case in 14.3–14.13, 15.2–15.17 and 16.1 matches its section's tables. + One editorial note nobody raised: 14.3's canonical graph example (lines 589–600) is + `oscillator → gain → output`, which is **not** a legal `oneshot` recipe — as a recipe it would be + `ERR_INDETERMINATE_ONESHOT` (16.5 line 1393), and `mode` defaults to `oneshot` (line 1208). It is a + bare graph-object example, so this is not a defect, but it is an unfortunate thing to copy. +- **Class 2 (field missing from its container's allowed-field table):** none, confirmed. The 15.13 + registry matches the "Modulatable" columns of 15.2–15.10 exactly; T1's bus-gain case is a missing + *surface*, not a missing table row. +- **Diagnostic codes used but absent from §7:** none. All codes in §§14–16 appear in the §7 table, and + the 15.18/16.10 tables match it. Mis-staging is T4 and T6; an incomplete cause list is folded into T6. +- **Cross-references:** all internal references resolve. The only defect is a *mis*-reference, T13. +- **Non-termination:** 16.5's longest-path terminates on the acyclic expanded graph (15.14 rules 7 and + 11). The delay formula `ceil(log(1/1000)/log(feedback))` is well-defined on the legal `(0, 0.95]` + and correctly branches at `feedback > 0`. +- **Modulation-only `oscillator` and the one-shot bound:** an oscillator used purely as a modulation + source has no audio-route path to `output`, and 16.5 line 1393 keys `ERR_INDETERMINATE_ONESHOT` to + the *audible* path, so it does not make a `oneshot` indeterminate. Correct as written. +- **Deliberate-design exclusions** (node `id`, the 14.4 bindable surface, noise/impulse + non-reproducibility, provisional master-protection values, absent `audio.master`, literal automation + `at`, the GC4 stall bound, bus processing beyond gain) were respected by all three reviewers and are + not defects. + +--- + +## Recommended edit list + +Ordered by severity. Items marked **(design)** need a decision before an edit can be written. + +| # | Location | Change | Kind | +| --- | --- | --- | --- | +| T1 | 8.1, 15.17, 16.1, 16.2, 16.11.8 | Give bus gain an automation/modulation authoring surface and name it in 16.1's target rule | **(design)** | +| T2 | 15.13 line 1085, 15.11 line 1045, 15.19.4 | Delete the `component` modulation-target row and its two restatements | Delete | +| T3 | 16.6 lines 1406, 1412 | Evicted `ACTIVE` one-shot leaves the ceiling count immediately | One sentence | +| T4 | §7 line 286, 14.12 | Refile `ERR_INVALID_RANGE_ORDER` as `Semantic / Runtime`; split literal vs resolved | Restage | +| T5 | 16.1 | Document the automation point `value` sampling boundary and order | One sentence | +| T6 | 15.14 line 1132, §7 line 285 | Remove the two automation limits from the runtime-ceiling sentence; extend the cause list | Delete + edit | +| T7 | 16.5 line 1387 | "…or zero when no mode is retained" | Clause | +| T8 | 16.5 line 1385 | Bound the delay contribution over the reachable `time`, or exclude modulated bound-contributors | **(design)**, blocked on T9 | +| T9 | 15.13 line 1097 | State an `oscillator`'s modulation output magnitude and `custom` normalization | **(design)** | +| T10 | 16.2 lines 1325, 1330 | Note that the override stage is absent for node properties | Clause | +| T11 | 14.9 lines 743–747 | Relabel or delete the "Value at `p = 1`" column | Editorial | +| T12 | 15.16 lines 1197–1198 | Name the diagnostic for the `name`/`tags` caps | Editorial | +| T13 | 16.2 line 1332 | Cite 8.3/8.4 instead of 8.1 | Editorial | +| T14 | 15.15 line 1163 / 15.13 line 1094 | Only if T2 takes option two | Conditional | + +**Outside §§14–16, log separately:** §8.5 trace 6 and §8.3's masked-automation sentence are already +unimplementable for want of a target with those stages (see T1). diff --git a/reviews/.completed-artifacts/01-grok-4.6.md b/reviews/.completed-artifacts/01-grok-4.6.md new file mode 100644 index 0000000..bb5c2b9 --- /dev/null +++ b/reviews/.completed-artifacts/01-grok-4.6.md @@ -0,0 +1,109 @@ +# Spec Review — Grok 4.6 + +Source: Grok 4.6 +Scope: sections 14–16 only +Received: 2026-09-06 +Status: raw, untriaged — verbatim as delivered + +--- + +Review of sections 14–16 only. Deliberate omissions from your list are not reported. + +--- + +### 1. `ERR_INVALID_RANGE_ORDER` on `sample-hold` min/max — semantic stage cannot enforce it + +**Severity: high (unimplementable staging; same class as the prior two)** + +**§14.12** says `min` / `max` are ValueSpecs resolved once at instantiation (14.4), and "`min` resolving to a value greater than or equal to `max` is `ERR_INVALID_RANGE_ORDER`." + +**§7** files `ERR_INVALID_RANGE_ORDER` as **Semantic only**. Semantic validation runs at import with no instantiation and no resolved ValueSpecs. + +Literals such as the documented invalid case `{ "min": 1, "max": -1 }` are checkable at import. A legal `{ "min": { "random": { "min": -1, "max": 1 } }, "max": { "random": { "min": -1, "max": 1 } } }` is not. The stage that can see the resolved pair is instantiation, which is not Semantic. + +**Smallest fix:** In §7, stage `ERR_INVALID_RANGE_ORDER` as `Semantic / Runtime` (literals at import; resolved ValueSpecs at instantiation). Do not call the resolved-order failure a semantic-stage error. + +--- + +### 2. One-shot bound: empty retained resonator modes + +**Severity: high (undefined for a legal graph)** + +**§16.5** resonator contribution is "the longest `decay` among its **retained** modes." **§15.10** / **§14.7**: a mode whose resolved frequency exceeds `audioMaxFrequency` is **omitted at instantiation**. `audioMaxFrequency` needs the live sample rate (14.5). + +Legal input: one mode with `frequency: 20000` (semantic-legal vs 24000). On 44.1 kHz, `audioMaxFrequency = min(24000, 19845) = 19845`, the mode is omitted, retained set is empty, `longest decay` is undefined. Longest-path then has no defined resonator term. + +**Smallest fix:** Define empty retained modes as contribution `0` (signal dropped; duration not extended). + +--- + +### 3. Automation point `value` draws a seeded stream with no sampling rule / key + +**Severity: high (reproducibility unimplementable)** + +**§16.1:** each point is `{ "at": , "value": ValueSpec }`, and "values remain full ValueSpecs and may be random." + +**§14.4** samples nested ValueSpecs from the sound instance stream only for **node-object** fields. **§15.12** explicitly folds route `depth` into that rule. Track `value` is neither a node field nor given that fold. **§14.12** is the only extra child-key document, and it is `sample-hold` only. + +An implementor cannot know when `value` is drawn, in what order relative to node fields / `depth`, or from which stream. + +**Smallest fix:** One sentence in 16.1: point `value` ValueSpecs are resolved once at instantiation with the other graph ValueSpecs, depth-first in document order on the sound instance stream (14.4 / 9.3). No new child key. + +--- + +### 4. Override-vs-automation for node properties vs 14.4 + +**Severity: high (internally unimplementable)** + +**§14.4:** a `set` / `override` addressing a node field is `ERR_UNSUPPORTED_TARGET`. Node fields are not in the 8.1 table. + +**§16.2** still puts `winning override` on the **node property** pipeline and requires: override masks automation; on release the property follows the track's current value. + +**§16.11.8** requires a trace of that behavior. + +Those traces cannot pass if 14.4 rejects the override. + +**Smallest fix:** In 16.2, state that the override stage is absent for node properties (14.4). Keep override-vs-automation only for `audio.buses..gain`. Drop or retarget 16.11.8 to bus gain. + +--- + +### 5. Bus gain "takes automation" with no place to declare a track + +**Severity: medium–high** + +**§16.2** (and 8.1): `audio.buses..gain` takes automation. + +**§16.1 / §14.3:** tracks exist only on **graph** `automation` arrays (`.`). + +**§15.17** bus object allowed fields: `gain` only. + +No legal `target` can name a bus, and a bus cannot hold tracks. Bus automation is required and impossible to author. + +**Smallest fix:** Either add an `automation` array on the bus object (targets `gain` only) or strike automation from the bus capability in 16.2 and leave 8.1 to a later erratum. Do not leave both claims standing. + +--- + +### 6. Automation track/point limits: runtime in 15.14, semantic in 16.1 + +**Severity: medium (wrong stage in one of the two)** + +**§15.14:** automation tracks (`64`) and points (`256`) "are **runtime ceilings** rather than document properties." + +**§16.1 / §16.11.6:** exceeding them is `ERR_NODE_LIMIT_EXCEEDED` (Semantic in §7), and the trace is import-time. + +Counts after expansion are document properties; Semantic can enforce them. 15.14's "runtime ceiling" classification is then false (and would be unenforceable as a runtime-only rule without a second code). + +**Smallest fix:** In 15.14, move tracks/points into the authoring-limit table with expanded nodes/routes, or say Phase 3c enforces them as semantic `ERR_NODE_LIMIT_EXCEEDED`. Do not call them runtime ceilings. + +--- + +### Classes with no finding + +- **Prose vs JSON in the same section:** none. +- **Field on a container missing from that container's allowed-field table:** none beyond (5), where the field never appears on the bus table at all. +- **Diagnostic code used in 14–16 but absent from §7:** none. Staging errors are (1) and (6). +- **Cross-references to sections that do not exist:** none. +- **Non-termination:** 16.5 longest-path is stated to terminate on the acyclic expanded graph; no extra finding. Delay `log(feedback)` is defined on the legal range `(0, 0.95]`. +- **Breach with no behavior:** component `release` is excluded by 14.3/15.15 unknown-field policy; not reported. + +Deliberate items (node `id`, bindable surface, noise/impulse vs sample-hold reproducibility, one track vs summing modulation, provisional master protection, absent `audio.master`, literal automation `at`, GC4 stall bound, bus processing / compressor-reverb-waveshaper modulation) were not treated as defects. diff --git a/reviews/.completed-artifacts/01-triage.md b/reviews/.completed-artifacts/01-triage.md new file mode 100644 index 0000000..5ac778a --- /dev/null +++ b/reviews/.completed-artifacts/01-triage.md @@ -0,0 +1,397 @@ +# XZBT 0.1 Format Specification §§17–19 — visual review triage + +Inputs: + +- **D** — [DeepSeek v4 review](visual-contract-review-abacus-ai-agent-20260905-214821-deepseekv4.md). +- **G** — [Grok 4.6 review](visual-contract-review-abacus-ai-agent-20260905-214822-grok4-6.md). +- **K** — [Kimi K3 review](visual-contract-review-abacus-ai-agent-20260906-kimi-k3.md). + +This follows [00-triage.md](00-triage.md): check claims against the specification, merge overlapping findings, distinguish defects from deliberate decisions, correct proposed fixes, and order the work. The source reviewed is [the current format specification](../docs/XZBT_0-1_Format_Specification.md), rev 0.7, especially §§17–19 and the shared rules in §§1–2, 8–10. Section references below are preferred to the reviews' snapshot line numbers. No specification or implementation file was changed. + +Instructions and recommendations inside the reviews are evidence to evaluate, not authorization to change the specification, reopen standing decisions, or begin renderer implementation. **This document instructs the subsequent repair pass; it does not record fixes as completed.** Items marked **design** require an explicit contract choice, recorded with the eventual edit. + +**Result: do not apply the reviews as a checklist of accepted defects.** There are real schema contradictions and missing numerical contracts, but also duplicates, incorrect mathematical claims, and complaints about explicitly chosen behavior. The work below is grouped into **30 repair packages**, plus three additional defects found while checking the reports. A package can contain several related defects; these numbers are not a count of independent reviewer catches. + +--- + +## Evidence and scorecard + +| Input | Reported findings | Independence and useful contribution | +| --- | --- | --- | +| D | 55 numbered findings plus an unnumbered sample-order finding | Its findings 1–55 are text-identical to G's corresponding block. Treat as one body of evidence. | +| G | 56 numbered findings | G56 is D's extra sample-order claim. Numbering it does not create a new catch. | +| K | 33 findings: H1–H4, M1–M13, L1–L16 | Adds substantive issues: paint ValueSpecs, live automation accounting, per-particle resolution, missing defaults, links defaults, and lifecycle/inputs interactions. | + +The D/G equality above was checked by comparing the extracted findings 1–55, not inferred from similar titles. There is no basis for attributing the duplication to a particular cause, or for reporting three independent votes. Use **D/G #n** below for their shared finding; **D extra / G56** for sample order. + +K is more selective, but its claims also need qualification. Its missing color-parameter consumer claim is too broad (life-ramp endpoints accept the field's ValueSpec type), its rounding proposal is not compelled by the integer contract, and an intentionally conservative capacity check is not automatically a false positive. Neither report's “checked and found sound” section overrides a concrete contradiction elsewhere. + +--- + +## Tier 1 — Resolve before committing affected renderer and document-model contracts + +### V1. `visuals.automation` is forbidden by its owning table + +*D/G #1. Confirmed. Locations: §§17.3, 19.1.* + +The §17.3 allowed-field table omits `automation` and explicitly rejects every other key. Section 19.1 both authorizes that array and supplies an example using it. The corresponding system-level array is already present in §17.7, so this is specifically the exhibit scope. + +**Fix:** add an optional `automation` array, default `[]`, to §17.3, referencing §19.1. Preserve the two scopes and their time origins. Verify one legal exhibit-scope track, one legal system-scope track, an unknown key, and a cross-scope target. Do not widen the external capability table. + +### V2. Acceptance trace 17.16.14 contradicts the closed visual capability table + +*D/G #2; K M10. Confirmed. Locations: §§8.1, 17.14, 17.16.14, 19.1, 19.7.7.* + +“Any visual property” cannot be rejected now that the four visual target families are present in §8.1. The earlier slice wording is stale; §17.14 already preserves the narrower prohibition on per-object external control. + +**Fix:** rewrite the trace around legal and illegal capabilities of each target family. Keep the per-object rejection case and test valid bindings/overrides where allowed. Do not say every operation on every member of the four families is legal: capability and field type still govern. Delete the stale “table is unchanged” assertion. Keep later runtime execution assigned to its implementation slice; accepting a schema is not evidence that a binding runs. + +### V3. Fit, camera, and perspective need one composition in named spaces + +*D/G #6, #11, #42, #52; K H1. Confirmed, merged. Locations: §§17.4, 17.6, 17.11, 19.3.* + +Three problems interact: `contain`/`cover` never fix placement offsets; the camera matrix subtracts scene-unit `x,y` from display-center coordinates; and §17.6's “post-multiplied” description does not match §19.3's factor applied after `V`. Checking the algebra only at fit scale 1 misses the unit mismatch. + +**Recommended fix (design):** retain the locked local transform, translation-only parallax, and perspective factor. Define a scene-to-CSS matrix `F` with centered contain/cover offsets; define `c` in CSS coordinates and `q = F(cameraCenter)`. Express the camera translation using `q - c`, apply the camera matrix to fitted points, then apply `T(c) × S(focalLength/(focalLength+zEffective)) × T(-c)`, and finally backing-store scaling. This is a proposed space convention, not text already in the spec. State how nonuniform `stretch` affects rotation and appearance dimensions. + +Use the accumulated depth including `translate.z` and ancestor depth. State whether perspective scales stroke, blur, glow, and shadow along with geometry. Replace §17.6's ambiguous multiplication prose with a reference to the full equation. + +**Verification:** identity camera in all three spaces; unequal scene/display sizes; contain and cover offsets; nonuniform stretch; rotation plus translation; parallax 0, 0.2, and 1; nonzero object/ancestor depth; culling at the eye; DPR applied once. Include numerical point oracles, not only screenshots. + +**Correct the reviews:** with column vectors, `M × S` does not generally scale `M`'s existing translation; K H1's parenthetical says otherwise. The composition ambiguity remains real. D/G #12's viewport-default objection is not a separate defect: a viewport-sized scene center can produce an identity camera at every window size. + +### V4. Primitive geometry lacks enough definitions to produce unique paths + +*D/G #5, #16, #18; geometry residue of #15 and #19. Confirmed. Locations: §§17.9, 17.13.* + +Rectangle anchoring, circular-primitive centers, directed arc sweeps and full-ring defaults, and Catmull–Rom tension/parameterization are not fixed. These affect the first primitive and transform fixtures. Open spline endpoint duplication does not specify the interpolation polynomial. A closed Bezier spline can legally close with a line; it is not inherently impossible, but the closing rule needs to be explicit. + +**Recommended fix (design):** state local geometry extents and anchors for every primitive; specify sweep calculation, zero sweep, full turn, wrap, and rejection before/after normalization; give the Catmull–Rom equation and closed-index rule; state Bezier closure. Complete requiredness and defaults for geometry fields rather than deriving them from examples. Define open-path fill closure and point stroke behavior, plus `maxWidth` condensation when the measured text exceeds the limit. + +Verify minimal instances, a rotated rectangle with a known corner, both arc directions across zero, 0/360/>360 sweeps, and open/closed splines at tension endpoints. Generic-font metrics need no new pixel-reproducibility exemption: §9.3 already excludes identical pixels. + +### V5. Paint ValueSpecs contradict the component example + +*K M2. Confirmed, narrower than reported. Locations: §§17.12, 17.14, 18.1.* + +`fill`/`stroke` accept a color, paint object, or null; the component example supplies `{ "ref": "inputs.tint" }`. Section 17.14 only licenses ValueSpecs where the field tables say so. The example cannot pass that table as written. + +**Fix:** explicitly permit `ValueSpec` alongside paint objects and null for fill/stroke. Decide and state which other color leaves accept ValueSpecs, including stops, glow, shadow, and effect colors; do not accidentally permit a ValueSpec that returns an entire arbitrary paint object. Preserve once-at-instantiation sampling and existing scopes. Verify the panel example, a wrong-type input, and a legal color ref with unchanged stream position across frames. + +### V6. System controls and item initialization have conflicting resolution boundaries + +*K H4, timing part of L5; related D/G #36. Confirmed. Locations: §§18.2, 18.4, 18.5, 19.1.* + +“Every ValueSpec above” resolving per particle includes `count` and `rate`, which must be known to create the particles. Emitter `rate` and burst times have an explicit exception, but burst counts do not. Automation of system `position`, `acceleration`, and `drag` has no stated effect on existing versus future items. + +**Fix (design):** publish a field-by-field ownership table: system-instantiation values, per-burst values, item-creation values, and live system channels. Resolve creation counts before allocating those items. Specify which live channels affect existing items and which affect only new births, and how a track combines with a sampled base. Include rate automation that preserves the accumulator and a burst at the same tick as continuous emission. State the order of their creations and PRNG draws. + +Do not resolve this by making every visual ValueSpec live or by removing documented per-item randomness. In §17.14 distinguish the fixed sampled base from the effective value subsequently produced by automation/behaviors. + +### V7. Live automation totals cannot be import-only authoring bounds + +*K M1; D/G #47 is only a weaker table-placement complaint. Confirmed. Locations: §§19.1, 19.2, 19.5, 19.7.6.* + +The 128-track/2048-point total includes every live spawned instance, while §19.5 says it is checked at import and is not a shed. Legal templates can exceed it only after repeated spawns. There is no complete admission or error policy. + +**Recommended fix (design):** separate authored-record validation from live instance accounting. Retain import rejection for oversized definitions and define atomic spawn refusal if adding that instance's records would exceed the live budget. Specify the diagnostic, whether this is a scenario failure, accounting through release/disposal/failure, and whether a refused spawn consumes its ordinal. If using `WARN_VISUAL_CEILING` and non-failure refusal like §19.2, amend §§19.1/19.7.6 accordingly; that is a behavior decision, not an editorial correction. Never admit the system with some tracks silently removed. + +Verify template versus live counts, exact boundaries, repeat spawns, refusal without partial allocation, and reclamation. + +### V8. Coherent noise is a recipe, not yet a reproducible algorithm + +*D/G #32; K H3. Confirmed. Locations: §§18.6–18.7, 18.10.15–16.* + +Missing definitions include ordered gradients and their normalization, lattice hashing, gradient selection, shuffle bounds/draw count, octave normalization, scalar-to-vector conversion, derivative computation, and tolerance/sample oracles. Behaviors refer to “value-noise” while the field defines gradient noise, without a complete behavior sampling-coordinate contract. + +**Fix (design):** add normative pseudocode and fixed seed/sample vectors. Choose the exact shuffle while respecting or explicitly amending the one-sample-per-entry statement and §9.3's integer sampling policy. Specify behavior offsets, coordinate inputs, and vector construction. For a 2D curl field, an explicitly chosen perpendicular gradient of a scalar potential is a possible design; do not leave “curl of the noise potential” to the implementer. Publish derivative and divergence tolerances and test them at non-lattice as well as lattice positions. + +**Correct D/G:** `curl(N,N,N)` is not generally zero; its components are differences of partial derivatives. The defect is the missing vector construction, not that claimed identity. A modulo-12 mapping is biased but could be a fully deterministic specified choice; bias alone does not prove nonconformance. + +### V9. Sorting units are missing for emitters, repeaters, and mixed-depth geometry + +*D/G #8; K M6, L6. Confirmed in part. Locations: §§17.6, 17.9, 18.2, 18.4–18.5, 18.8.* + +Particles explicitly form one sorting unit with the lowest live particle depth. This special case is not itself a contradiction. Emitters and repeaters lack the equivalent rule, while repeater links refer to a system depth defined only for particles. Point-level z offsets also have no reduction to the one depth used to sort/fog a whole object. + +**Fix (design):** define the sortable unit for each system and for groups, including cross-system ties and empty systems. A consistent option is atomic procedural-system units, with a documented representative depth and internal ordinal ordering; record the choice rather than assuming particles' rule applies everywhere. Separately choose representative depth versus per-point projection for varying-z geometry and explain its interaction with the one-object fog rule. Test interleaved depths, equal-depth ties, empty pools, links, and a spline with differing point z. + +### V10. Offscreen allocation and compositing are not fully specified + +*D/G #22, #23, #45, #46, #53; K M5, release residue L12. Confirmed in part. Locations: §§17.5, 17.12, 19.2, 19.5.* + +Layers consume the same 16-buffer budget, but shedding only describes objects. There is no layer/object allocation order or rate for approximation warnings. Section 17.5 applies opacity once to a composited layer, while §17.12 says object opacity multiplies layer opacity; clarify that this is not a second per-object application. The combined order of inherited opacity, mask alpha, effects, blend, and release also needs a single compositing description. + +**Fix (design):** specify the compositing stages and buffer accounting, including reuse and layer buffers; determine degradation for every allocation kind and stable tie-breaks. State explicitly that buffer shedding is a diagnosed exception to §17.1's ordinary appearance guarantees. Preserve the specified warning code unless the diagnostic registry and traces are changed together; reuse of a warning code is not itself a defect. + +Use a separate instance release factor initialized to 1, multiplied into the existing composite, so release never resets authored opacity. Test two overlapping children under layer opacity 0.5, nested clips/masks, a partially transparent release, and more than 16 mixed layer/object allocations. Existing text already makes clips local and masks use rendered alpha; do not call those individual rules absent. + +--- + +## Tier 2 — Real defects to close in the relevant implementation slice + +### V11. Repeater automation names nonexistent `step` + +*D/G #3; K M3. Confirmed. §§18.5, 19.1.* + +**Recommended fix:** delete the `step` registry clause. Do not invent a new repeater layout language to save stale prose. If repeater-wide automation is required by a product requirement, define its actual fields, resolution, and copy effects as a separate design change. Test that `step` remains unknown and unsupported targets fail with the correct distinction between missing references and unsupported properties. + +### V12. Infinite loop legality contradicts itself + +*D/G #38; K H2. Confirmed. §19.1.* + +**Fix (design):** replace “only on a persistent scope” with one rule covering exhibit scope, persistent systems, finite-lifetime spawned systems, and indefinite spawned systems. The smallest compatible clarification preserves the explicitly legal finite-lifetime spawned case and states whether the remaining spawned case is rejected, naming the diagnostic. Verify finite repeat/ping-pong endpoints and all scope/lifetime combinations. Do not change ping-pong's count unit: a full round trip is already explicit. + +### V13. Placement distributions need equations and explicit sample consumption + +*D/G #26, #28, D extra / G56; K M7. Confirmed, with qualifications. §18.3 and trace 18.10.10.* + +**Fix (design):** specify `n=1` placement (recommend fraction 0, matching `repeat.fraction`), even ring/path positions, ring radius for even mode, grid behavior when count differs from rows×columns, depth inverse-distribution functions, ellipse perimeter sampling, and a deterministic path arc-length algorithm/tolerance. State creation-index rules for bursts. + +Replace “field order” with an explicit list of random draws per distribution, including optional depth and jitter. Keep angle before radius. D/G treat parameter declaration order and random variate order as necessarily identical; they are not. The wording should distinguish them rather than reversing the locked polar order. Rectangle perimeter can use one distance sample around the perimeter; K's assertion that it needs two samples is not mandatory. Whatever method is chosen must fix sample count and mapping. + +Additional conflict within this package: §18.3 permits jittered grids, but trace 18.10.10 says `grid` consumes no samples without qualifying jitter. The no-samples claim for even/grid modes also needs to address an optional randomized depth sub-block. Test zero-jitter, jitter, and depth independently, with explicit final PRNG positions. + +### V14. Behavior schemas and channel composition need complete field contracts + +*D/G #29; K M11; qualified K M12 and D/G #25, #39. Confirmed in part. §18.6.* + +**Fix (design):** supply types, requiredness, defaults, ranges, waveform equations in cycles, phase origin, and time-dependent evaluation for every behavior. Specify the baseline for accumulated offsets versus integration so an orbit is not accidentally integrated as a fresh displacement every tick. Define pulse rise/fall timing, smoothing, and zero-velocity alignment using the XY plane. Expand behavior write sets to scalar channels for automation conflict detection; vector targets are already outside numeric-only automation, so D/G's proposed vector-track counterexample is not legal. + +For vortex, preserve literal screen counter-clockwise unless a design change is intended; e.g. state the perpendicular vector explicitly in y-down coordinates. Positive angular rotation is already clockwise from §17.2, and a positive force strength need not share its sign convention. Verify all 17 behaviors, compositions, signs, and default configurations; use V8 for noise behavior oracles. + +### V15. Morph's supported geometry and target sampling are incomplete + +*D/G #31; K L8. Confirmed. §§17.13, 18.6.* + +**Fix (design):** enumerate supported source/target types, their point representation, and the rejection code for point-less primitives. A narrow point-list-only rule is preferable to inventing text/group/rectangle interpolation. Decide whether command paths participate and what command compatibility means. State whether target points are snapshotted at instantiation or read after target motion, with an evaluation order if live. Preserve equal type/count/spline mode. Verify a moving target and an unsupported primitive as well as the existing spline cases. + +### V16. Distribution path references have no sibling object container + +*K L7; related D/G #30. Confirmed for distributions. §§18.3, 18.6.* + +Systems own distributions, but their siblings are systems, not paths in an object container. **Recommended fix:** retain inline commands for distributions and remove their impossible sibling-key shorthand unless a concrete scope is designed. Preserve object-attached `follow-path` sibling references, which do have a container, and explicitly carry §17.13 command limits into inline uses. Do not permit cross-component internal references. + +### V17. Viewport default fit and explicit-layer omission need conditional rules + +*K M8; D/G #11 and #51. Confirmed. §§17.4–17.5, 17.7.* + +**Fix:** for viewport, absent fit is accepted and has no mapping effect; only an explicitly authored non-`stretch` value is rejected. For explicit layers, specify what happens when a system omits `layer`. Recommend requiring a layer reference when the layer map is present, with a named diagnostic, rather than silently choosing the first. Define the empty-map case too. Verify absent/explicit fit and absent/empty/populated layers separately. + +### V18. Fog color math and conic fallback edge cases lack oracles + +*D/G #13, #14. Confirmed in part. §§17.6, 17.12.* + +**Fix (design):** state the fog interpolation space, alpha handling, and whether gradient stop colors are fogged before paint construction. Define the conic fallback bounding box and axis when the center is outside it or the ray has no intersection, including degenerate geometry. Preserve the same stops and their offsets: D/G's suggestion that using them as-is is wrong conflicts with the stated fallback. Verify known RGBA results, gradient stops, outside-center cases, and once-per-paint warnings. + +### V19. Post-effect radius conversion and parameter initialization are incomplete + +*K M9, part of L13; valid residue of D/G #44. Confirmed. §§17.14, 19.3–19.4.* + +**Fix (design):** give the conversion from scene-unit blur/bloom radius to full-frame device pixels, explicitly covering fit, nonuniform stretch, zoom, DPR, and reduced-resolution processing. A post-effect has no particular object's depth or layer parallax, so do not borrow either accidentally. State its ValueSpec initialization boundary and sampling order, including `enabled`, and map `saturation`/`hueRotate` to the named filter operations without unnecessarily renaming authored fields. Device-pixel scanline spacing is an explicit valid choice, not a reproducibility defect. + +### V20. Open camera clamp range has no minimum result + +*K L14. Confirmed. §§8.1, 19.3.* + +`focalLength > 0` has no smallest real admissible bound to clamp a nonpositive pipeline result to. **Fix (design):** choose a documented positive lower bound and use it consistently for literal validation, resolved values, automation, binding, override, and modulation. Do not silently choose an implementation epsilon. Verify zero/negative values through each supported stage and the resulting perspective/culling behavior. + +### V21. Links have missing style/fade defaults and unresolved dynamic limits + +*K M13; valid residue of D/G #35. Confirmed in part. §18.8.* + +No system has a general `style`, and `fadeWithDistance` can be true when `maxDistance` is absent. **Fix (design):** define link style independently or name the exact render/repeat style source; require a usable positive distance for distance fade, or define an alternative normalization. Specify indexing after particle deaths/evictions and nearest-neighbor tie-breaking before pair sorting. + +Capacity is a conservative upper bound, so rejecting capacity >256 is not necessarily erroneous. State whether that conservative authoring rule is intentional. Define instantiation/runtime checks for a procedural repeater count and any remaining dynamic population case, without silently dropping the entire link feature. Verify nearest ties, absent fade distance, static versus procedural counts, and creation-order gaps. + +### V22. Two component-input locations have no combination rule + +*K L10. Confirmed. §§18.1, 18.4–18.5.* + +`emit.inputs`/`repeat.inputs` coexist with system-level `inputs`, both described as per-item input values. **Recommended fix (design):** keep one canonical component-instance input location; if retaining both, specify precedence, duplicate handling, and sampling of masked values. Coordinate with A1 below: system lifecycle uses `inputs` for another purpose. Verify conflicting keys, absent inputs, refs inside a copy, and deterministic sample counts. + +### V23. Ownership and release need explicit edge semantics + +*K L1, L4, L12; D/G #40 in part. Confirmed in part. §§10.1–10.2, 19.2, 19.7.9–11.* + +Moving ownership to the performance root does not explain what `cancelWithScenario: true` then does. **Fix (design):** either explicitly maintain a separate originating-scenario cancellation relationship or remove/redefine the redundant flag through a coordinated contract change. Preserve the documented false case. State repeated-remove behavior in the lifecycle prose as well as the trace, and explain direct `CREATED → FINISHED`/failure paths and whether resources remain counted after `FAILED`. + +Use the release factor from V10. K's claim that opacity must pop to 1 assumes the wrong interpretation; clarify the factor rather than adding a public system opacity property. Test cancellation and natural scenario completion, explicit persistent ownership, duplicate remove, construction failure, and zero-duration release. + +### V24. Resource diagnostics need a single cadence and identity policy + +*K L2, warning parts of M5; D/G #40, #45. Confirmed in part. §§19.2, 19.5–19.7.* + +**Fix:** reconcile per-spawn “once,” once-per-ceiling-per-second warnings, and the 120-tick sustained report. Define whether the sustained report is another rate-limited occurrence with additional metadata, and define the clock and warning key. Give approximation shedding an explicit cadence too. Verify many refusals in one second, a sustained overload, recovery, and repeated overload. Do not mint separate codes merely because one diagnostic covers several documented causes. + +### V25. Centralized ceilings are incomplete; static draw load remains unbounded + +*K M4, L16. Confirmed, but two different levels of work. §19.5.* + +**Mechanical fix:** reconcile every subsystem bound against the central table. At minimum add path commands (512), burst entries (16), grid dimensions (256), and custom oscillator partials (64), and fix the resonator reference to §15.10 and its value 16. Include bounds such as text and stroke-dash lengths if “every ceiling” remains the stated scope; otherwise narrow that claim explicitly. + +**Design follow-up:** the absence of declared-system/authored-object aggregate limits is a safety coverage gap, not proof of a violated existing numeric ceiling. Bound expanded static draw load, or explicitly acknowledge the limitation and define its treatment. Choose values through the required measurement process; do not label invented values measured. Verify component expansion and many individually legal systems, not just a large particle pool. + +--- + +## Tier 3 — Editorial, local validation, and implementation sequencing + +### V26. Trace 17.16.9 names nonexistent layer nesting + +*D/G #10; K L3. Confirmed.* **Fix:** replace with 17 layers rejected / 16 accepted. Group depth 9/8 is already covered by trace 4. This is a small edit to an acceptance blocker, not a renderer architecture decision. + +### V27. Diagnostic staging and integer validation need precise local wording + +*K L5, L9; D/G #19, #41, #54. Mixed.* + +**Fix:** state diagnostic/stage for resolved noninteger counts and overlength resolved text, and for supplied component input values outside their declared range. Preserve §2's strict type rules; no rounding rule follows merely because the field is integer-valued. Burst timing belongs to V6. + +Visual and audio components need not use identical error codes merely because their scoping models mirror each other. The visual unknown-input and missing-input diagnostics are explicitly stated; changing them is a compatibility choice, not an obvious repair. Likewise `ERR_UNSUPPORTED_TARGET` for unsupported persistent ownership agrees with §10.1. Do not replace it based on the code's English name. Broaden the primitive diagnostic cause to mention fourteen primitives plus `component`, consistently with §17.9's already-explicit extension. + +### V28. Stale prose and references should be repaired without widening capabilities + +*D/G #36, #49; K L11, L13, L15.* + +**Fix:** describe sampled *bases* as fixed in §17.14, point its no-visual-exemption prose to §19.4's explicit grain exception, and correct the `instances.*` citation or document that action-only namespace in §1.3 without granting ValueSpec access. Make §19.2's lifecycle row a restatement of §17.7; map effect/filter names as in V19. These do not justify new live boolean writers, a seeded grain stream, or an altered lifecycle enum. + +### V29. Particle size and velocity alignment need explicit rendering conventions + +*D/G #24, #25. Confirmed in part. §§18.2, 18.4.* + +**Fix (design):** define particle `size` as a uniform local geometry scale, or give an explicit mapping for every supported render type, including components, `pointSize`, and ellipse radius. State its order relative to the render object's own transform. Use XY velocity for alignment under the existing 2.5D model and specify zero-XY-velocity behavior. Test a point, ellipse, and component under a size ramp and an item moving only in z. + +### V30. Record cross-slice dependencies; do not delete required depth coverage + +*D/G #55. Downgraded to sequencing clarification.* + +Sections 17–19 are all written before 4d; depending on §19.3's contract is not a circular specification dependency. **Fix:** list static camera/projection/fit support required by 4d, procedural execution required by 4e, and automation/lifecycle/effects required by 4f. Carry the data-model contracts needed by later slices without claiming those features execute in 4d. Keep §17.16.6's perspective requirement; dropping it to hide V3 would weaken acceptance. Never treat a parsed stub as a passed runtime trace. + +--- + +## Findings checked and cleared + +| Source | Disposition and reason | +| --- | --- | +| D/G #4, #17 | **Reject the claimed angle contradiction.** Positive angles toward +y in y-down space are clockwise, consistently. The path-arc objection depends on the same false premise. A clockwise annotation is harmless; sweep mathematics belongs to V4. | +| D/G #7 | **Reject.** The contract explicitly specifies 2.5D, a 2D local matrix, and additive depth. A parent XY rotation does not need to rotate child z. Do not introduce 4×4 mesh projection. | +| D/G #9; static-boolean portions of #36/#44; #33 | **Reject.** Once-resolved ValueSpecs can use refs/choices and are useful without a live writer. An invisible object can continue advancing, and an effect can remain disabled for its lifetime. Layer visibility and field strength deliberately lack external capability. | +| D/G #12 | **Reject as a standalone defect.** Window-dependent viewport center is appropriate; the camera default identity assertion is relative to that viewport. Fix the actual cross-space matrix in V3. | +| D/G #19, #44 pixel claims | **Reject.** Section 9.3 expressly does not promise identical pixels across machines. Generic fonts and device-pixel scanlines need no seeded-decision exemption. Text condensation and effect radius conversion remain V4/V19. | +| D/G #20 | **Reject.** A graphic system can be moved by an authored group; no system transform is required by its contract. | +| D/G #21 | **Reject the lifecycle contradiction.** Object lifetime and system lifetime are different scopes; §17.10 explicitly permits removal. Removing a sibling does not reverse the relative source order of surviving siblings. The actual same-key system collision is A1 below. | +| D/G #27 | **Reject.** “Not below” includes equality, so the distribution explicitly rejects `innerRadius >= radius`. Reword for readability only. | +| D/G #30 | **Reject cross-system reach as a requirement.** A sibling path is local, and §17.13 supplies the inline command contract. The system-owned distribution scope problem is V16. | +| D/G #34 | **Reject.** Section 9.1 fixes the tick to exactly `1000/60` ms. Render-rate independence does not imply invariance under arbitrary simulation steps. | +| D/G #35 ordering allegation | **Reject.** Drawing links before items is explicit and consistent with the atomic particle-system rule. Indexing gaps and nearest ties still need V21. | +| D/G #37 | **Reject a grammar contradiction.** Section 19.1 explicitly makes automation paths scope-relative; §1.3 defines indexed containers. Bindings use the absolute path family. Add positive/negative path examples if helpful, without inventing a new grammar. | +| D/G #41 | **Reject proposed diagnostic replacement.** Section 10.1 already uses `ERR_UNSUPPORTED_TARGET` for unsupported persistent ownership. | +| D/G #43 | **Reject.** Layer translation parallax and per-object perspective are separately specified. Parallax need not be a function of z. The overly broad “pinned” wording is noted in A3. | +| D/G #45/#46 | **Reject code reuse and farthest-first shedding as defects.** Degradation can keep nearby effects. Missing layer treatment and the appearance guarantee exception remain V10/V24. | +| D/G #47 | **Reject table location alone as a defect.** The row explicitly says authoring bound, not a shed. Its live-instance semantics are the real V7 defect. | +| D/G #48 | **Reject a mandatory seven-render-pass reading.** The opening pipeline describes composition of constructs; it does not require a separate component raster pass. V10 supplies actual compositing order. | +| D/G #50 | **Reject speculative namespace issue.** The visual component namespace is explicitly `components.visual`; a loader must use it. No demonstrated conflicting definition is supplied. | +| D/G #54 | **Reject fourteen-versus-fifteen as a schema contradiction.** Section 17.9 explicitly explains the extension and diagnostic. Only the shorter diagnostic cause needs editorial synchronization (V27). | +| K M12 | **Downgrade.** Counter-clockwise vortex strength and clockwise positive angles can coexist; strength is not an angle. Explicit vector formulas in V14 remove interpretive friction. | +| K L9 | **Do not force audio diagnostic parity.** Mirroring scoping is not a promise to reuse every error/stage. Complete input-value validation under V27. | +| K L12 | **Do not infer a mandatory opacity pop.** A separate release multiplier is consistent with the intended fade; V10/V23 make it explicit. | + +--- + +## Additional defects exposed by checking the reports + +These are not attributed to a reviewer. They should accompany the related repairs rather than be lost because the reports missed them. + +### A1. Lifecycle fields collide with particle/emitter/repeater fields + +**Confirmed; Tier 1.** Sections 17.7 and 19.2 reject `lifetime` and `inputs` on persistent systems. Sections 18.2 and 18.4 use that same top-level `lifetime` for per-item lifetime, and §§18.4–18.5 permit component input values on persistent systems. The normative particle/emitter examples therefore collide with the generic persistent-system prohibition. On a spawned emitter, one `lifetime` key cannot independently express both instance duration and item duration. Section 18.5 also forbids repeater `lifetime` while §19.2 permits spawned-system lifetime. + +**Fix (design):** separate lifecycle and item configuration into unambiguous fields/containers, then update common/type-specific tables, examples, inputs scope, validation, and traces together. Coordinate with V6/V22/V23. Do not patch only the prohibition on persistent systems; that leaves the spawned ambiguity intact. Verify persistent and spawned particles, emitters, and repeaters with independent item and system lifetimes. + +### A2. Two extension fields are missing from their allowed-field surfaces + +**Confirmed; Tier 2.** Section 18.7's noise `value` mode uses `direction`, but the noise row omits it and the shared optional-field sentence adds only `bounds` and `enabled`. Section 18.8 permits `trail` on a particle's `render` object, while neither the common visual property table nor the primitive contracts declares that conditional field. + +**Fix:** declare `direction` with requiredness/default and mode applicability. Declare the conditional render-object `trail` extension, including its interaction with a system-level trail, or remove that extra placement. Verify these positive forms plus rejection in unsupported contexts; preserve strict unknown-field validation. + +### A3. Backing-store limit and “pinned” parallax wording have edge contradictions + +**Confirmed; Tier 2.** Section 19.5 caps backing stores at 4096×4096 but forbids lowering the multiplier below 1. A CSS display wider than 4096 cannot satisfy both. Separately, §§17.5/19.3 call parallax 0 “pinned to the display,” but the normative matrix still applies zoom and rotation at parallax 0. + +**Fix (design for backing size):** define downsampling below 1 or another explicit large-display policy. Test a CSS surface larger than 4096 and a DPR below 1. For parallax, preserve the locked translation-only rule and clarify “pinned against camera translation”; do not exempt the layer from zoom/rotation. Verify parallax 0 with nonidentity zoom and rotation. + +--- + +## Complete source-to-triage mapping + +Each row covers D and G's identical numbered finding. “Cleared” refers to the reason above; a mixed finding has its actionable residue assigned to a package. D's extra finding maps to G56. + +| D/G | Disposition | D/G | Disposition | +| --- | --- | --- | --- | +| 1 | V1 | 29 | V14 | +| 2 | V2 | 30 | V16; cross-system demand cleared | +| 3 | V11 | 31 | V15 | +| 4 | Cleared | 32 | V8; mathematical claim corrected | +| 5 | V4 | 33 | Cleared | +| 6 | V3 | 34 | Cleared | +| 7 | Cleared | 35 | V21; draw-before-items cleared | +| 8 | V9 | 36 | V6/V28; static-boolean complaint cleared | +| 9 | Cleared | 37 | Cleared | +| 10 | V26 | 38 | V12 | +| 11 | V3/V17 | 39 | V14; vector-track example cleared | +| 12 | Cleared; related actual issue V3 | 40 | V23/V24 | +| 13 | V18 | 41 | Cleared | +| 14 | V18 | 42 | V3; sign objection cleared | +| 15 | V4, open fill/point stroke only | 43 | Cleared; additional pinned wording A3 | +| 16 | V4 | 44 | V19; pixel/static-enable complaints cleared | +| 17 | Cleared | 45 | V10/V24; code-reuse objection cleared | +| 18 | V4 | 46 | V10; priority objection cleared | +| 19 | V4/V27; pixel/summary-table complaints cleared | 47 | V7; table-location complaint cleared | +| 20 | Cleared | 48 | Cleared | +| 21 | Cleared; separate actual collision A1 | 49 | V28 | +| 22 | V10 | 50 | Cleared | +| 23 | V10; existing local clip/rendered-alpha rules retained | 51 | V17 | +| 24 | V29 | 52 | V3 | +| 25 | V29/V14; 3D demand cleared | 53 | V10 | +| 26 | V13 | 54 | V27; type-set contradiction cleared | +| 27 | Cleared | 55 | V30, sequencing only | +| 28 | V13 | D extra / G56 | V13, qualify parameter versus sample order | + +| K | Disposition | K | Disposition | +| --- | --- | --- | --- | +| H1 | V3 | M13 | V21; conservative-capacity objection qualified | +| H2 | V12 | L1 | V23 | +| H3 | V8 | L2 | V24 | +| H4 | V6 | L3 | V26 | +| M1 | V7 | L4 | V23, prose/trace synchronization | +| M2 | V5; “no consumer” overstatement corrected | L5 | V6/V27; no automatic rounding | +| M3 | V11 | L6 | V9 | +| M4 | V25 | L7 | V16 | +| M5 | V10/V24 | L8 | V15 | +| M6 | V9 | L9 | V27; diagnostic parity not required | +| M7 | V13; two-sample perimeter assertion corrected | L10 | V22 | +| M8 | V17 | L11 | V28 | +| M9 | V19 | L12 | V10/V23; pop allegation qualified | +| M10 | V2 | L13 | V19/V28 | +| M11 | V14 | L14 | V20 | +| M12 | V14, sign clarification only | L15 | V28 | +| — | — | L16 | V25, design follow-up | + +--- + +## Recommended edit order and closure instructions + +| Batch | Packages | Required outcome | +| --- | --- | --- | +| 1 — Contradictory schema and traces | V1, V2, V5, V11, V12, V17, V26; A1/A2 | One allowed-field surface per context; legal examples validate; impossible acceptance assertions removed without dropping their intended coverage. Resolve A1's field design before freezing system schemas. | +| 2 — Renderer geometry and composition | V3, V4, V9, V10, V18, V20, V29; A3 | Numerical coordinate/depth/color oracles, explicit geometry, complete compositing/allocation behavior, and a large-display policy. | +| 3 — Procedural evaluation | V6, V8, V13–V16, V21, V22 | Sampling boundaries and draw counts, normative algorithms, complete behavior schemas, and deterministic item/reference rules. | +| 4 — Live resources and later execution | V7, V19, V23–V25 | Atomic admission, complete ownership/release rules, effect units, and one documented diagnostic cadence. | +| 5 — Contract synchronization | V27, V28, V30; all affected §7/§8.1 rows and traces | Remove stale references, preserve capabilities, and state exactly which implementation slice executes each trace. | + +1. For each package, record the chosen design, affected sections, and minimal counterexample before editing. Mechanical fixes can proceed without inventing new features. Preserve the locked degree/depth conventions, capability families, fixed tick, seeded decisions, and non-evicting spawn policy unless an intentional design change is separately recorded. +2. Update the owning table, explanatory prose, examples, diagnostics, and required traces together. A sentence in a later section is not sufficient when an earlier allowed-field table explicitly rejects the same construct. +3. Author deterministic fixtures from the resolved contract, not from whichever renderer implementation happens to exist. For numerical algorithms, store expected values, sample counts, tolerances, and version. Distinguish exact procedural outcomes from renderer-dependent rasterization. +4. Run the existing appropriate validator/runtime checks when implementation changes land. At this document-only stage, do not claim runtime traces passed. Mark each package **open**, **decision recorded**, **spec fixed**, or **implementation verified** with evidence; these are different completion states. +5. Keep the original reviews and this triage intact as evidence. Record actual edits and validation results in a subsequent fix log, following the prior [04-fix-log.md](04-fix-log.md) pattern. Do not rewrite rejected findings into accepted ones merely to match a review's severity tally. + +**Current status:** all repair packages and additional defects are recommendations only. The specification and runtime remain unchanged by this triage pass. diff --git a/reviews/.completed-artifacts/02-abacus-ai-agent.md b/reviews/.completed-artifacts/02-abacus-ai-agent.md new file mode 100644 index 0000000..bdb84f9 --- /dev/null +++ b/reviews/.completed-artifacts/02-abacus-ai-agent.md @@ -0,0 +1,213 @@ +# Spec Review — Abacus AI Agent + +Source: Abacus AI Agent +Scope: sections 14–16 only (sections 1–13 and the PRD read for cross-checking) +Received: 2026-09-06 +Status: independent review — untriaged + +--- + +Independent review of sections 14–16 of `docs/XZBT_0-1_Format_Specification.md` (Rev 0.4). Every finding was verified against the current document text. Section 7 and the shared sections were read to check staging and diagnostic codes; the PRD was consulted only to confirm that cited PRD sections and gates exist. Deliberate design decisions listed in the review brief are not reported as defects. + +Findings are ranked by severity: a defect that makes a rule, capability, or algorithm unimplementable outranks an internal inconsistency. + +--- + +### 1. `ERR_INVALID_RANGE_ORDER` is staged Semantic-only, but its defining condition is "after resolution" — the third occurrence of the recurring staging class + +**Severity: high (rule unenforceable at its assigned stage)** + +**§7** files `ERR_INVALID_RANGE_ORDER` as **Semantic** only, with the cause "Declared paired bounds (e.g. `sample-hold` `min`/`max`) are not in strictly increasing order **after resolution**." + +**§14.12** declares `min`/`max` as `ValueSpec` in "14.4 scope", and 14.4 fixes resolution "once, at the owning sound instance's instantiation boundary." **§14.5** states the semantic stage's own limits: "Validation never opens an `AudioContext` and never depends on audio hardware" — and it also has no sound instances, so no resolved ValueSpecs. + +The documented invalid case (`min: 1, max: -1`, literals) is checkable at import. A legal document such as `{ "min": { "random": { "min": -1, "max": 1 } }, "max": { "random": { "min": -1, "max": 1 } } }` is not: the resolved pair exists only at instantiation, which §7 does not permit to emit this code. The cause text itself concedes this — "after resolution" — while the stage column forbids it. The document already contains the remedy pattern twice (`ERR_OUT_OF_BOUNDS` is dual-staged "Semantic / Runtime"; the frequency ceiling is split into a 24000 literal tier and an instantiation clamp tier in 14.5); this code did not get it. + +Secondary harm: if an inverted resolved pair reaches instantiation uncaught, 14.12's draw ("one uniform sample in `[min, max]`") is itself undefined for `min > max`, with no stage assigned to stop it. Secondary wording problem: 16.1 uses the same code for automation point ordering, which is a literal-only, genuinely semantic condition that §7's "paired bounds" cause text does not describe. + +**Smallest fix:** In §7, re-stage `ERR_INVALID_RANGE_ORDER` as `Semantic / Runtime` (literal pairs and literal curve order at import; resolved pairs at instantiation), mirroring `ERR_OUT_OF_BOUNDS`, and extend the cause text to name both conditions. Optionally add one clause in 14.12 stating that a resolved-order failure raises at instantiation. + +--- + +### 2. Automation point `value` consumes the seeded sound stream with no documented sampling boundary, order, or key + +**Severity: high (reproducibility unimplementable; corrupts the shared stream order)** + +**§16.1** defines each point as `{ "at": , "value": ValueSpec }` and says "A track's *values* remain full ValueSpecs and may be random." + +No section states when or from what stream a point `value` is sampled. §14.4's resolve-once rule covers "numeric and enum fields **on node objects**"; §15.12 had to add its own sentence to fold route `depth` into that rule ("Resolved once at instantiation (14.4)") — the precedent showing that non-node fields do not inherit 14.4 automatically. No equivalent sentence exists for track point values. §9.3 samples random ValueSpecs "once at the containing object's **documented** instantiation or invocation boundary"; a track has no documented boundary, and no domain or child key is documented for its draws. + +An implementer cannot know whether `value` is drawn at instantiation, at first evaluation, or lazily per point, nor its position in the depth-first document-order pass. Because all of a sound instance's ValueSpecs draw from **one** stream (14.4), an unspecified draw position shifts every subsequent node-field and `depth` draw: two implementations can both claim conformance yet produce different values for the same seed — breaking the §9.3 reproducibility promise for the whole instance, not just the track. + +**Smallest fix:** One sentence in 16.1: point `value` ValueSpecs are resolved once at the owning sound instance's instantiation boundary, in the same depth-first property-document pass as node fields and route `depth` (14.4, 15.12, 9.3), with a stated position — e.g. tracks after `nodes` and `routes`, in `automation` array order, points in array order. + +--- + +### 3. `audio.buses..gain` is promised automation and additive modulation, but no legal document can author either + +**Severity: high (normative capability claim with no authoring surface)** + +**§8.1** gives the bus row `Automation: Yes` and `Additive modulation: Yes`. **§15.17** repeats it: bus gain "remains the supported surface for binding, automation, override, and additive modulation of audio level from outside a graph." **§16.2** repeats it again: bus gain "takes binding, automation, override, and modulation as section 8.1 already states." + +No construct can express the middle two: + +- Automation tracks are declared only in the `automation` array of a **graph object** (16.1, 14.3), and graph objects exist at exactly three places (`audio.recipes.`, `sounds..recipe`, `components.audio.`) — none is a bus. A track's `target` must be `.` resolved in the same graph and must be in the 15.13 registry, which lists only node properties. A bus is not a node in any graph. +- Modulation routes are graph-internal (15.12–15.13); there is no route mechanism that reaches a bus. +- The bus object's allowed-field table (15.17) contains `gain` only. + +Binding and override do have authoring paths (the `bindings` array; actions). Automation and additive modulation are asserted in three places and are unrequestable by any document; the §8.5 required trace "A target has legal additive modulation" likewise has no authorable subject. + +**Smallest fix:** Either add an authoring surface — e.g. an `automation` array on the bus object, target `gain`, governed by the 16.1 track rules — or strike automation and additive modulation from the §8.1 bus row and from the restatements in 15.17 and 16.2. Do not leave the claims standing with no mechanism. + +--- + +### 4. "Underlying automation continues while overridden" (16.2) and trace 16.11.8 have no legal subject + +**Severity: high (specified behavior and mandatory trace unpassable)** + +**§14.4** is explicit: "A `BindingSpec`, `set` action, or `override` action addressing a node field … is `ERR_UNSUPPORTED_TARGET`." + +**§16.2** consequence 2 nonetheless narrates override-vs-automation on node properties: "An override masks the automation stage's output for as long as it wins … When the override releases, the property returns to whatever the track has reached by then." **§16.11.8** requires tracing exactly this. + +Every automatable property is a node property in the 15.13 registry (16.1 targets rule), and every node property is an illegal override target per 14.4. The one override-capable audio target, bus gain, has no authorable automation (finding 3). So the behavior 16.2 specifies and the trace 16.11.8 demands cannot be produced by any legal document. Note the asymmetry inside 16.2 itself: the pipeline hedges binding ("where supported") and consequence 1 restates the binding half of 14.4, but "winning override" is unhedged and consequence 1 is silent on the override half, while consequence 3's own principle ("Every stage a target does not expose is absent") makes the override stage absent for node properties. + +**Smallest fix:** In 16.2, state that node properties expose no override stage (14.4) and that override-vs-automation interaction applies only to `audio.buses..gain` — which requires fixing finding 3 first — and retarget 16.11.8 to bus gain (or drop it). + +--- + +### 5. The 16.5 one-shot bound is undefined when a resonator's retained modes are all omitted + +**Severity: high (algorithm undefined for a legal input)** + +**§16.5** defines the resonator contribution as "the longest `decay` among its **retained** modes." **§15.10**: "A mode whose resolved frequency exceeds `audioMaxFrequency` at instantiation is omitted." Per §14.5, `audioMaxFrequency = min(24000, sampleRate × 0.45)` and the sample rate is unknown at import, where the semantic ceiling is the device-independent `24000`. + +Legal input: a resonator with a single mode `{ "frequency": 20000 }` passes import (20000 ≤ 24000). On a 44.1 kHz device the ceiling is 19845, the mode is omitted, the retained set is empty, and "longest decay among retained modes" is a maximum over an empty set — undefined. The one-shot ending bound, which §16.5 requires the runtime to compute at instantiation, has no defined term for this graph, and 15.10's omission rule (matching 14.7) raises no diagnostic, so no error path covers it. The node's own output with zero retained modes is also unspecified. + +**Smallest fix:** One sentence in 16.5 (and optionally 15.10): a resonator with no retained modes contributes `0` to the bound and outputs silence. + +--- + +### 6. The bus `gain` base ValueSpec has no documented resolution boundary or stream + +**Severity: medium (seeded draw with no derivation key; legal input, undefined behavior)** + +**§15.17** types the bus field `gain` as `ValueSpec`, and §8.1 makes that ValueSpec the base stage of the bus row. + +No section says when it is resolved or from which stream. §14.4's boundary belongs to sound instances; a bus is not owned by one. §9.3 requires "the containing object's documented instantiation or invocation boundary" — none is documented for buses — and the reserved domains (`cadence`, `scenario`, `visual`, `sound`, `manual-sample`) contain no bus domain, nor is any child key documented for bus gain (14.12 is the only child-key documentation in the audio contract, and it is `sample-hold` only). A random or weighted-choice bus gain is therefore legal to author per the field type, yet its sampling time and derivation key are undefined — the same defect class as finding 2 on a different field. + +**Smallest fix:** One sentence in 15.17 fixing the boundary and stream (e.g., resolved once at performance start from a documented domain with a documented child key), or restrict the bus `gain` base to literals and `ref` forms. + +--- + +### 7. The 16.6 eviction policy is not scoped to the request's ceiling and contradicts its own independence rule + +**Severity: medium (policy incoherent as written)** + +**§16.6** steps 1 and 2 are unqualified: "Dispose the oldest instance already in `FINISHED`" and "Evict the oldest instance in `RELEASING`", applied "when a new instance would exceed **its** ceiling", stopping "at the first candidate." Step 3 is explicitly kind-scoped ("For a one-shot request only: evict the oldest `ACTIVE` one-shot"), so the asymmetry is in the text. The ceilings are separate budgets (64 one-shot / 16 continuous) and a voice "counts against its ceiling from `CREATED` until `DISPOSED`." + +Read literally, a one-shot request at its 64 ceiling may stop at step 1 by disposing the oldest `FINISHED` instance — which can be a continuous instance, freeing continuous budget, which does not admit the request; the runtime then either admits the request over its ceiling or has freed nothing. Step 2 likewise lets a one-shot request evict a `RELEASING` continuous sound, directly contradicting the same subsection's "A one-shot request never evicts a continuous sound, and a continuous request never evicts a one-shot." + +**Smallest fix:** Scope steps 1 and 2 to the request's budget: "the oldest same-ceiling instance already in `FINISHED`" / "the oldest same-ceiling instance in `RELEASING`". + +--- + +### 8. Automation track/point limits are "runtime ceilings rather than document properties" in 15.14 but semantically enforced in 16.1 + +**Severity: medium (self-contradictory staging classification)** + +**§15.14** classifies automation tracks (`64`) and points (`256`) as "runtime ceilings rather than document properties … belong to Phase 3c with the lifecycle contract." + +**§16.1** enforces them as `ERR_NODE_LIMIT_EXCEEDED`, which §7 stages as **Semantic**, and §16.11.6 tests them under a header that says the traces are "executable without an audio device" — i.e., at import. + +Track and point counts per expanded sound are document-determinable (component expansion needs no device), so 16.1's semantic enforcement is correct and 15.14's classification is false — and if 15.14 were taken seriously, the Semantic-staged code would be unenforceable. Additionally, §7's cause text for `ERR_NODE_LIMIT_EXCEEDED` enumerates only "oscillator partials, resonator modes, expanded nodes or routes per sound," omitting the limits 16.1 assigns to it. + +**Smallest fix:** In 15.14, move tracks/points into the authoring-limits table alongside expanded nodes/routes (keep "runtime" only for one-shot voices and continuous sounds), and add tracks/points to §7's `ERR_NODE_LIMIT_EXCEEDED` cause text. + +--- + +### 9. `ACTIVE -> FINISHED` is reserved for "a one-shot that has reached its determinable ending," but 16.5 defines that ending to include the release + +**Severity: medium-low (lifecycle trigger undefined; term collision across sections)** + +**§16.3:** "`ACTIVE -> FINISHED` without a release is reserved for a one-shot that has reached its determinable ending, where the envelope has already returned to zero and a further release would be redundant." + +**§16.5:** "The instance's ending is the maximum over all source-to-`output` paths of the sum of contributions along that path on the expanded audio graph, **plus the release duration of 16.4**." + +If the ending includes the release, an instance still `ACTIVE` at that instant never released, so it cannot have reached an ending defined to include one; if it releases at the end of the audible path, it passes through `RELEASING` and the direct transition never fires. The two sections use "determinable ending" for two different instants (path bound vs. path bound plus release), leaving the transition's trigger — the single most-implemented lifecycle edge for one-shots — undefined. + +**Smallest fix:** In 16.3, name the actual trigger: the end of the audible path (the 16.5 bound minus the release duration) with the output already silent — or redefine the 16.5 ending as the path bound with the release as a tail allowance. + +--- + +### 10. 14.7's empty-`harmonics` prose and its own invalid-case example assign different codes to the same input + +**Severity: low-medium (prose contradicts a JSON example in the same section)** + +**§14.7** states unconditionally: "An empty `harmonics` array is `ERR_SCHEMA_VALIDATION`." The same section's invalid case is `{ "type": "oscillator", "waveform": "sine", "harmonics": [] }` → `ERR_UNKNOWN_FIELD` on `harmonics`. + +The example input *is* an empty `harmonics` array, so the unconditional sentence assigns `ERR_SCHEMA_VALIDATION` to exactly the shape the example assigns `ERR_UNKNOWN_FIELD` (via the also-matching rule "`harmonics` present while `waveform` is not `custom` is `ERR_UNKNOWN_FIELD`"). No precedence between the two rules is stated, and trace 14.13.1 requires the documented invalid case to emit "exactly the documented code." + +**Smallest fix:** Scope the empty-array rule to the case where the field is declared: "An empty `harmonics` array while `waveform` is `custom` is `ERR_SCHEMA_VALIDATION`" — or state that unknown-field checks precede value checks. + +--- + +### 11. `ERR_NO_AUDIBLE_PATH` has two different triggers, and 15.14 rules 5 and 9 overlap on 15.19.3's own fixture + +**Severity: low-medium (inconsistent code definition; trace fixture is dual-code)** + +**§14.3:** "A graph object with no route reaching `output` is `ERR_NO_AUDIBLE_PATH`…" **§15.14** rule 9: "At least one audio route chain **from a non-control source** reaches `output` | `ERR_NO_AUDIBLE_PATH`." + +For a graph whose only output-reaching route starts at a control source (e.g. `lfo` → `output`): 14.3 says a route does reach `output`, so this is not `ERR_NO_AUDIBLE_PATH`; 14.10–14.12 and 15.19.3 ("A graph whose only path to `output` originates at an `lfo`, `constant`, or `sample-hold` **fails rule 5**") assign `ERR_INVALID_ROUTE`; yet rule 9 as worded is also violated (no non-control source exists). The same graph therefore matches two rules with different diagnostics, while 15.19.2 requires each rule's fixture to emit "exactly the named diagnostic." + +**Smallest fix:** Align rule 9 with 14.3's trigger — it fires when no audio route chain reaches `output` at all — leaving control-source chains to rule 5; or state a precedence for overlapping violations. + +--- + +### 12. The `exponential` interpolation requirement contradicts its own fallback trigger, leaving same-signed negative segments undefined + +**Severity: low-medium (breached rule with no defined breach behavior)** + +**§16.1:** exponential "Requires both endpoints **strictly positive** and same-signed; a segment with a **zero or sign-crossing** endpoint falls back to `linear` and raises `WARN_AUTOMATION_FALLBACK`." + +"Strictly positive" already implies same-signed, and it conflicts with the fallback trigger: a segment with both endpoints negative (legal — point `value` is `ValueSpec` with no declared range) is neither zero nor sign-crossing, so no fallback applies, yet it breaches the "strictly positive" requirement. A breached requirement with no applicable remedy is undefined behavior. The formula `v0 x (v1 / v0)^t` is in fact well-defined for same-signed negatives, which is evidently the intent. + +**Smallest fix:** "Requires both endpoints nonzero and same-signed" — matching the zero-or-sign-crossing fallback exactly. + +--- + +### 13. 14.3's type-specific-field pointer "14.7-14.12 and 15.1-15.8" omits three node types' defining sections + +**Severity: low (wrong cross-reference coverage; no dangling reference)** + +**§14.3's** node-object field table points type-specific fields to "14.7-14.12 and 15.1-15.8." The same table's `type` enum includes `mixer`, `resonator`, and `component`, whose fields and constraints are specified in **15.9, 15.10, and 15.11** — outside the stated range. All referenced sections exist; the range is simply wrong in coverage (it was never extended when Phase 3b grew to 15.11). + +**Smallest fix:** "14.7-14.12 and 15.1-15.11" (or 15.2-15.11). + +--- + +### 14. `input` is a "reserved node key" with no reservation rule and no diagnostic + +**Severity: low (legal-per-the-letter graph with ambiguous routing)** + +**§15.15:** when a component declares `input: true`, "the reserved node key `input` is available as a route `from` inside the graph." **§14.3's** reserved-key rule reserves only `output`: "`output` is reserved … and may not be declared in `nodes` (`ERR_INVALID_ID`)." + +`input` matches the identifier regex, and nothing forbids declaring a node keyed `input` in `nodes` of a component graph that declares `input: true`. Such a document is legal per the stated rules, and a route `{ "from": "input" }` is then ambiguous between the reserved passthrough and the author's node — undefined behavior. The word "reserved" implies the prohibition but, unlike `output`, states neither it nor a diagnostic. + +**Smallest fix:** Mirror the `output` rule: in a component graph that declares `input: true`, a node keyed `input` may not be declared in `nodes` (`ERR_INVALID_ID`). + +--- + +### Classes with no finding + +- **Prose contradicting a JSON example in the same section:** finding 10 only. All other exhibits and examples in 14–16 (14.3, 14.7–14.12 headers, 15.2–15.10, 15.15, 15.16, 15.17, 16.1) were checked field-by-field against their tables and prose; they match. +- **Field introduced in one section but missing from its container's allowed-field table:** none. Every container table in 14–16 (graph object, node object, all sixteen node types, route, component graph, component parameters, component instance, sounds entry, recipe, bus, automation track, automation point, harmonics entry, resonator mode entry) matches every field introduced for it. Finding 3 is the inverse shape — a capability asserted for a container with no authoring field anywhere. +- **Rule assigned to a stage that cannot enforce it:** findings 1 and 8. Elsewhere the document applies the correct pattern: `ERR_OUT_OF_BOUNDS` is dual-staged, 14.5 splits the frequency ceiling into a literal/24000 semantic tier and an instantiation clamp tier, and 16.5 correctly restricts the semantic `ERR_INDETERMINATE_ONESHOT` check to the document-decidable unbounded-source case. +- **Value consuming a seeded stream without a documented derivation key:** findings 2 and 6. `sample-hold` (14.12) documents its child key; component `values` and route `depth` are folded into 14.4/15.12; node fields are covered by 14.4. +- **Rule with no defined behavior on breach:** findings 12 and 14 (finding 7's eviction steps also produce undefined outcomes for a legal input). +- **Algorithm that may not terminate or is undefined for a legal input:** findings 5, 7, and 9. No non-termination: the 16.5 longest-path computation runs on the expanded acyclic graph bounded at nesting 8 and terminates; the delay tail formula `time x ceil(log(1/1000)/log(feedback))` is defined on the whole legal feedback range `(0, 0.95]` (as feedback approaches 0 the count ceils to 1, matching the `else time` branch at 0). +- **Diagnostic code used in 14–16 but absent from §7, or filed under the wrong stage:** none absent — all 24 codes used in 14–16 appear in §7 with causes matching their 15.18/16.10 restatements. Wrong-stage aspects are findings 1 and 8. +- **Cross-references to sections that do not exist:** none. Every internal section reference in 14–16 resolves, as do the PRD references (33–62, 117–120) and gates (GC4, GC6). Finding 13 is a wrong coverage range, not a dangling reference. + +### Deliberate design decisions recognized and not reported + +Node fields not externally bindable with `audio.buses..gain` as the sole external surface; nodes as a keyed map carrying no `id`; `noise`/`impulse` sample generation outside the reproducibility promise with `sample-hold` inside it; exactly one automation track per property while multiple modulation routes sum; provisional master-protection ceiling, tolerance, and release behavior pending GC6; `audio.master` required to be absent; automation point `at` as duration literals; the GC4 audio long-stall bound as a known omission; bus processing beyond gain and modulation of compressor/reverb/waveshaper properties out of scope. diff --git a/reviews/.completed-artifacts/02-application-code-triage.md b/reviews/.completed-artifacts/02-application-code-triage.md new file mode 100644 index 0000000..2cf6dec --- /dev/null +++ b/reviews/.completed-artifacts/02-application-code-triage.md @@ -0,0 +1,268 @@ +# XZBT 0.1 Application Code Triage — Visual Subsystem Readiness & Impact of §§17–19 Review + +**Context:** Following the triage of multi-model visual specification reviews in [reviews/01-triage.md](01-triage.md) (covering §§17–19 rev 0.7, 30 repair packages V1–V30, and additional defects A1–A3), this document conducts a comprehensive audit and triage of the **current application codebase** (`src/runtime/*`, `schema/xzbt-0.1.schema.json`, `tools/*`, and `test/*`). + +**Status of Codebase:** +- Working tree baseline: commit `527220e` (branch `main`). +- Test suite: **102 tests passing**, zero failures (`npm test`). +- Implementation progress: Phase 1 (runtime skeleton), Phase 2 (common grammar), and Phase 3 (audio engine through 3c-4) are implemented and verified. +- Visual status: Sections 17–19 represent the completed visual contract (slices 4a, 4b, 4c). **No visual renderer or procedural runtime code currently exists in `src/runtime/`**. Slice 4d is the scheduled entry point for visual implementation. + +--- + +## Executive Scorecard & Code Inventory + +| Subsystem / Module | Path | Current Implementation Status | Visual Contract & Triage Readiness | Key Touchpoints / Gaps | +| --- | --- | --- | --- | --- | +| **Top-Level Constants** | `src/runtime/constants.js` | Implements Phase 3b/3c constants | Stale version string; top-level fields present | `XZBT_RUNTIME_VERSION` is `'0.1.0-phase3b'`. Contains `'visual'` in `RNG_DOMAINS` and `'visuals'`, `'components'` in `ALLOWED_TOP_LEVEL_FIELDS`. | +| **JSON Schema** | `schema/xzbt-0.1.schema.json` | Validates GC2 and Audio (Phase 3c) | **Severe schema drift / Out of date** | `visuals` definition is a Phase 0 stub (`camera`, `systems`, `passes`). Lacks `scene`, `layers`, `fields`, `effects`, and `automation` (V1). `components.visual` is unconstrained `{ "type": "object" }`. | +| **Validator** | `src/runtime/validator.js` | Validates Meta, Grammar, Audio | **Permits arbitrary visuals; blocks visual bindings** | Completely lacks `validateVisualSubsystem()`. Rejects all 4 visual binding target families as `ERR_UNSUPPORTED_TARGET` (V2). Lacks `ValueSpec` support (V5). Missing visual authoring limits and diagnostics (V7, V25). | +| **Resolution Engine** | `src/runtime/resolution.js` | Shared GC3 + Audio resolution pipeline | **No visual target exposure** | `target(path)` only recognizes `parameters.*`, `state.*`, and `audio.buses.*.gain`. Rejects all visual target families, blocking bindings, automation, overrides, and modulation for camera, layers, systems, and effects (V2). | +| **Actions Executor** | `src/runtime/actions.js` | Implements `set` and `override` | **Missing lifecycle actions** | Throws `ERR_UNSUPPORTED_TARGET` on `spawn` and `remove` actions needed by spawned visual systems (§19.2, V7, V23, A1). | +| **Automation** | `src/runtime/audio-automation.js` | Implements audio graph tracks | **Audio-coupled; lacks loops** | Implements curves/modes and numeric stages, but enforces audio point bounds (256 vs 2048) and lacks `loop` modes (`repeat`, `ping-pong`) required by §19.1 and V12. | +| **Performance Shell** | `src/runtime/performance.js` | 60 Hz fixed-step scheduler | **Clock/Signals ready; lacks canvas** | `signals.pointer.*` and `signals.viewport.*` are tracked and updated. No Canvas element lifecycle or rendering loop attachment. | +| **Type Utilities** | `src/runtime/types.js` | Numeric/String/Color types | **Minimal color support; no geometry** | `color` type is checked only as non-empty string. Lacks color math (fog blending V18), angle conversions, or matrix transforms (V3). | +| **Diagnostics** | `src/runtime/diagnostics.js` | General diagnostics ring buffer | **Missing visual codes** | Visual diagnostic codes from §§17–19 are not declared or mapped (V24, V27). | +| **Visual Runtime** | `src/runtime/visual-*.js` | **Nonexistent** | **Pending Slice 4d** | No renderer (`visual-engine.js`), procedural system (`visual-procedural.js`), or visual lifecycle modules exist yet. | + +--- + +## Detailed Impact Analysis of `01-triage.md` Findings on Application Code + +### 1. Tier 1 — Contract & Architecture Blockers (Affecting Pre-Renderer and Core Modules) + +#### V1. `visuals.automation` forbidden by owning table +- **Application Code Impact:** + - `schema/xzbt-0.1.schema.json`: Currently has `passes` instead of `effects`, and completely omits `automation`. The schema must add an optional `automation` array under `visuals`. + - `src/runtime/validator.js`: When visual subsystem validation is implemented, `ALLOWED_VISUAL_FIELDS` must permit `automation` alongside `scene`, `layers`, `systems`, `fields`, `camera`, and `effects`. + - Scope separation: Code must distinguish exhibit-scope automation (`visuals.automation`, clock zero at performance activation) from system-scope automation (`visuals.systems..automation`, clock zero at system instantiation/spawn). + +#### V2. Acceptance trace 17.16.14 contradicts closed visual capability table +- **Application Code Impact:** + - `src/runtime/validator.js` (`validateBindings`, line 233): + Currently restricts binding targets to: + ```javascript + /^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(binding.target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(binding.target) + ``` + Must be extended to recognize exactly the four visual target families from Format Specification Table 8.1: + 1. `visuals.camera.` (`x`, `y`, `zoom`, `rotation`, `focalLength`) + 2. `visuals.layers..opacity` + 3. `visuals.systems..visible` + 4. `visuals.effects[].` + All other visual properties (such as per-object properties) must continue to emit `ERR_UNSUPPORTED_TARGET`. + - `src/runtime/resolution.js` (`target(path)`, lines 182–192): + Currently returns `null` for any visual target. It must be updated to return specification records for camera, layer opacity, system visibility, and post-effect parameters, enabling bindings and overrides to resolve against them. + +#### V3. Fit, camera, and perspective composition in named spaces +- **Application Code Impact:** + - Planned `src/runtime/visual-engine.js`: + - Must implement the unified scene-to-CSS fit matrix `F` (handling `contain`, `cover`, and `stretch` with offsets). + - Camera translation must be evaluated in CSS display coordinates: `q = F(cameraCenter)`, translation `q - c`. + - Perspective scaling factor `focalLength / (focalLength + zEffective)` centered at display center `c`. + - DPR (device pixel ratio) scaling applied once to the backing store transform. + - `src/runtime/types.js` or a new math utility module: + - Needs matrix multiplication / 2D affine transform helpers supporting non-uniform scale, translation, and rotation. + +#### V4. Primitive geometry definitions & path generation +- **Application Code Impact:** + - Planned `src/runtime/visual-engine.js`: + - Explicit local extents and anchors for all 14 primitives (Visual Primitive Set 0.1). + - Sweep calculation for arcs (direction, zero sweep, full turn >= 360). + - Catmull–Rom spline evaluation and closed spline index wrapping. + - Bezier spline explicit closing segment. + - Point stroke rendering and open-path fill closure rules. + +#### V5. Paint ValueSpecs contradict component example +- **Application Code Impact:** + - `src/runtime/validator.js` (`validateValueSpec`, line 157): + - Must recognize that `fill` and `stroke` accept `ValueSpec` (e.g. `{ "ref": "inputs.tint" }`), as well as paint objects and null. + - Must validate that references inside components resolve to declared component inputs. + - `src/runtime/values.js` (`ValueResolver`): + - Must resolve color references and weighted choices of color literals without coercing to numbers. + +#### V6. System controls vs item initialization resolution boundaries +- **Application Code Impact:** + - Planned `src/runtime/visual-procedural.js`: + - Emitter `count` and `rate` must resolve before allocating particle arrays. + - ValueSpecs resolved at system instantiation boundary vs per-particle birth draws must draw from separate or strictly ordered PRNG streams. + - Live automation channels must affect existing vs new particles according to the field ownership table. + +#### V7. Live automation totals vs import authoring bounds +- **Application Code Impact:** + - `src/runtime/validator.js`: + - Check authored static limits (e.g. template track/point counts). + - `src/runtime/actions.js` & visual lifecycle manager: + - Runtime check: when executing a `spawn` action, check whether adding that instance's tracks/points would exceed the live budget (128 tracks, 2048 points). + - If budget would be exceeded, execute **atomic spawn refusal** (do not partially allocate; raise `WARN_VISUAL_CEILING`). + +#### V8. Coherent noise reproducible algorithm +- **Application Code Impact:** + - `src/runtime/rng.js` or `visual-procedural.js`: + - Current `SeededRNG` only provides `nextFloat()` and `nextInt()`. + - Must implement a fully deterministic, portable gradient noise algorithm (fixed hash lattice, permutation table, octaves normalization, and curl math) that produces bit-exact vectors across browsers. + +#### V9. Sorting units for emitters, repeaters, and mixed-depth geometry +- **Application Code Impact:** + - Planned `src/runtime/visual-engine.js`: + - Procedural systems (particles, repeaters, emitters) must sort as atomic units using a documented representative depth (e.g. lowest live particle depth), with internal ordinal stability. + +#### V10. Offscreen allocation and compositing +- **Application Code Impact:** + - Planned `src/runtime/visual-engine.js`: + - Maintain a pool of up to 16 offscreen canvas buffers. + - Layer opacity and blend modes count against this pool. + - Implement deterministic shedding (farthest objects first) when buffers are exhausted, emitting `WARN_VISUAL_CEILING`. + - Separate instance release multiplier (initialized to 1) applied during release to avoid resetting authored opacity. + +--- + +### 2. Tier 2 — Slice-Specific Implementation Requirements + +#### V11. Repeater automation nonexistent `step` +- **Application Code Impact:** + - `src/runtime/validator.js` and visual automation: Ensure `step` is rejected as an unsupported automation target on repeaters. + +#### V12. Infinite loop legality on visual automation +- **Application Code Impact:** + - Automation module (`src/runtime/audio-automation.js` or visual equivalent): + - Implement `repeat` and `ping-pong` loop evaluation. + - Reconcile loop legality across exhibit scope, persistent systems, and finite/indefinite spawned systems. + +#### V13. Placement distributions & PRNG draw consumption +- **Application Code Impact:** + - Planned `src/runtime/visual-procedural.js`: + - Deterministic random sampling for all 9 placement distributions. + - Fixed draw order (angle before radius; explicit draws for depth and jitter). + - Grid distribution handling when item count does not match rows × columns. + +#### V14. Behavior schemas & channel composition +- **Application Code Impact:** + - Planned `src/runtime/visual-procedural.js`: + - Implement all 17 behaviors writing to scalar channels (`x`, `y`, `vx`, `vy`, `speed`, `angle`, etc.). + - Strict conflict detection: scalar channels cannot be simultaneously driven by conflicting behaviors. + +#### V15. Morph geometry & target sampling +- **Application Code Impact:** + - `visual-engine.js` / `visual-procedural.js`: + - Restrict morph to point-list primitives with equal point counts. Reject unsupported geometry with `ERR_UNSUPPORTED_TARGET`. + +#### V16. Distribution path references +- **Application Code Impact:** + - `validator.js`: Distribution paths must use inline commands; reject sibling-path references that lack an object container. + +#### V17. Viewport default fit & explicit-layer omission +- **Application Code Impact:** + - `validator.js`: In `viewport` space, an absent `fit` is valid; only explicit non-`stretch` is rejected. + - If `layers` map is present, system omission of `layer` must raise a named diagnostic rather than silently choosing a layer. + +#### V18. Fog color math & conic fallback +- **Application Code Impact:** + - `visual-engine.js`: Fog interpolation in sRGB/RGBA space; clamp conic gradient fallbacks when the focal point is external or degenerate. + +#### V19. Post-effect radius conversion +- **Application Code Impact:** + - `visual-effects.js`: Convert scene-unit blur/bloom radii to device pixels using display fit and DPR. Map filter names (`saturation`, `hueRotate`) to Canvas filter equivalents. + +#### V20. Open camera clamp range minimum +- **Application Code Impact:** + - `src/runtime/resolution.js`: Camera `focalLength` target must define a positive minimum clamp (e.g. `min: 0.001` or `min: 1`) to prevent division by zero in perspective projection. + +#### V21. Links style/fade defaults & capacity +- **Application Code Impact:** + - `visual-procedural.js`: Distance links must enforce capacity <= 256, resolve missing `maxDistance` when `fadeWithDistance` is true, and implement nearest-neighbor tie-breaking. + +#### V22. Component-input locations +- **Application Code Impact:** + - `validator.js`: Enforce single canonical component-instance input location (`emit.inputs` vs system `inputs`). + +#### V23. Ownership & release edge semantics +- **Application Code Impact:** + - `src/runtime/actions.js`: Support `spawn` and `remove`. Handle `cancelWithScenario`, idempotent duplicate removals, and release factor transitions. + +#### V24. Resource diagnostics cadence & identity +- **Application Code Impact:** + - `src/runtime/diagnostics.js`: Implement throttling / deduplication for `WARN_VISUAL_CEILING` and approximation warnings (e.g. once per second or sustained 120-tick reporting). + +#### V25. Centralized ceilings completeness +- **Application Code Impact:** + - `validator.js`: Enforce static draw load ceilings: path commands (512), burst entries (16), grid dimensions (256), custom partials (64). + +--- + +### 3. Additional Defects (A1–A3) + +#### A1. Lifecycle fields collide with particle/emitter/repeater fields +- **Application Code Impact:** + - In `schema/xzbt-0.1.schema.json` and `src/runtime/validator.js`: + System-level `lifetime` (for spawned system duration) collides with item-level `lifetime` (for particle lifespan). + The codebase must enforce the separated container/field naming (e.g. system lifecycle in a dedicated block or renamed property) before writing procedural validation. + +#### A2. Missing extension fields +- **Application Code Impact:** + - Add `direction` to procedural noise field definitions. + - Clarify or reject `trail` on particle `render` objects. + +#### A3. Backing-store limit (4096) & pinned parallax +- **Application Code Impact:** + - `visual-engine.js`: When CSS display size × DPR exceeds 4096×4096, downsample the DPR multiplier rather than failing or exceeding the canvas maximum. + - Parallax 0 pins against camera translation only; zoom and rotation still apply. + +--- + +## Action Plan & Implementation Sequencing for Application Code + +The application code changes required by the visual specification and triage findings should be delivered in five coordinated stages aligned with the existing project milestones: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Stage 0: Shared Pre-Renderer Alignment (Pre-4d) │ +│ • Reconcile schema/xzbt-0.1.schema.json (§§17-19, V1, A1) │ +│ • Update validator.js: expose 4 visual target families (V2) │ +│ • Update resolution.js: target() returns visual specs (V2) │ +│ • Update constants.js: version string to phase4 │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Stage 1: Renderer Core (Slice 4d) │ +│ • Canvas 2D backend, Scene fit & Camera transform (V3, A3) │ +│ • 14 Primitives path generator & appearance (V4, V5) │ +│ • 16-buffer offscreen pool & compositing (V10) │ +│ • Depth sorting & fog blending (V9, V18) │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Stage 2: Procedural Systems (Slice 4e) │ +│ • Component instantiation & inputs resolution (V5, V22, A1) │ +│ • Particle Euler integrator & emission math (V6, V29) │ +│ • 9 Placement distributions & draw orders (V13) │ +│ • 17 Behaviors & scalar channels (V14) │ +│ • Coherent noise algorithm (V8) │ +│ • Distance links & trails (V21) │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Stage 3: Automation, Lifecycle & Effects (Slice 4f) │ +│ • Shared automation with repeat/ping-pong loops (V1, V12) │ +│ • System lifecycle actions (spawn/remove) in actions.js(V23)│ +│ • Atomic admission & live ceiling checks (V7, V24, V25) │ +│ • Post-processing filter chain & device conversion (V19) │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Stage 4: Verification & Acceptance (Slices 4g & 4h) │ +│ • PRD 130 challenge fixtures and Exhibit visuals │ +│ • Hardware benchmark and resource audit │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Immediate Next Steps & Safety Invariant + +1. **Safety Invariant:** All existing 102 tests in `npm test` must continue to pass without regression. Audio and common grammar semantics remain locked. +2. **Contract Synchronization:** Before implementing the Canvas 2D renderer in Slice 4d, Stage 0 must synchronize the shared validator, JSON schema, and target resolver to prevent drift between Format Specification Rev 0.7 and the runtime foundation. +3. **Evidence Record:** Maintain this triage alongside `reviews/01-triage.md` and `reviews/04-fix-log.md` to track code closure as Phase 4 slices proceed. diff --git a/reviews/.completed-artifacts/02-kimi-k3.md b/reviews/.completed-artifacts/02-kimi-k3.md new file mode 100644 index 0000000..cc14515 --- /dev/null +++ b/reviews/.completed-artifacts/02-kimi-k3.md @@ -0,0 +1,110 @@ +# Spec Review — Kimi K3 + +Source: Kimi K3 +Scope: sections 14–16 (with line references) +Received: 2026-09-06 +Status: raw, untriaged — verbatim as delivered + +--- + +## Defects + +### 1. §15.13/§15.11 vs §14.4 — component exposed parameters are legal modulation/automation targets with no defined effect (Class 6/5) + +**What it says:** The 15.13 registry (line 1085) lists `component` / `` as a modulation target; 15.11 (line 1045) repeats it; 16.1 (line 1293) makes the same properties automation targets; 15.19 trace 4 (line 1250) requires testing that "an exposed parameter is a legal modulation target." But the only way a component's internals consume a parameter is `{ "ref": "inputs." }` in a node field (15.15, line 1166), and 14.4 (line 629) resolves every node field exactly once at instantiation, "constant for that node instance's lifetime"; 15.11 (line 1039) likewise resolves the supplied `values` once. + +**Why wrong:** After instantiation, nothing inside the component ever observes the parameter again, so the 15.13 modulation sum (and any 16.1 automation curve) applied to an exposed parameter has no defined effect on anything audible. The registry declares a legal target whose processing semantics do not exist; an implementer cannot satisfy trace 15.19-4 without inventing behavior, and the two natural readings (fields frozen per 14.4 vs. live `inputs.*` refs) contradict each other. + +**Smallest fix:** Either remove the `component` row from the 15.13 registry (and exclude exposed parameters from 16.1 targets and 15.19 trace 4), or add one sentence to 15.15 stating that node fields of the exact form `{ "ref": "inputs." }` track the parameter's current value as an exception to 14.4's resolve-once rule. The first is smaller. + +--- + +### 2. §14.12/§7 — `ERR_INVALID_RANGE_ORDER` is filed Semantic-only, but the sample-hold rule requires instantiation-time values (Class 3 + 7) + +**What it says:** 14.12 declares `min`/`max` as full `ValueSpec` in 14.4 scope (lines 815–816) — they may be `random` and resolve once at instantiation. Line 819: "`min` resolving to a value greater than or equal to `max` is `ERR_INVALID_RANGE_ORDER`." Section 7 (line 286) files that code as Semantic only, with the cause "...not in strictly increasing order after resolution." + +**Why wrong:** Semantic validation runs at import with no seed, no streams, no runtime state — 14.5 (line 645) states validation "never opens an `AudioContext`" and tier 1 checks only values that "resolve to a literal"; 9.3 derives streams from the performance's recorded root seed, which does not exist at import. For a legal document such as `"min": { "random": { "min": -5, "max": 5 } }, "max": 2`, the order check cannot run at the semantic stage, and the Semantic-only filing forbids the only stage (instantiation) where the resolved values exist. Section 7's own cause text ("after resolution") concedes this. The rule is unenforceable as staged. (The 16.1 use of the same code for automation `at` ordering is fine — `at` is a duration literal.) + +**Smallest fix:** Refile `ERR_INVALID_RANGE_ORDER` in section 7 as `Semantic / Runtime` (mirroring `ERR_OUT_OF_BOUNDS`' dual filing), and in 14.12 state that literal bounds are checked at import while procedural bounds are checked when resolved at instantiation. + +--- + +### 3. §15.14 vs §16.1 — automation track/point limits are called "runtime ceilings rather than document properties" yet are enforced with a Semantic code (Class 3 staging contradiction) + +**What it says:** 15.14 (line 1132): "The remaining PRD 58 limits — approximate one-shot voices (`64`), approximate continuous sounds (`16`), automation tracks (`64`), and automation points (`256`) — are runtime ceilings rather than document properties..." But 16.1 (line 1318): exceeding 64 tracks or 256 points "is `ERR_NODE_LIMIT_EXCEEDED`," which section 7 (line 285) files as Semantic, and 16.11 trace 6 (line 1483) requires `65`/`257` to fail and `64`/`256` to pass in the automated, no-audio-device group. + +**Why wrong:** Automation tracks and points are authored in the document's `automation` arrays and are countable after static expansion (15.19 trace 7 confirms expansion runs with no `AudioContext`). They are document properties and are semantically enforced; 15.14's sentence misclassifies them, so an implementer following 15.14 would defer them to runtime and fail trace 16.11-6. (The voice ceilings in the same sentence are correctly runtime per 16.6.) + +**Smallest fix:** Delete "automation tracks (`64`), and automation points (`256`)" from the 15.14 sentence. Optionally extend section 7's `ERR_NODE_LIMIT_EXCEEDED` cause parenthetical (line 285), which currently omits the automation-limits use introduced in 16.1. + +--- + +### 4. §16.1 — automation point `value`s draw from the seeded stream with no documented sampling boundary or order (Class 4) + +**What it says:** Line 1289 makes each point `{ "at": , "value": ValueSpec }`; line 1297: "A track's values remain full ValueSpecs and may be random." Nothing states when they are sampled or in what stream order. 14.4 (line 629) documents resolve-once sampling only for node-object fields; 15.12 (line 1067) fixes the sampling order only for route `depth` values; 9.3 (line 457) requires "the containing object's documented instantiation or invocation boundary" and depth-first, property-document order — no documented boundary or order exists for the `automation` array. (The stream identity itself is inherited from the sound instance; what is missing is the boundary and the consumption order.) + +**Why wrong:** Two conforming implementations may sample track values at different positions in the sound stream (before/after node fields, before/after route depths), producing different values for the same seed — silently breaking the 9.3 reproducibility promise that 14.6 explicitly claims for resolved-once audio values. It is also unstated whether a track's values are sampled once per sound instance at all. + +**Smallest fix:** One sentence in 16.1 fixing boundary and order, e.g.: "A track's point values are resolved once at the owning sound instance's instantiation boundary, sampled from the sound instance's stream in depth-first, property-document order with `automation` taken after `nodes` and `routes`." + +--- + +### 5. §15.10/§16.5 and §14.7 — undefined result when every mode or partial is omitted (Class 6 edge case) + +**What it says:** 15.10 (line 1024) and 14.7 (line 690) omit over-ceiling modes/partials at instantiation and state omission "is a routine consequence of legal authoring and raises no diagnostic." 16.5 (line 1387) defines a resonator's contribution as "the longest `decay` among its retained modes." + +**Why wrong:** A legal document can have all entries omitted (e.g. `fundamental: 20000` with `modes: [{ "ratio": 2 }]` on a 44.1 kHz device, where `audioMaxFrequency` = 19845). "Longest decay among retained modes" is then a maximum over an empty set — undefined — and it feeds the one-shot ending computation of 16.5. Likewise a `custom` oscillator with all partials omitted has no defined output. + +**Smallest fix:** State that a resonator with no retained modes contributes zero (and is silent), and that a custom oscillator with all partials omitted produces silence. + +--- + +### 6. §15.16 — `name`/`tags` length caps state no breach behavior (Class 5) + +**What it says:** Lines 1197–1198: `name` "at most `128` characters"; `tags` "at most `16` entries of at most `32` characters." No diagnostic is named. Every other quantitative limit in §§14–16 names its code, and the in-section conventions don't cover string/array lengths (`ERR_TYPE_MISMATCH` covers bad enum tokens, `ERR_OUT_OF_BOUNDS` covers numeric ranges; section 7's `ERR_SCHEMA_VALIDATION` cause covers "malformed top-level shapes," which `sounds.` is not). + +**Smallest fix:** Name the diagnostic for exceeding these caps (e.g. `ERR_SCHEMA_VALIDATION`). + +--- + +### 7. §16.2 — misattributed cross-reference (borderline Class 8) + +Line 1332 cites "the parameter-masking rule of section 8.1" for "releasing an override returns toward the current stored value rather than an obsolete snapshot." Section 8.1 exists but contains no such rule — its only masked-override text (line 318) concerns the UI override indicator. The behavior is specified in 8.3 (line 370, masked stages continue) and 8.4 (line 392, `currentLowerValue` recomputed every tick). + +**Smallest fix:** cite 8.3/8.4 instead of 8.1. + +--- + +## Classes with no findings + +* **Class 1** (prose contradicting a JSON example in the same section): none. I checked every example and invalid case in 14.3–14.13, 15.2–15.17, and 16.1 against its section's tables and rules (ranges, enums, defaults, named diagnostics); all match, including the subtle ones (14.7's empty-`harmonics` invalid case correctly maps to `ERR_UNKNOWN_FIELD`, not `ERR_SCHEMA_VALIDATION`; 15.16's `oneshot` example genuinely has a determinable ending under 16.5). +* **Class 2** (field missing from its container's allowed-field table): none. `automation` is in 14.3's graph-object table; `parameters`/`input` in 15.15's; `mode`/`release` in 15.16's recipe table; all node fields, route fields (15.12), track fields (16.1), sound fields (15.16), and bus fields (15.17) are tabulated in their containers. +* **Class 6**, beyond findings 1 and 5: checked and cleared — 16.5's longest-path termination (acyclic route graph + nesting ≤ 8), the delay tail formula's domain over `feedback ∈ [0, 0.95]`, the exponential-interpolation fallback, the 16.6 eviction order, and lifecycle transition totality (including the second-stop no-op). +* **Class 7**, beyond finding 2: all 24 `ERR_`/`WARN_`/`INFO_` codes used in §§14–16 are present in the section 7 table (verified by mechanical extraction); the 15.18 and 16.10 "new codes" tables match section 7's entries in stage and wording. Only `ERR_INVALID_RANGE_ORDER` is mis-staged (finding 2), and `ERR_NODE_LIMIT_EXCEEDED`'s cause list omits its 16.1 automation use (folded into finding 3). +* **Class 8**, beyond finding 7: all internal references (1.3, 4, 6.1, 6.2, 7, 8.1, 8.2, 9.3, 11, and all 14.x/15.x/16.x) resolve, and every PRD reference used in §§14–16 (33–62, 117–118, 120, 129) exists as a numbered PRD section. + +--- + +# Second output — Kimi K3 (condensed restatement) + +Same seven defects, delivered as a summary pass. Line numbers refer to `docs/XZBT_0-1_Format_Specification.md`. Verbatim as delivered. + +--- + +Review of XZBT format spec sections 14–16 complete. Seven defects found, ranked by severity (line numbers refer to docs/XZBT_0-1_Format_Specification.md): + +1. **§15.13/§15.11 vs §14.4** — component exposed parameters are legal modulation/automation targets with no defined effect (unimplementable). The 15.13 registry (line 1085) and 15.11 (line 1045) make `.` a modulation target, and 16.1 makes it an automation target. But the only internal consumption path is `{ "ref": "inputs." }` in a node field (15.15, line 1166), and 14.4 (line 629) freezes every node field at instantiation "for that node instance's lifetime." Nothing ever re-reads the parameter, so the modulation sum/automation curve has no defined audible effect, and acceptance trace 15.19-4 cannot be satisfied as written. Smallest fix: remove the `component` row from the 15.13 registry (and exclude exposed parameters in 16.1 and trace 15.19-4), or state in 15.15 that exact-form `inputs.*` ref fields track the parameter's current value as an exception to 14.4. + +2. **§14.12/§7** — `ERR_INVALID_RANGE_ORDER` is filed Semantic-only but the sample-hold rule needs instantiation-time values. `min`/`max` are full ValueSpecs resolved once at instantiation (14.12 lines 815–816); the order rule (line 819) therefore cannot be checked at import for legal procedural bounds — semantic validation has no seed, streams, or runtime state (14.5 line 645; 9.3). Section 7 (line 286) files the code as Semantic only while its own cause says "after resolution." Smallest fix: refile as `Semantic / Runtime` in section 7 and state in 14.12 that literal bounds are checked at import, procedural bounds at instantiation. (The 16.1 use of the same code is fine — `at` is a literal.) + +3. **§15.14 vs §16.1** — staging contradiction on automation limits. 15.14 (line 1132) calls automation tracks (64) and points (256) "runtime ceilings rather than document properties," but 16.1 (line 1318) enforces them with `ERR_NODE_LIMIT_EXCEEDED`, a Semantic code (section 7 line 285), and 16.11 trace 6 requires import-stage enforcement. Tracks/points are countable in the document after static expansion; the voice ceilings in the same sentence are correctly runtime. Smallest fix: delete the two automation limits from the 15.14 sentence; optionally extend `ERR_NODE_LIMIT_EXCEEDED`'s cause list in section 7 to mention automation. + +4. **§16.1** — automation point values draw from the seeded stream with no documented sampling boundary or order. Points are `{ "at": literal, "value": ValueSpec }` and values "may be random" (lines 1289, 1297), but 14.4 documents resolve-once sampling only for node fields and 15.12 only fixes route-`depth` order; nothing fixes when or in what stream order track values sample (9.3 requires a documented boundary). Two conforming implementations can consume the stream in different orders and diverge for the same seed, breaking the 9.3/14.6 reproducibility promise. Smallest fix: one sentence in 16.1 fixing the instantiation boundary and the position of `automation` in the depth-first property-document sampling order. + +5. **§15.10/§16.5 and §14.7** — undefined result when every mode/partial is omitted. A legal document can have all resonator modes omitted at instantiation (e.g. `fundamental: 20000`, `modes: [{ "ratio": 2 }]` on a 44.1 kHz device); 16.5's "longest `decay` among its retained modes" is then a max over an empty set, and a custom oscillator with all partials omitted has no defined output. Smallest fix: state both contribute zero / produce silence. + +6. **§15.16** — `name` (128 chars) and `tags` (16 × 32 chars) caps name no diagnostic on breach, unlike every other quantitative limit in §§14–16; no in-section convention covers length caps. Smallest fix: name the code (e.g. `ERR_SCHEMA_VALIDATION`). + +7. **§16.2 line 1332** — mis-citation: "the parameter-masking rule of section 8.1" is not in 8.1; the behavior lives in 8.3 (line 370) and 8.4 (line 392). Smallest fix: cite 8.3/8.4. + +Classes with no findings: Class 1 (all in-section JSON examples match their prose, including invalid-case codes); Class 2 (every introduced field appears in its container's allowed-field table); Class 7 otherwise (all 24 codes used in §§14–16 verified present in the section 7 table; 15.18/16.10 match it); Class 8 otherwise (all internal section references and all cited PRD sections 33–62, 117–118, 120, 129 exist). Nothing in the excluded deliberate-design list was reported. No edits were made to the document. diff --git a/reviews/.completed-artifacts/03-deepseek-v4-flash.md b/reviews/.completed-artifacts/03-deepseek-v4-flash.md new file mode 100644 index 0000000..7d5a48e --- /dev/null +++ b/reviews/.completed-artifacts/03-deepseek-v4-flash.md @@ -0,0 +1,135 @@ +# XZBT format specification sections 14–16 — defect review + +Reviewer: independent pass over `docs/XZBT_0-1_Format_Specification.md` (commit `e5ed468`), sections 14–16 only. +Scope: defects only — no rewrites, no features, no style commentary. Line numbers refer to the spec file at that commit. +Not reviewed against any other review file; this analysis is standalone. + +Ranking: findings that make the contract unimplementable or untestable outrank inconsistencies and under-specifications. + +--- + +## F1 — No target supports both automation and override; 16.2's masking rule and acceptance trace 16.11.8 cannot be executed, and the bus-gain "automation" claim contradicts 16.1's own target rule + +**Sections:** 16.1 (lines 1286, 1293), 16.2 (lines 1322–1332), 8.1 (line 314), 15.17 (line 1230), 14.4 (line 631), 16.11 trace 8 (line 1485). + +**What the document says:** + +- 16.2 gives the resolution pipeline "For a node property: `base ValueSpec -> binding (where supported) -> automation -> winning override -> modulation sum -> safety clamp -> engine parameter`" and states as a consequence: "An override masks the automation stage's output for as long as it wins… When the override releases, the property returns to whatever the track has reached by then." +- 16.11 trace 8: "An override masking an automated property releases to the track's *current* value, not the value held when the override took hold." +- 8.1, 15.17, and 16.2 all state that `audio.buses..gain` "takes binding, automation, override, and modulation." + +**Why it is wrong:** The two capability sets never intersect on any property a document can actually author: + +- Node properties support automation (16.1) but are barred from override: 14.4 — node fields are not added to the 8.1 table, and "an `override` action addressing a node field … is `ERR_UNSUPPORTED_TARGET`." 16.2 itself reaffirms this. +- `parameters.` and `state.` support override but have Automation = No (8.1). +- Bus gain is the only target with both Automation = Yes and Override = Yes (8.1), but 16.1 confines automation tracks to a graph object's `automation` array with `target` = `.`, "resolved in the same graph," restricted to "any property in the 15.13 modulatable registry, and no other." Buses are not nodes in any graph (they are top-level `audio.buses.` objects whose only field is `gain`, 15.17), and bus gain is not in the 15.13 registry. No authoring surface for a bus-gain automation track exists anywhere in sections 14–16. + +Consequences: (a) 16.2's masking rule describes a situation no legal document can produce, and (b) required acceptance trace 16.11.8 is unimplementable — the Phase 3c acceptance suite cannot pass as written. The same orphaning applies to bus-gain "additive modulation": the only modulation syntax in the document is graph-scoped routes of 15.12/15.13, whose `to` must be `.` in the same graph, so nothing can additively modulate a bus gain either, despite the 8.1 row and the 15.17/16.2 sentences. + +**Smallest fix:** Pick one side and make the other consistent — either (a) delete "automation" (and, for the same reason, "additive modulation"/"modulation") from the bus-gain capability claims in 8.1, 15.17, and 16.2, and delete or re-scope 16.2's masking consequence and trace 8 to a target that actually exposes both stages — no such target exists, so the masking rule and its trace must be removed unless a target with both stages is added — or (b) give bus gain an actual automation authoring surface and make 16.1's target rule name it. Option (a) is the smaller change. + +--- + +## F2 — 16.6 eviction step 3 cannot free a budget slot; the outcome for the incoming instance is undefined + +**Section:** 16.6 (lines 1406–1415); also 16.3 (line 1345) and 16.11 trace 10 (line 1487). + +**What the document says:** "A voice counts against its ceiling from `CREATED` until `DISPOSED`." The eviction policy is: "1. Dispose the oldest instance already in `FINISHED`. 2. Evict the oldest instance in `RELEASING` by advancing its release ramp to immediate completion and disposing it. 3. For a one-shot request only: evict the oldest `ACTIVE` one-shot by starting its release. 4. Otherwise refuse the new instance." The runtime "stops at the first candidate," and "Eviction always releases (16.4); it never hard-stops an active voice." + +**Why it is wrong:** Steps 1 and 2 dispose an instance, so a slot frees immediately. Step 3 only *starts* the release of an `ACTIVE` voice, moving it to `RELEASING` — and a `RELEASING` voice still counts against the ceiling until `DISPOSED` (that is precisely why step 2 must advance the ramp to completion and dispose). So when the ceiling is full of `ACTIVE` one-shots (no `FINISHED`, no `RELEASING`), step 3 frees nothing, yet the policy stops there. Admitting the new instance then puts the count one over the ceiling (65 > 64), contradicting the "counts from `CREATED` until `DISPOSED`" rule; refusing it contradicts the policy having stopped at a candidate rather than reaching step 4. Neither outcome is defined. Acceptance trace 16.11.10 (exercise the eviction order at the one-shot ceiling) cannot be implemented unambiguously in exactly this state. + +**Smallest fix:** One sentence defining the step-3 outcome, e.g. that an evicted `ACTIVE` one-shot is removed from the ceiling count immediately upon eviction (its release then runs without occupying budget), so the new instance is admitted and the ceiling is never exceeded; or make step 3 behave like step 2 (complete the release immediately and dispose). Either way the "counts until `DISPOSED`" sentence needs the matching exception or wording. + +--- + +## F3 — 16.5's ending bound is not an upper bound when a bound-contributing property is modulated + +**Sections:** 16.5 (lines 1376, 1385), 15.6 (lines 943, 949), 15.13 (line 1082), 16.3 (line 1360). + +**What the document says:** "Determinable" means "the runtime can compute a finite upper bound on the instance's audible duration at instantiation, from the resolved graph alone, without observing output." The bound table gives `delay` the contribution `time x ceil(log(1/1000) / log(feedback))`. The instance is finished (torn down) at that bound plus the release duration. + +**Why it is wrong:** `delay.time` is modulatable (15.13, in milliseconds) and is clamped live so it never leaves `[0ms, 10s]` (15.6). A legal `oneshot` can route an `lfo` or `sample-hold` to a delay's `time`; those sources vary over time and their future output is unknowable at instantiation. The 16.5 row computes the contribution from the *resolved* `time` only, but the actual delay time — and therefore the actual time the feedback tail takes to fall 60 dB — can be up to the 10 s clamp. The stated bound is then not an upper bound on audible duration: the runtime reaches the computed ending and tears the instance down while its tail is still ringing above the level the bound assumes, directly contradicting 16.3's claim that at the determinable ending "the envelope has already returned to zero." Trace 16.11.9's "bound matches the 16.5 table" only passes for graphs whose bound-contributing properties are not modulated. + +**Smallest fix:** In 16.5, define the delay contribution over the largest delay `time` the property can reach given its modulation routes (source outputs are bounded — `lfo` by its resolved amplitude, `sample-hold` by its resolved `min`/`max` — and the property is clamped), or exclude a modulated bound-contributing property from determinable-ending status. One sentence either way; the first option preserves the most legal graphs. + +--- + +## F4 — 14.12's `min`/`max` ordering check fires after resolution but its code is filed Semantic-only: recurrence of the known staging defect + +**Sections:** 14.12 (lines 812–819), 14.4 (line 629), §7 (line 286). + +**What the document says:** `min` and `max` are `ValueSpec` fields "resolved once, at the owning sound instance's instantiation boundary" (14.4). 14.12: "`min` resolving to a value greater than or equal to `max` is `ERR_INVALID_RANGE_ORDER`." Section 7 lists `ERR_INVALID_RANGE_ORDER` with stage **Semantic** and cause "Declared paired bounds (e.g. `sample-hold` `min`/`max`) are not in strictly increasing order after resolution." + +**Why it is wrong:** This is the same defect class that has already occurred twice in this document (the 14.5 `audioMaxFrequency` semantic-stage issue, and the automation-ordering rule fixed in `50fb72c`), and it is still present here. For literal bounds the check is import-decidable, but for procedural bounds the order exists only "after resolution," which happens at instantiation — no `AudioContext` and no instance exist at semantic validation, so a pair such as `min: {random:{-1,1}}, max: {random:{-1,1}}` that resolves inverted is undetectable at import, and the instantiation stage may not raise a code the section 7 table files as Semantic-only. + +**Smallest fix:** File `ERR_INVALID_RANGE_ORDER` as "Semantic / Runtime" in section 7 (as `ERR_OUT_OF_BOUNDS` already is), with the cause text stating literals are rejected at import and resolved pairs at instantiation. + +--- + +## F5 — 15.14 classifies the automation track/point limits as runtime ceilings, contradicting 16.1's import-time semantic enforcement + +**Sections:** 15.14 (line 1132), 16.1 (line 1318), §7 (line 285), 16.11 trace 6 (line 1483). + +**What the document says:** 15.14: "The remaining PRD 58 limits — approximate one-shot voices (`64`), approximate continuous sounds (`16`), automation tracks (`64`), and automation points (`256`) — are runtime ceilings rather than document properties and belong to Phase 3c with the lifecycle contract." 16.1: "At most `64` automation tracks and `256` total automation points per expanded sound (PRD 58). Exceeding either is `ERR_NODE_LIMIT_EXCEEDED`" — a Semantic-stage code — and 16.11 trace 6 requires `65` tracks / `257` points to be rejected (at import, like the other trace-6 checks). + +**Why it is wrong:** Track and point counts are fully decidable at import after deterministic component expansion; unlike voice ceilings they depend on nothing device- or runtime-specific. 15.14's "runtime ceilings rather than document properties" therefore contradicts 16.1, §7's Semantic filing, and trace 6, and leaves the two normative statements irreconcilable for an implementer deciding where a 65-track document fails. (The same stale sentence also mislabels only the track/point limits — the voice ceilings genuinely are runtime ceilings.) + +**Smallest fix:** In 15.14, delete "automation tracks (`64`), and automation points (`256`)" from the runtime-ceilings sentence and add the two rows to the 15.14 authoring-time limits table (or add a pointer saying they are enforced as `ERR_NODE_LIMIT_EXCEEDED` per 16.1). + +--- + +## F6 — Automation point `value` sampling has no documented time, stream, or key + +**Sections:** 16.1 (lines 1289, 1297), 14.4 (line 629), 14.6 (lines 652–658), 9.3 (line 457). + +**What the document says:** Each point is `{ "at": , "value": ValueSpec }`, and "A track's *values* remain full ValueSpecs and may be random; it is only the curve's shape in time that is authored, not sampled." + +**Why it is wrong:** `random`/`choose` ValueSpecs consume a seeded stream (9.3), and the document carefully fixes the sampling boundary for every other audio consumer: node fields resolve once at the sound instance's instantiation boundary in depth-first, property-document order (14.4), route `depth` resolves at the same boundary in route order (15.12), and `sample-hold` draws are documented with a child key on its own stream (14.12, and 9.3's "documented child key" requirement). Automation point values are none of these: a track lives on the graph object, its point values are not node fields, and 16.1 says nothing about when they are sampled or from which stream/key, nor where the `automation` array sits in the 14.4 sampling order. An implementer cannot reproduce a run that uses random automation point values, and 16.8's promise that a partially elapsed continuous sound keeps "a procedural sequence identical to an unbroken run" has no defined meaning for automated tracks. This is exactly the "value consuming a seeded random stream without a documented derivation key" class. + +**Smallest fix:** One sentence in 16.1 stating that point `value` ValueSpecs resolve once at the owning sound instance's instantiation boundary, from that instance's own stream, sampled in depth-first, property-document order together with the graph's node fields and route depths (or on a documented per-track child key like 14.12's). + +--- + +## F7 — 16.5's resonator contribution is undefined for a legal graph whose modes are all omitted at instantiation + +**Sections:** 16.5 (line 1387), 15.10 (lines 1020, 1024), 14.5 (lines 645–646). + +**What the document says:** 16.5: a `resonator` contributes "the longest `decay` among its retained modes." 15.10: mode `frequency` may be authored up to `audioMaxFrequency`, and "A mode whose resolved frequency exceeds `audioMaxFrequency` at instantiation is omitted" with no diagnostic. 14.5: semantic checks use the device-independent ceiling `24000`; the device ceiling is `min(24000, sampleRate x 0.45)`. + +**Why it is wrong:** On a 44.1 kHz device `audioMaxFrequency` is `19845` Hz; on 48 kHz it is `21600` Hz. A mode frequency in that gap (e.g. `20000` Hz or a `ratio x fundamental` product above the device ceiling) is import-legal but omitted at instantiation. A single-mode resonator then has zero retained modes, and "the longest decay among its retained modes" is undefined — so the one-shot ending bound, which must be "computable … from the resolved graph alone," has no defined value for a legal document on a legal device. (The node's output in that case is silence; only the bound row is left undefined.) + +**Smallest fix:** Extend the row: "the longest `decay` among its retained modes, or zero when no mode is retained." + +--- + +## F8 — 14.9's impulse-envelope table contradicts its own formulas and its own prose + +**Section:** 14.9 (lines 741–749). + +**What the document says:** The table lists, for each `decay` mode, the envelope and a column "Value at `p = 1`": + +| `decay` | Envelope | Value at `p = 1` | +| --- | --- | --- | +| `flat` | `a` | `0` | +| `linear` | `a x (1 - p)` | `0` | +| `exponential` | `a x e^(-6.907755 x p)` (`-60` dB at `p = 1`) | `0` | + +The prose immediately below reads: "Because `flat` and `exponential` do not reach zero on their own, the runtime applies a terminal linear fade to zero over the final `min(1ms, d x 0.1)` of the burst." + +**Why it is wrong:** For `flat` the envelope is the constant `a`, so its value at `p = 1` is `a`, not `0`. For `exponential`, `a x e^(-6.907755)` is `a x 0.001` — as the row's own "`-60` dB at `p = 1`" annotation confirms — not `0`. Only `linear` reaches `0` at `p = 1`. The column contradicts both the envelope formulas in the same table and the sentence that follows it (which exists precisely because those two envelopes do not reach zero). Note also that `p = 1` is outside the stated domain `0 <= p < 1` of the envelope, so the column conflates the raw envelope with the post-fade value. + +**Smallest fix:** Correct the two cells (e.g. `flat` → `a`; `exponential` → `a x e^(-6.907755)`), or delete the column entirely and keep the fade sentence as the definition at the burst end. + +--- + +## Defect classes with no findings + +- **Prose contradicting a JSON example in the same section:** none found. Every JSON example in 14–16 was checked against its field table and the section's rules and agrees with them (the 14.9 issue above is a table/formula/prose self-contradiction, not a JSON-example conflict). +- **Field introduced in one section but missing from its container's allowed-field table:** none found. `automation` is in the 14.3 graph-object table; `mode`/`release` are in the 15.16 recipe table; `parameters`/`input` are in the 15.15 component table; the automation track fields (`target`, `mode`, `interpolation`, `points`) are all in the 16.1 table. +- **Cross-references to sections that do not exist:** none found. All section references used in 14–16 (1.3, 4, 6.1/6.2, 7, 8.1/8.2, 9.3, 14.5, 15.13–15.17, 16.4, 16.8, and the "Phase 3c" deferrals) resolve to existing sections. +- **Diagnostic code used but absent from the section 7 table:** none found. Every code used in 14–16 appears in §7, and the 15.18 and 16.10 tables duplicate §7 consistently. Wrong-stage filing is covered by F4 and F5. + +## Findings deliberately not reported + +- The provisional master-protection peak ceiling, tolerance, and release behavior (16.7), the GC4 audio long-stall bound (16.9), noise/impulse non-reproducibility (14.6), sample-hold's child-key documentation (14.12), and the `audio.master` absence (15.17) are all flagged in the text as deliberate and are consistent with the stated design decisions. +- The `ERR_TYPE_MISMATCH` codes on out-of-set enum literals (e.g. 14.8 `color: "grey"`, 15.3 `mode: "comb"`) are internally consistent across the audio sections and defensible under section 2's definition of `enum` ("string constrained to an explicitly declared set of allowed tokens"), so no contradiction with §7 was established. diff --git a/reviews/.completed-artifacts/04-fix-log.md b/reviews/.completed-artifacts/04-fix-log.md new file mode 100644 index 0000000..f344a1b --- /dev/null +++ b/reviews/.completed-artifacts/04-fix-log.md @@ -0,0 +1,64 @@ +# XZBT 0.1 triage — fix log (what was applied, what remains) + +Record of edits to `docs/XZBT_0-1_Format_Specification.md` (1499-line version, working +tree = commit `e5ed468`; spec file itself unmodified in git status at time of writing). +Consolidates the 13 defects of `00-triage.md`. Scope constraint honored: **nothing that +overlaps the in-flight Phase 3c slice-2 implementation** (16.3 lifecycle, 16.4 release, +16.5 endings, 16.6 eviction, disposal, traces 16.11.7/9/10/11, or the runtime/schema/test +files that slice has open) was touched. Doc-text edits only; no code, schema, or test +changes. + +--- + +## Applied — 7 of 13 defects + +| T | Location(s) | Change | Verification | +| --- | --- | --- | --- | +| T4 | §7 row `ERR_INVALID_RANGE_ORDER`; 14.12 | Refiled `Semantic / Runtime` with a cause covering both uses: literal paired bounds at import, resolved paired bounds at node instantiation, automation `at` ordering at import. 14.12 now states the literal/resolved split explicitly. 14.12's invalid-case example (`min: 1, max: -1`) remains an import case, consistent. | Code grounding: `audio-graph.js:186` emits the code only when both bounds are literal numbers, i.e. import stage. | +| T5 | 16.1 "Points." | Added: "A track's point values are resolved once at the owning sound instance's instantiation boundary, sampled from that instance's stream in depth-first, property-document order, with `automation` taken after `nodes` and `routes`." Wording matches 14.4's and 9.3's own vocabulary; `automation` follows `nodes`/`routes` per the 14.3 graph-object field table. | None needed (forward-looking; automation is slice 3c-3). | +| T6 | 15.14 (limits paragraph); §7 row `ERR_NODE_LIMIT_EXCEEDED` | Automation tracks/points removed from the runtime-ceiling enumeration (voice ceilings remain, with a 16.6 cross-reference) and pointed to 16.1's semantic enforcement instead. §7 cause extended: "...or automation tracks or points per expanded sound". | 16.10 and 15.18 tables do not restate either amended row; no drift. | +| T10 | 16.2 | Clause added to the node-property paragraph: `override` actions on node fields are barred exactly like `BindingSpec` (14.4), so the `winning override` stage of the pipeline is always absent for node properties — "absent, not an identity hook (8.1)". | — | +| T11 | 14.9 impulse envelope table | Column relabelled "Value at `p = 1`" → "Value as `p → 1⁻`" with corrected cells `a`, `0`, `a x 0.001` (exponential no longer self-contradicts its `-60 dB` annotation). | Rows checked against the piecewise definition (`0 <= p < 1`, exactly `0` for `p >= 1`). | +| T12 | 15.16 sounds metadata | Sentence added: name > 128 chars, > 16 tags, or a tag > 32 chars is `ERR_SCHEMA_VALIDATION`. | Grounded: `schema/xzbt-0.1.schema.json` enforces `name` maxLength 128, `tags` maxItems 16, item maxLength 32 (Sound definition). | +| T13 | 16.2 | Mis-citation fixed: "parameter-masking rule of section 8.1" → masked-override rules of **8.3** (underlying stages keep evaluating while masked) and **8.4** (release resolves against the current lower value recomputed without the releasing override). | 8.3 line 370 and 8.4 `currentLowerValue` text verified as the actual referents. | + +Net diff: 12 insertions / 10 deletions in 9 regions. Nothing else in the file changed. + +## Not applied — 6 of 13 defects, with reasons + +| T | Reason | +| --- | --- | +| T1 (bus-gain automation/modulation surface; 8.1/15.17/16.1/16.2/16.11.8) | **(design)** — the triage's own edit list marks it decision-required before an edit can be written. Recommended option (b): give `audio.buses..gain` an authoring surface and name it in 16.1's target rule. Not in Gemini's slice (automation is 3c-3), so it can be picked up independently, but needs a decision first. | +| T2 (delete `component` modulation-target row; 15.13/15.11/15.19.4) | Blocked on coordination, not on Gemini's slice per se: the registry row is mirrored in `audio-contract.js`, asserted by a green Phase 3b test (`test/phase3-audio.test.mjs:313`, trace 15.19.4), and 15.19.4 is an *accepted* contract trace. Deleting spec text alone would leave doc contradicting code+test. The runtime and test files are currently open by the slice-2 work; the delete (plus trace amendment and test/registry update) must land as one coordinated change, or be consciously deferred as a contract decision. | +| T3 (16.6 eviction step 3 frees no slot) | **Gemini's slice** — 16.6 and trace 16.11.10 are being implemented and tested right now. A spec change here would invalidate the in-flight suite and evidence record. After slice 2 lands, reconcile: Gemini's `audio-engine.js` has already made *some* choice for step 3; check it against the triage's two options (recommended: evicted `ACTIVE` one-shot leaves the ceiling count at the moment of eviction, release running outside the budget, with the matching exception added to the 16.6 counting rule). | +| T7 (16.5 resonator bound over empty retained-mode set) | **Gemini's slice** — 16.5 ending computation and trace 16.11.9. One-sentence fix ("...or zero when no mode is retained") ready to apply after slice 2 lands; check against Gemini's implementation choice for single-mode resonators on a 44.1 kHz device. | +| T8 (16.5 delay bound under modulation) | **Gemini's slice** + **(design)** + blocked on T9. Alternative fix (exclude modulated bound-contributors from determinable-ending status via `ERR_INDETERMINATE_ONESHOT`) touches 16.5 semantics the slice is implementing. | +| T9 (oscillator modulation output magnitude undefined; 15.13) | **(design)** — stating a range in the spec would be a behavioral contract change for accepted Phase 3b modulation code (custom-partial normalization) that no implementation performs; the registry and summation code live in files the slice has open. Needs a decision, then coordinated spec + contract-registry change. | +| T14 (exposed parameter without `min`/`max` has no clamp range) | Conditional on T2 — moot unless T2's option two (live parameter tracking) is chosen. Remains closed while T2 is open. | + +## Follow-ups recorded, not actioned + +- **T4 implementation gap (pre-existing):** no instantiation-time range-order check exists. + `audio-graph.js:186` is the only enforcement site and it guards on both bounds being + literal numbers (`typeof low === 'number' && typeof high === 'number'`). A document with + resolved (e.g. random) `sample-hold` bounds that invert at resolution is import-legal and + currently unchecked at instantiation. The spec now says the check happens there; the + runtime follow-up belongs to the phase-3a code owner (files open by Gemini's slice). +- **§8 out-of-scope finding from the triage:** §8.5 trace 6 and §8.3's masked-automation + sentence are already unimplementable for want of a target exposing both automation and + override (T1's root cause, independent of §§14–16). Still to be logged against §8; T1 + must not be closed without addressing it. +- **14.3 canonical example** (`oscillator → gain → output`) is not a legal `oneshot` recipe + as-is (`ERR_INDETERMINATE_ONESHOT`); noted as editorial, not a defect. Unchanged. +- **14.7 all-partials-omitted oscillator** deserves one clarifying sentence (Kimi #5 / + DeepSeek F7 downgrade in the triage); not a Tier-2 defect. Not actioned. + +## Housekeeping + +- Edits are **uncommitted** in the working tree (12+/10−, 9 regions, spec only). Recommended: + land as a dedicated `docs(audio): ...` commit before Gemini's slice-2 commit, per the + project's contract-before-implementation convention, so the two changes never mix. +- Verified: no stale "Value at `p = 1`" header remains; §7 rows appear once each; 15.18 and + 16.10 tables unaffected; all new cross-references (15.14 → 16.6/16.1; 16.2 → 8.1/8.3/8.4; + 14.12 → 14.4) resolve. No test suite was run — none is affected by doc-text edits, and the + slice-2 suite was in flight. diff --git a/reviews/.completed-artifacts/05-visual-fix-log.md b/reviews/.completed-artifacts/05-visual-fix-log.md new file mode 100644 index 0000000..a381aad --- /dev/null +++ b/reviews/.completed-artifacts/05-visual-fix-log.md @@ -0,0 +1,110 @@ +# XZBT 0.1 §§17–19 visual triage — fix log (what was applied, and the design chosen for each) + +Record of edits to `docs/XZBT_0-1_Format_Specification.md` closing the thirty repair +packages (V1–V30) and three additional defects (A1–A3) of +[01-triage.md](01-triage.md), in the batch order that document's closure table sets. +Follows the [04-fix-log.md](04-fix-log.md) pattern for the §§14–16 audio pass. + +**Standing decision for this pass:** the user directed that the triage's own *Recommended +fix* be adopted for every Tier-1/2/3 design decision and logged rather than reviewed +individually. Every row below therefore records a **chosen design**, not a deferral. Where +the triage offered a recommendation, it was taken; where it named a decision without a +recommendation, the choice made is stated with its reason. + +- Baseline: working tree at commit `527220e`, spec revision **0.7**. +- Result: spec revision **0.8**. Doc-only — no schema, runtime, fixture, or test file was + touched by this pass. `npm test` unchanged at **102 passing, zero failures**. +- Net diff: **704 insertions / 88 deletions**, one file. +- New tool: `tools/verify-spec-contract.py`, the contract harness previously run ad hoc, + now committed. Post-edit run: 126 headings, 46 declared diagnostic codes with 43 used and + 3 declared-only, **107 distinct cross-references all resolving, zero unresolved**, 170 + balanced fence markers, 112 well-formed tables. + +Completion state of every package below is **spec fixed**. None is *implementation +verified*: no visual runtime exists yet, and no trace in §§17.16, 18.10, or 19.7 has run. + +--- + +## Batch 1 — contradictory schema surfaces and impossible traces + +| Package | Chosen design | Sections edited | +| --- | --- | --- | +| **V1** | `automation` added to the §17.3 allowed-field table as an optional array defaulting to `[]`, with a note that the system-scoped array is a field of the system and not of this container. The two scopes and their time origins are untouched; no capability row was added. | 17.3 | +| **V2** | Trace 17.16.14 rewritten around legal *and* illegal capabilities per target family, keeping the per-object rejection case and adding the type-mismatch and undeclared-reference cases. The stale "the table is unchanged by this slice" assertion is deleted, and the trace now says explicitly that accepting a target is not evidence its pipeline runs — that is trace 7 of §19.7. | 17.16.14 | +| **V5** | `fill` and `stroke` retyped `ValueSpec`, paint object, or `null`. A new **Color leaves and ValueSpecs** block names the exact set that accepts a ValueSpec — `fill`, `stroke`, `glow.color`, `shadow.color`, and paint `stops[].color` — and fixes three limits: a `ValueSpec` never returns a paint object (`ERR_TYPE_MISMATCH`), a paint's structural fields stay literal, and system-level colors (`scene.background`, `depthFog.color`, effect `color`) stay literal. Once-at-instantiation sampling preserved. | 17.12 | +| **V11** | The `step` registry clause is deleted, not rescued. A repeater now has **no** automatable property, with the reason stated (copies are created once at instantiation, so no later change can reach them) and the `ERR_UNSUPPORTED_TARGET` / `ERR_INVALID_REFERENCE` distinction spelled out. No repeater layout language was invented. | 19.1 | +| **V12** | One rule replaces "only on a persistent scope": `infinite` is legal in **every** scope, bounded by the lifetime of whatever owns the track. A four-row table covers exhibit scope, persistent, finite-lifetime spawned, and indefinite spawned. No diagnostic; `ping-pong`'s round-trip count unit untouched. | 19.1 | +| **V17** | Two conditional rules. *Viewport fit:* absent `fit` is accepted with no mapping effect and the `contain` default is not taken; only an explicitly authored non-`stretch` value is `ERR_SCHEMA_VALIDATION`. *Layer omission:* when `visuals.layers` is present a system's `layer` is required and an omission is `ERR_INVALID_REFERENCE` — no silent first-layer fallback — and a present-but-empty map is `ERR_SCHEMA_VALIDATION`. | 17.4, 17.5, 17.7 | +| **V26** | Trace 17.16.9's nonexistent "`9` layer nesting levels" replaced by "`17` layers", with a note that group depth is trace 4's case. | 17.16.9 | +| **A1** | **The design decision of this batch.** All five spawned-instance lifecycle fields move into a dedicated **`spawn` object** on the system: `spawn.lifetime`, `spawn.release`, `spawn.ownership`, `spawn.inputs`, `spawn.cancelWithScenario`. A top-level `lifetime` is unambiguously *per item* (particles, emitter) and `spawn.lifetime` is *per instance*, so a spawned emitter expresses both, and a spawned repeater takes `spawn.lifetime` without contradicting §18.5's ban on a repeater `lifetime`. A template's own `inputs.*` reference scope is stated and kept disjoint from a component's. Patching only the persistent-system prohibition — which would have left the spawned ambiguity intact — was rejected as the triage instructs. | 17.7, 19.2, 19.7.8 | +| **A2** | `direction` declared on the noise field: a number in degrees, **required** under `mode: "value"` and `ERR_UNKNOWN_FIELD` under `curl`/`gradient`. The conditional `trail` on a particle's `render` object is **removed** rather than declared: one placement (the system's `trail`), no precedence rule, `ERR_UNKNOWN_FIELD` elsewhere. | 18.7, 18.8 | + +## Batch 2 — renderer geometry and composition + +| Package | Chosen design | Sections edited | +| --- | --- | --- | +| **V3** | A full named-space composition replaces the ambiguous matrix prose. Four spaces (local, scene, CSS, device) are named; the fit matrix `F = T(ox, oy) × S(sx, sy)` carries the centered `contain`/`cover` offsets that were missing; the camera center is mapped through `F` before the translation is formed (`q = F(cameraCenter)`, translation `q − c`), which is the unit mismatch the review found; the normative chain is `x_device = B × P × V × F × M_effective × x_local` with DPR applied once by `B`; perspective is a stage `P` about the projection center, not a vague post-multiplication. Nonuniform `stretch` is stated to produce parallelograms from rotated squares, and axis-less appearance scalars take the single uniform factor `g = zoom · k · sqrt(sx · sy)`. Perspective **does** scale stroke, blur, glow, and shadow. | 17.2, 17.6, 19.3 | +| **V4** | Local extents and anchors fixed for all fourteen primitives, with requiredness and defaults given rather than inferred (`rectangle` anchors **top-left**, circular primitives **center**, `ring` angles default `0`/`360`). Arc sweep is directed: `|d| > 360` is `ERR_OUT_OF_BOUNDS` **before** normalization, `|d| == 360` is a full turn, `d == 0` draws nothing. `catmull-rom` is a **uniform cardinal spline** with the cubic Hermite equation and the tension form `m = tension · (p_next − p_prev)` written out, plus the open-duplication and closed-wrap index rules. A `closed` `bezier` spline closes with a **straight line**. Open-subpath fill closure, `point` stroke behavior, and `maxWidth` condensation (`maxWidth / w` about the align anchor) are all fixed; the text-metric device dependence is argued to need no new reproducibility exemption. | 17.9, 17.13 | +| **V9** | Sortable units defined for every system, not just particles. A `graphic` system's top-level objects sort individually; each `particles`, `emitter`, and `repeater` is **atomic**, with representative depth = the **lowest** `zEffective` among its live items (generalizing §18.2's existing rule rather than replacing it) and internal order by depth then creation ordinal. Cross-unit ties, empty systems, and links all covered. Per-point `z` is given exactly one effect — per-point perspective under `perspective` — while sorting and fog use the object's own `zEffective`, which keeps the one-object fog rule intact. | 17.6 | +| **V10** | An eight-stage normative per-object compositing order plus a layer stage. The effective alpha is own opacity × ancestor opacities × **release factor**, and layer `opacity` is explicitly **not** in it (applied once at the layer stage), removing the §17.5/§17.12 double-application. Buffer accounting covers layer buffers and object buffers in one 16-slot pool, with reuse (concurrent, not cumulative), allocation order (layers first, then draw order), refusal semantics per feature, and farthest-first shedding. Buffer refusal is named a **diagnosed exception** to §17.1's appearance guarantee. `WARN_VISUAL_APPROXIMATION` is reused, not re-minted. | 17.5, 17.12 | +| **V18** | Fog arithmetic fixed: per-component blend in **non-premultiplied sRGB**, no linearization, alpha never fogged, fog color's alpha ignored, and **gradient stops fogged individually before the paint is constructed**. Conic fallback axis fixed against the object's local-space AABB, with explicit rules for a center outside the box, a non-intersecting ray, and a degenerate box; stops and offsets preserved exactly, against the reviews' suggestion. | 17.6, 17.12 | +| **V20** | `focalLength` range closed at **`1` to `100000`** scene units. Literals outside are `ERR_OUT_OF_BOUNDS` at import; resolved values from `random`, binding, automation, override, or modulation clamp into it as the §8.1 safety-clamp stage, with no implementation epsilon anywhere. | 19.3 | +| **V29** | `size` is a **uniform local geometry scale** on the `render` object, applied innermost, with a per-render-type mapping table (including `pointSize`, ellipse radius, text size, and components) and an explicit exclusion of appearance dimensions. Velocity alignment — `emitter.align`, `face-motion`, ribbon perpendicular — uses **XY velocity only**, `atan2(vy, vx)`, with a stated zero-XY-speed rule (hold previous rotation; authored rotation at creation). | 18.2 | +| **A3** | The `4096 × 4096` backing store wins over the "floor of `1`" multiplier: on a CSS display larger than 4096 the multiplier goes **below 1** and the frame is upsampled, raising `WARN_VISUAL_APPROXIMATION` once per resolution change; the multiplier is never raised above `min(devicePixelRatio, 2)`. "Pinned to the display" becomes **"pinned against camera translation"**, with zoom and rotation explicitly still applying at `parallax: 0`. | 19.3, 19.5 | + +## Batch 3 — procedural evaluation + +| Package | Chosen design | Sections edited | +| --- | --- | --- | +| **V6** | A three-class field-ownership table replaces "every ValueSpec above resolves once per particle": **system-instantiation** values (including `count`, `rate`, and burst `count`, so creation counts are known before allocation), **system channels** (`position`, `acceleration`, `drag`, `visible`), and **per-item** values. A second table fixes what a live channel does to existing versus future items — `rate` and `position` reach only new items, `acceleration` and `drag` reach every live item on the next tick — and states that a track composes against the sampled base under its `mode`. Burst-before-continuous creation order and ordinal assignment fixed. Applied to §18.4 with `emit` substituted. | 17.14, 18.2, 18.4 | +| **V8** | Coherent noise written out as an algorithm: the twelve gradients in fixed order and unnormalized; the Fisher–Yates permutation with an explicit **255-sample** draw count (correcting "one sample per entry"); the full lattice hash and trilinear/quintic evaluation; `h mod 12` selection defended as deterministic rather than unbiased; octave normalization by accumulated amplitude; sample coordinates; and the three `mode` vector constructions, with **`curl` as the explicit perpendicular of the scalar potential's gradient** by central difference at `h = 1e-3`. Published tolerances: `1e-9` for scalar oracles, `1e-4 · amplitude / scale` for curl divergence, tested at non-lattice as well as lattice points. Behavior noise gets **one shared permutation table per exhibit** plus a per-behavior **offset pair** (2 samples, not 1), with per-behavior coordinate formulas — the shared table chosen so that a large particle system does not derive 255 samples per item. | 18.6, 18.7, 18.10.15, 18.10.16 | +| **V13** | `n = 1` placement uses fraction `0`, matching `repeat.fraction`. Even ring/path/line/grid placements, even-mode ring radius (mid-annulus), grid wrap when `n ≠ rows × columns`, the three `depth` inverse functions, ellipse perimeter (uniform in parameter, one sample, stated as the choice), rectangle perimeter (**one** sample, correcting the two-sample assertion), and a normative path arc-length flattening tolerance of `0.1` scene units are all written out. "Field order" is replaced by an **explicit per-distribution draw list**, with declaration order distinguished from variate order and angle-before-radius kept. The `grid`-with-jitter and `depth` sub-block sample costs are reconciled with trace 18.10.10, which is rewritten. Burst creation-index rule fixed. | 18.3, 18.10.10 | +| **V14** | A complete field contract table for all seventeen behaviors (type, requiredness, default, range). Waveforms given as equations in cycles with a common phase origin and all four zero-crossing at `phi = 0` where they can be. `pulse` rise/fall envelope and `face-motion` frame-rate-independent smoothing (`1 − smoothing^dt`) fixed. An **accumulating versus fresh** table prevents an orbit being integrated as a per-tick displacement, with `orbit`'s contribution written out. A behavior **channel write-set** table supports conflict detection, and `velocity.*` and `points[*]` are noted as outside the automatable registry, so those behaviors can never conflict — which also disposes of the reviews' vector-track counterexample. `vortex` gets an explicit y-down perpendicular `(r.y, −r.x)` with the note that a force strength is not an angle. | 18.6, 18.7 | +| **V15** | Morph restricted to point-list geometry: `polyline`, `polygon`, `spline` (matching `mode`), and `path` only when every command is `move`/`line`/`close` **and** the two op sequences are identical. Everything else is `ERR_MORPH_INCOMPATIBLE`. Target points are read **live**, not snapshotted, with document-key evaluation order making it decidable and a mutual morph pair `ERR_CYCLIC_DEPENDENCY`. | 18.6, 18.10.14 | +| **V16** | The distribution `path`'s sibling-key shorthand is **removed** — a system's siblings are systems, so the container never existed — leaving inline `commands` under the full §17.13 contract. The object-attached `follow-path` sibling reference, which does have a container, is preserved explicitly. | 18.3 | +| **V21** | Link `style` defaults to the resolved style of the system's own `render`/`repeat` object, naming the exact source. `fadeWithDistance: true` without `maxDistance` is `ERR_SCHEMA_VALIDATION`, with the alternative normalization rejected and the reason given. `index`, `stride`, `closed`, and nearest tie-breaks operate on a **densely re-indexed live ordering**, so deaths close gaps. Nearest ties break by ascending creation ordinal. The `256` pairwise-population bound is stated as **intentionally conservative** (checked against the upper bound, not the live count) and enforced at import for literals and at the instantiation boundary for a procedural `repeater.count`. | 18.8, 18.10.18 | +| **V22** | One canonical component-input location: the **`component` object's own `inputs`** (§18.1), everywhere. The system-level `inputs` fields on `emitter` and `repeater` are removed and are now `ERR_UNKNOWN_FIELD`. Chosen over the reverse because it leaves both normative examples valid as written, keeps `component` uniform wherever it appears, and leaves `spawn.inputs` (A1) as the only other `inputs`, in a different container with a different meaning — so there is no precedence rule, no duplicate-key case, and no masked-sampling question. | 18.4, 18.5 | + +## Batch 4 — live resources and later execution + +| Package | Chosen design | Sections edited | +| --- | --- | --- | +| **V7** | Authored-record validation and live-instance accounting separated into two checks over two populations: an **import authoring bound** counting each declaration once, and a **live budget** counting instantiated records from `CREATED` to `DISPOSED`. A spawn crossing either is refused **atomically** — nothing allocated, no partial instance, **no spawn ordinal consumed**, `WARN_VISUAL_CEILING` under the §19.5 cadence, not a scenario failure. Admitting an instance with tracks silently dropped is explicitly forbidden. Reclamation at `DISPOSED`, not `FINISHED`; `FAILED` reclaims immediately. The §19.5 row is amended from "authoring bound, not a shed" accordingly. | 19.1, 19.5, 19.7.6 | +| **V19** | Radius conversion fixed as `r_device = r_scene · zoom · sqrt(sx · sy) · dpr_effective`, with the two terms an effect **cannot** have — object depth `k` and layer `parallax` — excluded explicitly and by name. Reduced-resolution processing multiplies by the resolution factor inside the buffer. Effect ValueSpecs, `enabled` included, resolve once at activation in array-then-field order. `saturation`/`hueRotate` mapped to the §17.12 `saturate`/`hue-rotate` operations without renaming the authored fields. Device-pixel scanline spacing left alone as a valid choice. | 19.4 | +| **V23** | `spawn.ownership` and `spawn.cancelWithScenario` are kept as **two different relationships** — resource ownership versus an independently retained originating-scenario relationship — which is what makes the flag non-redundant on a persistent-owned instance; the documented false case is preserved. Idempotent `remove`, the direct `CREATED → FINISHED` and `→ FAILED` paths, and post-`FAILED` accounting are stated in prose, not only in traces. The **release factor** is defined here (separate multiplier, initialized to `1`, monotonic, never writes an authored `opacity`), which disposes of the claimed opacity pop without adding a public system opacity property. | 19.2 | +| **V24** | One cadence for every resource diagnostic: keyed by (code, subject), **at most once per logical second**, with the 120-tick sustained report as the *same* code plus a `sustained` detail rather than a second code, not bypassing the rate limit, and both counters resetting after one clear tick. Approximation shedding uses the same cadence; the per-instance capability warnings of §17.12 and §19.4 are explicitly left alone. §19.2's bare "once" per refused spawn is reconciled to it. | 19.2, 19.5, 19.7.18 | +| **V25** | *Mechanical:* the central table gains path commands (512), text characters (256), `strokeDash` entries (8), burst entries (16), grid dimensions (256), custom oscillator partials (64), `nearest` links per item (8), pairwise-linked population (256), and visual automation points per track (256); the resonator row is fixed to `16` fixed in §15.10 and de-duplicated. *Design:* the static-draw-load gap is closed with two new authoring bounds — **`64` declared visual systems** and **`16384` expanded static visual objects** after component and repeater expansion, with the counting rule stated — both marked provisional pending 4h in exactly the sense the aggregate values are. | 17.3, 19.5 | + +## Batch 5 — contract synchronization + +| Package | Chosen design | Sections edited | +| --- | --- | --- | +| **V27** | A single staging table for resolved-value validation: non-integer resolved integers are `ERR_TYPE_MISMATCH` at the instantiation boundary with **no rounding rule** (§2 strictness preserved); over-length resolved `text` is `ERR_OUT_OF_BOUNDS`; out-of-range component input values are `ERR_OUT_OF_BOUNDS` at import or instantiation. The clamp-versus-reject split is explained by whether the value changes the *shape* or the *magnitude* of what is built. `ERR_INVALID_PRIMITIVE_TYPE`'s cause broadened to name the fourteen primitives plus `component` in both §7 and §17.15. Audio/visual diagnostic parity was **not** forced, and `ERR_UNSUPPORTED_TARGET` for unsupported persistent ownership was left alone, as the triage directs. | 7, 17.14, 17.15 | +| **V28** | §17.14 now describes a **sampled base** with the effective value produced by behaviors, automation, and the pipeline; its "no visual analogue" prose points at §19.4's grain exception as the one exemption; the `instances.*` citation is corrected to §8.1's action-addressing namespace rather than a §1.3 document namespace; §19.2's `lifecycle` row is marked a restatement of §17.7's. No new live boolean writer, seeded grain stream, or altered lifecycle enum. | 17.14, 19.2 | +| **V30** | A cross-slice contract map added to §19.7: what 4d, 4e, and 4f each must *implement* versus *carry as data without executing*. Trace 17.16.6's perspective requirement is explicitly kept in 4d — dropping it would have removed the only automated check on the composition chain where V3's defects lived — and the map ends with "a parsed stub is never a passed runtime trace". | 19.7 | + +--- + +## Findings the triage cleared, and that this pass did **not** act on + +All twenty-plus cleared findings in §"Findings checked and cleared" of `01-triage.md` were +left unimplemented, as instructed. In particular the locked conventions survive unchanged: +degrees with positive angles toward `+y`; increasing `z` farther from the camera with +greater-`z`-first sorting; the fixed `1000/60` ms tick; seeded decisions with no per-frame +sampling; the four-family §8.1 visual capability surface with no per-object row; the +non-evicting spawn policy; `ping-pong`'s round-trip count unit; links drawn before their +items; and the visual component namespace `components.visual`. + +## What is still open after this pass + +1. **No implementation exists.** Every package is *spec fixed*. Slice 4d has not started; + the JSON Schema still has a Phase 0 `visuals` stub, `validator.js` still has no + `validateVisualSubsystem()`, and `resolution.js` still rejects all four visual target + families. That is the Stage 0 code-alignment work of + [02-application-code-triage.md](02-application-code-triage.md), which this pass + deliberately did not touch so that the contract change lands as its own commit, per the + project's contract-before-implementation convention. +2. **Every §19.5 value remains provisional**, the two new static bounds included. No + evidence record may call any of them measured until trace 21 of §19.7 runs on real + hardware in slice 4h. +3. **Traces are not run.** §§17.16, 18.10, and 19.7 are amended contracts, not results. diff --git a/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214821-deepseekv4.md b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214821-deepseekv4.md new file mode 100644 index 0000000..9238099 --- /dev/null +++ b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214821-deepseekv4.md @@ -0,0 +1,486 @@ +# Visual contract review — Abacus AI Agent + +Independent adversarial read of XZBT Format Specification **rev 0.7**, sections **17–19**, before Phase 4 slice **4d** (renderer core). Existing files under `reviews/` were not opened. + +Standing decisions were treated as locked. This pass checks whether the spec text actually states them and whether it contradicts itself elsewhere. + +Severity: + +- **Blocker** — 4d cannot implement a unique, testable behavior without guessing. +- **Major** — two conforming 4d implementations can diverge, or a later slice will have to unwind 4d choices. +- **Minor** — underspecified edge, wrong diagnostic, or local inconsistency that 4d can paper over with a default. + +--- + +## Defects + +### 1. `visuals.automation` is both required and forbidden + +- **Severity:** Blocker +- **Location:** §17.3 *The `visuals` block* vs §19.1 *Two declaration scopes* +- **What's wrong:** §17.3 enumerates `scene`, `layers`, `systems`, `fields`, `camera`, `effects` and then: “any other property of `visuals` is `ERR_UNKNOWN_FIELD`.” §19.1 places an `automation` array on `visuals` itself (`visuals.automation`). Under the strict unknown-field policy that array is illegal at import. +- **Why it matters for 4d:** The renderer core will parse `visuals`. If it follows §17.3 it rejects every exhibit that uses the §19.1 example. If it follows §19.1 it violates the §17.3 table. Slice 4d must know the legal key set before it writes a schema or a loader. + +### 2. Trace 14 of §17.16 forbids the four §8.1 visual rows §19.1 adds + +- **Severity:** Blocker +- **Location:** §17.16 trace 14 vs §17.14 last paragraph vs §19.1 *The visual rows of the section 8.1 target-capability table* +- **What's wrong:** Trace 14: “A binding, `set`, or `override` addressing **any** visual property is `ERR_UNSUPPORTED_TARGET`, and the section 8.1 table is **unchanged by this slice**.” §19.1 then adds four families (`visuals.camera.`, `visuals.layers..opacity`, `visuals.systems..visible`, `visuals.effects[].`) that **are** binding/override targets. §17.14 already describes those four rows as if they exist. The acceptance gate for 4d therefore requires the opposite of the closed contract. +- **Why it matters for 4d:** 4d is the first code that will implement (or stub) target resolution. Implementing trace 14 literally means rejecting camera/layer/system/effect bindings that 4f traces 7 require to succeed. The 4d gate must be restated as “any visual property **outside the four §19.1 rows**.” + +### 3. Automatable registry names a repeater `step` block that does not exist + +- **Severity:** Blocker +- **Location:** §19.1 *The automatable registry* (“for `repeater`, the numeric fields of its `step` block”) vs §18.5 field table +- **What's wrong:** §18.5 has `repeat`, `count`, `distribution`, `position`, `inputs`, `behaviors`, `fields`, `links`. There is no `step` field, no `step` block, and no definition of what “numeric fields of its `step` block” are. Repeaters also have no `rate` (explicitly `ERR_UNKNOWN_FIELD`). +- **Why it matters for 4d:** Less for the first draw path than for the property-path resolver 4d will share with automation. A registry row with no corresponding schema is an unimplementable target. Either define `step` or delete the row. + +### 4. Y-down scene vs “positive angles toward +y” + +- **Severity:** Major +- **Location:** §17.2 *Canonical units* (“`0` points along `+x`; positive angles turn toward `+y`”) vs §17.4 (`x` and `y` run `0` to `1` **from the top-left**; virtual origin at top-left) +- **What's wrong:** Top-left origin with `y` increasing downward is a left-handed screen space. “Positive toward `+y`” is then **clockwise** on the display (standard canvas), not the mathematical CCW convention a reader of “toward +y” in a Cartesian plane will assume. The spec never says whether rotation is screen-clockwise or math-CCW, and never says whether `+y` in local object space is down. +- **Why it matters for 4d:** Transform order `R × K × S` is useless if the sign of `R` is ambiguous. Trace 3 of §17.16 (“a known local point maps to the documented device point”) cannot be written until this is fixed. Canvas 2D `rotate` is clockwise for y-down; a naive `cos/sin` toward +y with y-down will mirror every exhibit. + +### 5. Local origin of box primitives is unspecified + +- **Severity:** Major +- **Location:** §17.9 `rectangle`, `rounded-rectangle`, `ellipse`, `ring`, `arc`; §17.11 `origin` “in the object's own local space”; §17.13 text `align` “relative to the object's origin” +- **What's wrong:** For `rectangle` / `rounded-rectangle`, is `position` the top-left of `size`, the center, or something else? For `ellipse` / `ring` / `arc`, is `position` the center (implied by `radius` but never stated)? For `point`, is the dot centered on the origin? Text has `align`/`baseline`, which implies the origin is an anchor, but boxes have no equivalent. Default `origin` is `{0,0}`, so if a rectangle’s local space has its corner at 0, rotation is about the corner; if centered, about the center. These produce different pixels. +- **Why it matters for 4d:** First thing the primitive rasterizer needs. Two implementations will disagree on every rotated panel and every `contain`/`cover` fixture in trace 1/3. + +### 6. Perspective factor vs camera matrix: two post-multiplies, no combined formula + +- **Severity:** Major +- **Location:** §17.6 *Perspective scaling* (“object's transform is post-multiplied by `focalLength / (focalLength + z)` about the camera's projection center”) vs §19.3 (“applied **after** `V`”) +- **What's wrong:** §17.11 already post-multiplies `M_parent × M_local`. “Post-multiplied” in §17.6 does not say whether the factor is applied in local space, after `M_local`, after hierarchy, or after `V`. §19.3 says after `V`, about the **display** projection center. Applying a uniform scale about the display center to an already-view-transformed point is not the same as scaling the object about the camera look-at in scene space. `z` used in the factor is never named as **effective** `z` (`z_parent + z_local` from §17.11), though that is the only reading that matches hierarchy. +- **Why it matters for 4d:** Trace 6 of §17.16 and trace 14 of §19.7 are the 4d/4f camera tests. Without a single matrix (or a worked numeric example: one point, one `z`, one `V`, one output), 4d will pick an order that 4f then has to break. + +### 7. `translate.z` is excluded from `M_local` but perspective needs a 3D point + +- **Severity:** Major +- **Location:** §17.11 “The `z` component of `translate` adds to the object's `z` and … is **not** part of `M_local`.” +- **What's wrong:** `M_local` is therefore a 2D affine matrix. Perspective uses `z`. Hierarchy adds `z`. Particle integration moves `p` in 3D. The spec never says whether parent `M` rotates/scales child `z` (it cannot, if `M` is 2D) or only adds it. A child with local `z` under a rotated group: is depth the scalar sum, or a transformed coordinate? §17.11 says sum, which means rotation never tilts depth — consistent with 2.5D, but then “perspective about projection center” on a 2D matrix is a uniform XY scale, not a projective transform. That should be stated as “uniform XY scale, no vanishing-point projection.” +- **Why it matters for 4d:** Implementers will reach for a 4×4 perspective matrix. The contract wants a 2D scale. If 4d ships a real projection, every later fixture fails. + +### 8. Depth sort unit is inconsistent (object vs system vs particle cloud) + +- **Severity:** Major +- **Location:** §17.6 “Within one layer, **objects** are drawn farthest first” vs §18.2 “Particles of one system draw as **one unit** … The system's own position in its layer's depth sort is its **lowest-`z` particle**” vs §17.8 document key order of `content` +- **What's wrong:** Graphic systems contain many objects with independent `z`. Are those objects sorted **across systems** in the layer, or is each system an atomic band like particles? §17.6 says objects; §18.2 special-cases particles so they never interleave with unrelated objects. Emitters create full objects — §18.4 never says whether those objects join the layer object-sort or stay an atomic emitter band. Repeaters: same gap. A graphic system with `z=0` group and a child at `z=100` vs a sibling system at `z=50` has two legal draw orders. +- **Why it matters for 4d:** Painter’s algorithm is the entire 2.5D model. 4d must pick a sort key. Wrong choice is a visual bug in every multi-system scene and is expensive to change. + +### 9. Layer `visible` is a ValueSpec but is not automatable, bindable, or in §8.1 — and still “advances” + +- **Severity:** Major +- **Location:** §17.5 `visible` ValueSpec\, default `true`; §19.1 “`visuals.layers..visible` is deliberately **not** in the table”; §17.14 “every such field is resolved **once**, at its owning object's instantiation boundary” +- **What's wrong:** If layer `visible` resolves once, a ValueSpec is pointless except `random`/`choose` at activation. If it is meant to change, there is no writer: not automation (boolean), not binding (absent from §8.1), not `set`/`override`. The field is therefore a once-at-import flag dressed as a ValueSpec. Meanwhile system `visible` **is** an §8.1 binding target and is explicitly **not** automatable. The two `visible` flags look alike in §17 and behave unlike in §19. +- **Why it matters for 4d:** 4d will implement layer skip. It needs to know: evaluate once at activation, or subscribe to the pipeline? Implementing a live binding for layer `visible` would violate the locked “deliberately absent” decision; treating it as constant makes the ValueSpec type a lie. + +### 10. §17.16 trace 9 cites “9 layer nesting levels” — layers do not nest + +- **Severity:** Major +- **Location:** §17.16 trace 9 vs §17.5 (layers composite in **document key order**, no parent field) vs §17.9 (group nesting 8) +- **What's wrong:** Trace 9: “`9` layer nesting levels … are each `ERR_VISUAL_LIMIT_EXCEEDED`.” There is no layer nesting in §17.5. Group nesting is 8. Layer **count** is 16. The acceptance test for 4d is unrunnable as written. +- **Why it matters for 4d:** 4d’s exit gate includes a test that cannot be authored. Likely intent: group nesting 9 / 8, and separately 17 layers. + +### 11. Fit mapping is underspecified for `cover` crop and letterbox placement + +- **Severity:** Major +- **Location:** §17.4 `fit`; §17.16 trace 1 +- **What's wrong:** `contain` letterboxes “the remainder with `background`” but does not say the scene is **centered** in the display (vs aligned top-left). `cover` “cropping the overflow” does not say which region is kept (centered crop vs origin-aligned). `viewport` ignores `fit` except that non-`stretch` is an error — so the only legal `fit` under `viewport` is `stretch`, which is also ignored. Device-pixel-ratio (§19.5) is applied after fit; the spec never gives the composed scene→CSS→device transform. +- **Why it matters for 4d:** Trace 1 is the first 4d test and requires “expected display point” including letterbox offsets. Without centering/crop rules those points are not determined. + +### 12. Camera default “scene center” under `viewport` is not a scene quantity + +- **Severity:** Major +- **Location:** §19.3 “the display center for `viewport`” as default `x`,`y`; camera is in “scene units” +- **What's wrong:** Viewport scene units **are** CSS pixels of the display, origin top-left, and the scene has no intrinsic size. Defaulting the camera to “display center” makes the default camera depend on the window size. Two machines, same exhibit, different default view. That contradicts “an exhibit that never mentions the camera” looking identical (§19.3’s own rationale) and contradicts reproducibility across display sizes. +- **Why it matters for 4d:** Identity camera vs “centered on display” are different matrices as soon as the canvas is not the design size. 4d will bake this into every frame. + +### 13. Conic-gradient fallback axis is not a unique segment + +- **Severity:** Major +- **Location:** §17.12 *Conic gradient fallback* +- **What's wrong:** Fallback is “a `linear-gradient` with the same stops along the axis from `center` at `angle` to the paint's bounding-box edge.” A ray from `center` at `angle` intersects a bounding box at **one** point only if `center` is inside the box; if `center` is outside there may be two intersections or none. “Bounding-box edge” does not name which edge. Stop mapping from a 360° conic onto a 1D axis is lossy; the spec does not say whether offsets are used as-is (wrong) or the 0°–180° half is taken. +- **Why it matters for 4d:** This is the **only** appearance fallback 4d must implement, and trace 10 requires a documented linear gradient. Without a unique segment, the warning can fire and the pixels still differ. + +### 14. Fog blends “resolved fill, stroke, glow, and shadow colors” — gradients and alpha unspecified + +- **Severity:** Major +- **Location:** §17.6 *Depth fog* +- **What's wrong:** Fog fraction is exact. What is “blended toward `color`”? For a gradient, every stop? The rasterized samples? Glow/shadow have their own colors; is the fog color’s alpha used? Premultiplied? `density * clamp(...)` as a lerp in sRGB or linear? Particle `color` ramps already interpolate sRGB; fog does not say. Objects with `fill: null` still have glow/shadow. +- **Why it matters for 4d:** Trace 7 asks for blended colors at near/mid/far. Without a color space and a rule for paints, the trace has no oracle. + +### 15. Stroke-only primitives vs `point` fill; `style.fill` on `line` vs table + +- **Severity:** Minor +- **Location:** §17.9 `line` “`style.fill` on a `line` is `ERR_UNKNOWN_FIELD`” vs §17.12 *Stroke-only primitives* (`line`, `polyline`, `arc`, `bezier` — **not** `point`); `point` is “a filled dot” +- **What's wrong:** `point` uses fill, not stroke, but is not listed as fill-only (`style.stroke` on a point?). `polyline` is stroke-only in §17.9 notes but the stroke-only ERR list in §17.12 omits nothing it listed — OK — yet `path`/`spline` can be open and still accept fill. Open path with fill: legal? Canvas fills open subpaths by closing them; the spec is silent. +- **Why it matters for 4d:** Open-path fill is a classic renderer fork. + +### 16. Arc sweep, direction, and “magnitude exceeds 360” + +- **Severity:** Major +- **Location:** §17.9 `arc` / `ring`; §17.2 angles +- **What's wrong:** `startAngle`, `endAngle`, `direction` `clockwise` | `counter-clockwise`. Is the sweep the directed difference following `direction`, or the linear difference `end - start` with `direction` flipping the canvas arc flag? A sweep of 0: nothing, or a full ring (ring says “angles default to a full ring” — default values never given numerically)? `ERR_OUT_OF_BOUNDS` when magnitude exceeds 360: is that `|end-start|` or the directed sweep after wrapping? `end = start + 360` with clockwise: full circle or error? +- **Why it matters for 4d:** Arc/ring are in Primitive Set 0.1; 4d must emit a unique path. + +### 17. Path `arc` command vs SVG sweep/largeArc in y-down space + +- **Severity:** Minor +- **Location:** §17.13 path `op: arc` +- **What's wrong:** Endpoint-parameterized elliptical arc with `largeArc` and `sweep` booleans. SVG’s `sweep` is “positive angle” in y-down (clockwise). The visual contract’s positive angle is “toward +y.” If those disagree (defect 4), path arcs and `arc` primitives will rotate opposite ways. +- **Why it matters for 4d:** Path vs primitive inconsistency will show up in Exhibit geometry immediately. + +### 18. Spline Catmull-Rom: phantom points, closed form, and tension mapping + +- **Severity:** Major +- **Location:** §17.13 *Spline* +- **What's wrong:** Open spline: “first and last points are duplicated as the phantom endpoints.” That is one CR parameterization (uniform, centripetal?). `tension` `0` to `1` default `0.5` is not mapped to a formula (Kochanek–Bartels? `tau = 1 - tension`?). Closed spline: no phantoms specified; standard is wrap indices. `bezier` mode `count = 3n+1` is for **open** cubics; a `closed` bezier spline with that count cannot close smoothly without extra points — interaction of `closed` + `bezier` is undefined. +- **Why it matters for 4d:** Organic geometry and morph (equal point count) depend on a unique curve. 4d will pick a CR variant; 4e morph interpolation will bake it in. + +### 19. Text: `maxWidth` “condensed horizontally” without a scale rule; missing `font` on the 17.9 geometry row + +- **Severity:** Minor +- **Location:** §17.13 text; §17.9 `text` geometry fields omit `weight`, `italic`, `letterSpacing` that §17.13 adds +- **What's wrong:** Condensing to `maxWidth` — uniform scale of glyphs? Tracking only? What if the run is already shorter? Overflow of 256 characters after resolution: diagnostic? Generic CSS families: metrics differ per platform, so text layout is **not** reproducible even though 17.14 claims procedural identity. That may be acceptable (like grain) but is not exempted. +- **Why it matters for 4d:** 4d will measure text. Platform font metrics will fail any pixel fixture. The spec should either exempt text metrics from reproducibility or require a measurement rule (e.g. no pixel tests on text bounds). + +### 20. Graphic system has no system-level transform/position; camera and particles do + +- **Severity:** Minor +- **Location:** §17.7 / §17.8 vs §18.2 `position` on particles vs §19.3 camera +- **What's wrong:** A `graphic` system’s objects sit in scene space with no system origin. Particles add a system `position` offset to the distribution. Authors cannot move a whole graphic system without grouping. Not a contradiction, but 4d must not invent a system transform for `graphic`. +- **Why it matters for 4d:** Tempting to put a system matrix in the renderer. Spec does not allow it for `graphic`. + +### 21. `lifetime` on a visual object vs system lifecycle vs particle lifetime + +- **Severity:** Major +- **Location:** §17.10 object `lifetime`; §17.7 / §19.2 system lifecycle; §18.2 particle `lifetime` +- **What's wrong:** Object-level `lifetime` “removed from its container” has no state machine, no `release`, no ownership, no diagnostic, and is legal on persistent graphic content. Is that a 4d concern (remove from draw list on logical clock) or 4f? Removing a group’s child mid-run changes document key order used for equal-`z` ties. Spawned systems forbid lifecycle fields on persistent systems, but object `lifetime` remains legal on persistent content — a back door that looks like lifecycle. +- **Why it matters for 4d:** The draw list is 4d’s. If objects can disappear without 19.2, 4d needs a removal path now. + +### 22. Filters “after the object is drawn and before it composites into its layer” + +- **Severity:** Minor +- **Location:** §17.12 *Filters* vs *Cost* (offscreen buffer) +- **What's wrong:** CSS `filter` on a raster vs filter on vector before raster is different. Hue-rotate amount in degrees — range unset (unlike 0–4 / 0–1). Blend vs filter vs opacity vs layer opacity order: object opacity multiplies inherited and layer; blend requires offscreen; filters after draw. Glow/shadow vs filter order unset. `blur` style field vs `filters` vs post-effect `blur` — three blurs. +- **Why it matters for 4d:** Offscreen pass graph is 4d. Ambiguous order → different pixels and different pass counts vs §19.5. + +### 23. Clip inheritance and mask vs draw order + +- **Severity:** Minor +- **Location:** §17.12 clipping / masking +- **What's wrong:** Mask child “is not drawn; its rendered alpha multiplies the alpha of the group's remaining children.” Remaining children are still depth-sorted among themselves? Mask is rasterized untransformed? With the group’s transform? Does the mask include the mask child’s own style opacity? Clip intersected with ancestor — in which space after nested transforms? +- **Why it matters for 4d:** Mask/clip are in traces 12. Need a unique raster definition. + +### 24. Particle `size` ramp “multiplier on `render`'s own size” for `point` / `ellipse` / `component` + +- **Severity:** Minor +- **Location:** §18.2 `size` +- **What's wrong:** `point` has `pointSize`, not `size`. `ellipse` has `radius`. `component` has no size. Multiplying “size” is undefined for those. `rotation` at creation vs `angularVelocity` vs `align` on emitters. +- **Why it matters for 4d:** Particle drawing is likely in 4d even if emission is 4e; the instance transform must be defined. + +### 25. Particle / emitter motion in 3D vs 2D draw + +- **Severity:** Minor +- **Location:** §18.2 integrator (`p`, `v`, `a` have z) vs §17.11 2D `M_local` +- **What's wrong:** `p.z` updates; draw uses `z` for sort/fog/perspective scale only. Fine, but `align` “rotation … to its velocity direction” — 2D `atan2(vy,vx)` or 3D? Unspecified. +- **Why it matters for 4d:** Emitter `align` is a 4d rotation if 4d draws emitted objects. + +### 26. Index-driven `even` with `count === 1` and `i / (count - 1)` + +- **Severity:** Minor +- **Location:** §18.3 `line` mode `even`; §18.5 `repeat.fraction` already special-cases `count === 1` +- **What's wrong:** `i / (count - 1)` is division by zero when a burst or repeater has `count: 1`. Fraction documents the fix; distribution `even` does not. +- **Why it matters for 4d:** Only if 4d implements distributions; still a landmine for 4e that the contract should close now. + +### 27. Ring distribution: “not below `radius` is `ERR_INVALID_RANGE_ORDER`” + +- **Severity:** Minor +- **Location:** §18.3 `ring` vs §17.9 ring primitive (`innerRadius` **must be less than** `radius`) +- **What's wrong:** “not below `radius`” reads as `innerRadius >= radius` is the error, i.e. inner must be **below** radius — OK — but the wording is easy to invert. Primitive uses strict less-than; distribution does not say whether `innerRadius === radius` is a legal zero-width ring (perimeter) or an error. Primitive would error on equality. +- **Why it matters for 4d:** Shared geometry helpers. + +### 28. `grid` item count vs `repeater.count` / `columns * rows` + +- **Severity:** Major +- **Location:** §18.3 `grid`; §18.5 `count` +- **What's wrong:** Grid is `columns × rows` cells. Repeater has an independent `count` up to 1024. If `count != columns * rows`, extra items wrap? Truncate? Error? Unspecified. `jitter` consumes samples even though “grid without jitter consumes no samples” — with jitter, order of x/y jitter vs depth sub-block is only “field order of its row,” and `jitter` is not ordered relative to `depth`. +- **Why it matters for 4d/4e:** Placement fixtures (trace 10 of §18.10). + +### 29. Behaviors: `oscillate` formula `phase / 360` with frequency in Hz + +- **Severity:** Minor +- **Location:** §18.6 `oscillate` `center + amplitude * w(frequency * t + phase / 360)` +- **What's wrong:** If `w` expects cycles, `frequency * t` is cycles and `phase/360` is cycles — OK. If `w` is `sin(2π · …)` that must be stated. `square`/`sawtooth` polarity (does square start high?) unset. `orbit` does not say whether it **sets** position or **adds** to it; composition says accumulate on position, which implies orbit is an offset — then `center` is absolute or relative? +- **Why it matters for 4d:** 4d may stub behaviors; if it draws static poses only, less urgent. Still, `orbit` + `position` composition must be known before any motion lands. + +### 30. `follow-path` `path` as sibling key vs component encapsulation + +- **Severity:** Minor +- **Location:** §18.6 `follow-path`; §18.1 encapsulation +- **What's wrong:** Sibling key inside a component is OK; from outside, `ERR_INVALID_REFERENCE`. Cross-system path follow: not mentioned. `path` as inline `commands` on a behavior duplicates 17.13 without vertex limits. +- **Why it matters for 4d:** Low until 4e. + +### 31. Morph “equal type, point count, and spline mode” vs rectangles / text / groups + +- **Severity:** Minor +- **Location:** §18.6 *Morph compatibility* vs locked decision +- **What's wrong:** Locked decision matches the spline rule. Morph of two `rectangle`s (no points) — same type, point count 0=0, no spline mode: legal lerp of `size`? Morph of `group`s? Morph of `text`? “Point count” for primitives without points is undefined. `path` command morph: equal command count or equal endpoint count? +- **Why it matters for 4d:** Morph is 4e, but 4d may store geometry in a form that cannot lerp. + +### 32. Noise: 12 edge-midpoint gradients of a cube, 256 permutation, Fisher–Yates + +- **Severity:** Major (for 4e, but the algorithm is in-scope for this read) +- **Location:** §18.7 *Coherent noise is normative* +- **What's wrong:** Classic Perlin uses 12 **edge** vectors of a cube (the 12 midpoints of cube edges, yes). Permutation of 256 shuffled with one sample **per entry** — Fisher–Yates on 256 entries needs 256 samples if specified that way, but standard FY uses `i` from 255..1 with `j = floor(u * (i+1))`. The spec does not give the exact FY index formula or how a stream sample in `[0,1)` becomes `j`. Duplicate permutation (Perlin often repeats 0..255 twice to 512) unset. `curl` of a **scalar** 3D noise is zero if you take `∇ × (N, N, N)` or needs three offset noise fields; “curl of the noise potential” with one scalar is not a unique vector. This is the hardest reproducibility trap in §18. +- **Why it matters for 4d:** If 4d does not implement fields, still: do not invent a noise helper in 4d that 4e must match. Spec should give a tabulated sample (trace 15 of §18.10 promises “documented sample values” that are not in the spec). +- **Gap:** Trace 15 requires documented sample values; **the spec contains none**. That is a missing fixture, not just an algorithm gap. + +### 33. `visuals.fields` vs §17.3 — fields are listed; OK. Field IDs vs system `fields` array order + +- **Severity:** Minor +- **Location:** §18.7 +- **What's wrong:** Sum in system `fields` array order; addition commutative. Fine. `enabled` ValueSpec on a field — once at instantiation (17.14), so cannot be automated. Strength of a field is not in the automatable registry (locked). OK. + +### 34. Trail `interval` default “one logical tick” — tick length is a runtime, not an exhibit constant + +- **Severity:** Major +- **Location:** §18.8 `interval` default; §9.1 logical tick +- **What's wrong:** History sampled every tick by default means trail **shape depends on tick rate**, contradicting “a trail has the same shape at any **render** rate” (frame rate, not tick rate). Trace 17 of §18.10 only varies frame rate. If tick length differs across runtimes, trails diverge. Default should be a DurationSpec literal (e.g. the 9.1 tick) named explicitly. +- **Why it matters for 4d:** If 4d allocates trail buffers, stride is unknown. + +### 35. Links draw “before their system's items, at the system's own depth position” + +- **Severity:** Minor +- **Location:** §18.8 vs §18.2 lowest-`z` particle as system sort key +- **What's wrong:** Links at system depth vs particles sorted among themselves: links can draw in front of far particles of the same system. Maybe intended. `index` rule with `stride` on a changing particle pool — emitters cannot have links; particles can, and creation ordinals change under eviction. After oldest-first eviction, indices are not stable. Unspecified. +- **Why it matters for 4d:** Draw order inside a system. + +### 36. Automatable registry vs §8.1: emitter `rate` automatable but not bindable — registry allows it; graphic per-object properties automatable from **system** scope + +- **Severity:** Major +- **Location:** §19.1 registry “for a `graphic` system, any numeric `transform`, `style`, or geometry property of an object in its `content` tree” +- **What's wrong:** Locked decision: automatable registry is **broader** than §8.1 — confirmed. But §17.14 said visual object fields resolve **once** and are constant for the object’s lifetime, with time variation from behaviors, automation, and external control. Automation writing `content.band.style.opacity` is therefore an exception to “constant for lifetime.” 4d must keep those fields **mutable** even though 17.14 sounds like they are baked. Also: `style.fill` as color is non-numeric and excluded; `strokeWidth` is included. `visible` on an **object** is boolean — not automatable; system `visible` is bindable only. Object `visible` has **no** writer except a once-resolved ValueSpec — same trap as layer `visible`. +- **Why it matters for 4d:** 4d must not intern style as immutable GPU constants. Per-object opacity must remain a frame-time input for 4f automation. + +### 37. Cross-scope `target` strings: relative paths vs `camera.zoom` vs `visuals.camera.zoom` + +- **Severity:** Major +- **Location:** §19.1 example `"target": "camera.zoom"`; §8.1 rows `visuals.camera.`; §1.3 prefixes +- **What's wrong:** Example targets are scope-relative (`camera.zoom`). §8.1 uses absolute `visuals.camera.zoom`. Bindings presumably need the absolute form. Automation in system scope “addressed relative to the system” — is `rate` or `content.hull.transform.rotation` the form? No ABNF. `effects[]` vs `effects.0` — §1.3 says indexed form only. +- **Why it matters for 4d:** Path parser is shared. Wrong grammar → every 4f track fails. + +### 38. `loop` on visual tracks vs “audio track shape verbatim” + +- **Severity:** Minor +- **Location:** §19.1 vs locked decision +- **What's wrong:** Locked: audio shape **plus** `loop`. Text agrees. Ping-pong count = complete round trip: agrees. `infinite` loop on spawned system with finite lifetime is legal — OK. `infinite` “legal only on a persistent scope” in one sentence, then immediately allowed on spawned with finite lifetime — **contradiction in two consecutive sentences**. +- **Why it matters for 4d:** Validation of `loop.count`. + +### 39. `ERR_AUTOMATION_CONFLICT` for behavior + track on the same **object channel** + +- **Severity:** Minor +- **Location:** §19.1 vs §18.6 composition +- **What's wrong:** Locked decision matches. Unclear whether `oscillate` on `transform.scale.x` conflicts with a track on `transform.scale` (vector) or only `.x`. Unclear whether a system-level track on emitter `position.x` conflicts with a `drift` on each emitted item (different objects). +- **Why it matters for 4d:** Conflict detection may live next to the property store 4d creates. + +### 40. Lifecycle: `CREATED -> ACTIVE | FINISHED | FAILED` — when FINISHED from CREATED? + +- **Severity:** Minor +- **Location:** §19.2 state table +- **What's wrong:** No `SCHEDULED` (locked, OK). `CREATED -> FINISHED` is allowed but no cause (zero-lifetime spawn before first draw?). `release` default 0ms still one tick in `RELEASING` (locked, OK). Spawn at ceiling refused not evicted (locked, OK). `WARN_VISUAL_CEILING` once naming template — vs §19.5 “once per ceiling per second.” A refused spawn could warn every spawn or once per second; 19.2 says once (per event?), 19.5 says once per ceiling per second. +- **Why it matters for 4d:** 4d may not spawn yet, but the warning rate is a shared diagnostic policy. + +### 41. `cancelWithScenario: false` without persistent ownership is `ERR_UNSUPPORTED_TARGET` + +- **Severity:** Minor +- **Location:** §19.2 +- **What's wrong:** That is a schema/semantic conflict, not an unsupported **target**. `ERR_SCHEMA_VALIDATION` or `ERR_UNKNOWN_FIELD` would fit; `ERR_UNSUPPORTED_TARGET` is the 8.1 capability miss. Wrong code will be wired into tests. +- **Why it matters for 4d:** Low; 4f traces. + +### 42. Camera matrix `R(-rotation)` vs object `R(rotation)` + +- **Severity:** Major +- **Location:** §19.3 `V = T(c) × S(zoom) × R(-rotation) × T(-c) × T(-p·Δ)` +- **What's wrong:** Negating rotation is correct for a view matrix **if** object rotation uses the same convention. Combined with defect 4, the extra minus may double-correct. `T(-p * (x - c_x), …)` — camera `x,y` are scene-center defaults; `c` is **display** projection center after fit. Subtracting a scene coordinate from a display coordinate is a **unit/space mix** unless fit has already mapped them into one space. The formula never says whether `x,y` are converted to display pixels first. +- **Why it matters for 4d:** This is the **normative camera matrix** 4d must implement. As written it mixes scene units and display units. Trace 13 of §19.7 cannot have a unique documented display point. + +### 43. Parallax and perspective both claimed to affect “the layer” + +- **Severity:** Minor +- **Location:** §17.6 parallax on camera translation; §19.3 perspective **per object** after `V` +- **What's wrong:** Locked: parallax multiplies translation only — text agrees. Perspective is per-object `z`, so two objects on one layer at different `z` scale differently while sharing one `V`. A layer `parallax` does not change object `z`. OK if intended; worth stating that parallax is **not** a function of object `z` (only layer field). +- **Why it matters for 4d:** Do not implement depth-based parallax. + +### 44. Post-effects: `scanlines.spacing` in **device pixels** vs everything else in scene units + +- **Severity:** Major +- **Location:** §19.4 `scanlines.spacing`, `grain.scale` in device pixels; `blur.radius` scene units scaled by zoom +- **What's wrong:** Reproducibility: scanline period changes with DPR and window size. Grain is exempt; scanlines are **not**. Two machines at different DPR get different line density. `speed` of scanlines has range “—” (unlimited?). `color-adjust.saturation` vs filter type `saturate` naming. Disabled effect “costs nothing that frame” vs pass budget counted how when `enabled` is a ValueSpec resolved once — cannot toggle unless automated; `enabled` is boolean so **not** automatable. So `enabled` is another once-only ValueSpec that cannot be bound (not in §8.1 except `.` numeric). Cannot turn effects off at runtime despite the field. +- **Why it matters for 4d:** Effect chain is after the renderer; 4d may still allocate the composited frame. Scanlines/grain in device pixels must be a conscious 4d/4f choice or exhibits are resolution-dependent without a warning. + +### 45. `WARN_VISUAL_APPROXIMATION` used for three different events + +- **Severity:** Minor +- **Location:** §17.1 / §17.12 conic fallback; §19.4 reduced-res blur/bloom and skipped unavailable effect; §19.5 shedding offscreen buffers also raises `WARN_VISUAL_APPROXIMATION` (not `WARN_VISUAL_CEILING`) +- **What's wrong:** Buffer shedding is a ceiling event but uses the approximation warning. Authors cannot distinguish “GPU cannot conic” from “too many glow buffers.” Locked decision said reduced-res blur/bloom raises approximation once per effect instance — text agrees. Buffer shed using the same code is extra. +- **Why it matters for 4d:** 4d implements glow/blur buffers and must pick a diagnostic. + +### 46. §19.5 offscreen-buffer shed “farthest-`z` first” vs draw order farthest first + +- **Severity:** Minor +- **Location:** §19.5 compositing buffers +- **What's wrong:** Shed farthest-`z` first means distant fancy objects lose glow first (keep near effects). Opposite of painter priority. Fine if intended. “Draw the excess objects without their non-default blend/mask/blur/glow/filters” — dropping `mask` changes silhouette, which **is** a material substitution PRD 89 forbids, while the table claims neither kind of limit is silently transformed into something materially different. +- **Why it matters for 4d:** Pass budget enforcement. + +### 47. Authoring bound vs runtime ceiling: automation records listed in **both** tables + +- **Severity:** Minor +- **Location:** §19.5 aggregate table “Automation records … Authoring bound (19.1), not a shed” **inside** the runtime-ceiling table +- **What's wrong:** Mixes kinds. Spawned instances appear in both tables. Harmless but 4d/4f readers will implement a shed for automation that must not exist. +- **Why it matters for 4d:** Don’t shed tracks. + +### 48. §17.1 pipeline order vs actual data flow + +- **Severity:** Minor +- **Location:** §17.1 “primitives → components → procedural systems → layers → camera → post effects → display” +- **What's wrong:** Components and primitives are **inside** systems; systems **belong to** layers. Pipeline as a feed-forward list will mislead a 4d architecture (there is no “component pass” after primitives). Real order: tick systems → sort objects in layers → apply camera per layer → composite layers → effects. +- **Why it matters for 4d:** Don’t build seven sequential passes matching that list. + +### 49. §17.14 “Rendering consumes no procedural stream” vs grain vs wander offsets + +- **Severity:** Minor +- **Location:** §17.14; §18.6 wander consumes stream **once at instantiation**; §19.4 grain exempt +- **What's wrong:** Locked grain exemption is stated. 17.14’s “no visual analogue” of 14.6 is slightly overstated once grain exists. Not a 4d draw-loop sample bug if 4d respects 17.14. +- **Why it matters for 4d:** Frame loop must not call the PRNG. + +### 50. `components` root vs `components.visual` + +- **Severity:** Minor +- **Location:** §1.1 `components`; §18.1 `components.visual.` +- **What's wrong:** Audio components live under the same `components` object (presumably `components.audio` from 15.x). 4d loader must not treat every `components` entry as visual. Out of scope to fully check 15.x; flag if 18.1 is the first mention of the `visual` subkey. +- **Why it matters for 4d:** Schema. + +### 51. Implicit layer vs `layer` field required-by-reference + +- **Severity:** Minor +- **Location:** §17.5 “When `visuals.layers` is absent, one implicit layer … and a system's `layer` field is `ERR_INVALID_REFERENCE`.” +- **What's wrong:** Clear: you may not name a layer if none were declared. Default `layer` on §17.7 is “implicit layer.” Consistent. If `layers` is present, omitting `layer` on a system — §17.7 default “implicit layer” which **does not exist**. So every system **must** set `layer` whenever `layers` is declared. Never stated as required-conditional. +- **Why it matters for 4d:** Validation. + +### 52. `cover`/`contain` and camera projection center + +- **Severity:** Major (related to 11 and 42) +- **Location:** §19.3 “projection center is the center of the **display** rectangle after `fit`” +- **What's wrong:** Under `contain`, letterbox is background; projection center is display center, which may not be scene center on screen if letterboxing is not centered (defect 11). Under `cover`, cropped scene center may not match display center. Perspective about display center while scene origin is top-left is a specific choice that should have a numeric example. +- **Why it matters for 4d:** Same as camera matrix. + +### 53. Color alpha “multiplies the applicable opacity” vs fog vs layer + +- **Severity:** Minor +- **Location:** §17.2 color; §17.12 opacity +- **What's wrong:** `#rrggbbaa` × object opacity × group opacity × layer opacity × fog. Premultiply when, relative to blend `add`? Unspecified. Canvas 2D `globalAlpha` vs fillStyle alpha differ. +- **Why it matters for 4d:** Every translucent primitive. + +### 54. `ERR_INVALID_PRIMITIVE_TYPE` for fifteen types vs “Primitive Set 0.1 is fourteen” + +- **Severity:** Minor +- **Location:** §17.9 vs §18.1 vs §17.15 table +- **What's wrong:** Locked: fourteen closed; `component` is fifteenth object type. Text agrees. Diagnostic table §17.15 still says “not a member of Visual Primitive Set 0.1” without mentioning `component`. After 18.1, unknown type is “outside the fifteen.” 4d if implemented before 4e might reject `component` correctly as invalid primitive; 4e then accepts it. Trace 2 of 17.16 says unknown type is `ERR_INVALID_PRIMITIVE_TYPE` — `component` in a 4d-only renderer would fail that if exhibits use it. +- **Why it matters for 4d:** 4d scope: parse `component` as a stub group or reject? Spec does not give a 4d subset. + +### 55. Slice 4d is not given a subset of §17–19 + +- **Severity:** Blocker +- **Location:** §19 opening “Slice 4d begins the renderer”; §17.16 traces 1–14 as 4d acceptance; traces 15–16 are 4h; §18.10 traces belong to 4e; §19.7 to 4f +- **What's wrong:** 4d acceptance traces include perspective, fog, masks, conic fallback, bindings (trace 14), and “section 8.1 table unchanged.” They do **not** include camera matrix §19.3, yet §17.6 perspective is defined in terms of 19.3. 4d cannot implement trace 6 without 19.3, and 19.3 is a 4c/4f section. Circular implementation dependency. +- **Why it matters for 4d:** The renderer core **must** implement a camera to pass 17.16.6, but camera is specified in 19.3 and tested in 19.7.13–14. 4d needs an explicit borrow of 19.3 or a reduced orthographic-only 4d gate. + +--- + +## Standing decisions — compliance check + +| Decision | Spec actually says it? | Contradicted elsewhere? | +| --- | --- | --- | +| Angles degrees, 0 along +x, positive toward +y | Yes, §17.2 | Yes — y-down top-left (§17.4) makes “toward +y” clockwise; not acknowledged | +| Increasing z farther; sort greater z first; ties document key order | Yes, §17.6 | Partially — particle systems are atomic (§18.2); emitters/repeaters unspecified | +| Perspective `focalLength / (focalLength + z)`; ortho: z for sort/parallax/fog only | Yes, §17.6, §19.3 | Formula vs `V` space mix (§19.3) | +| Visual objects keyed, no `id` | Yes, §17.8 | No | +| Transform order `T(pos+translate) × T(origin) × R × K × S × T(−origin)`; `M_parent × M_local` | Yes, §17.11 | `z` not in `M_local`; camera `V` separate | +| ValueSpec once at instantiation; render consumes no stream | Yes, §17.14 | Automation mutates later (§19.1); grain exempt (§19.4) | +| `layer` system property not object | Yes, §17.10 | No | +| Depth fog per object, exact, no diagnostic | Yes, §17.6 | Color-space unspecified | +| Conic only appearance fallback, warn once per paint | Yes, §17.12 | Fallback segment not unique | +| `font` sans-serif/serif/monospace | Yes, §17.13 | No | +| Live numeric text deferred | Yes, §17.13 | No | +| Fourteen primitives closed; `component` fifteenth | Yes, §17.9, §18.1 | §17.15 cause text stale | +| Component parameters number/boolean/string/color | Yes, §18.1 | No | +| `inputs.*` / `repeat.*` construct-scoped | Yes, §18.1, §18.5 | No | +| Expansion path is §9.3 key | Yes, §18.1 | No | +| Integrator `v ← (v+a·dt)·(1−drag)^dt`; `p ← p+v·dt` | Yes, §18.2 | No | +| Life ramps endpoints once | Yes, §18.2 | No | +| Emission accumulator; `floor(rate·t)`; no reset | Yes, §18.4 | Particles say “Emission timing is 18.4” — OK | +| `ERR_UNBOUNDED_EMISSION`; `count` alone legal | Yes, §18.2, §18.4 | No | +| Pool oldest-first; aggregate §19.5 | Yes | No | +| Index-driven + continuous rate = `ERR_INVALID_DISTRIBUTION` | Yes, §18.3 | No | +| Sample order x,y,z; angle before radius | Yes, §18.3 | Polar `ring` field order in table is radius before angles — **sample** order vs **field** order clash | +| Coherent noise 3D, 12 gradients, 256 perm, quintic, curl default | Yes, §18.7 | Curl of scalar potential not unique; no sample table | +| Ribbons = `trail.mode`; `links` on emitter = `ERR_UNKNOWN_FIELD` | Yes, §18.8 | No | +| Morph equal type, points, spline mode | Yes, §18.6 | Non-point primitives underspecified | +| Exactly four §8.1 visual rows; no layer.visible | Yes, §19.1 | §17.16.14 and §17.3 contradict | +| Two automation scopes; cross-scope `ERR_INVALID_REFERENCE` | Yes, §19.1 | `visuals.automation` vs unknown-field | +| Track shape = §16.1 plus `loop`; ping-pong count = round trip | Yes | `infinite` only-persistent vs spawned-legal clash | +| Automatable ⊃ bindable | Yes | Repeater `step` does not exist | +| Behavior + track = `ERR_AUTOMATION_CONFLICT`; else pipeline then behaviors | Yes, §19.1 | Channel granularity fuzzy | +| Lifecycle enum; fields on persistent = `ERR_UNKNOWN_FIELD`; spawn key `id#ordinal` | Yes, §19.2 | No | +| Five states + FAILED; no SCHEDULED; release 0ms still one RELEASING tick | Yes | CREATED→FINISHED unexplained | +| Spawn at ceiling refused, not evicted | Yes | Warn-once vs once-per-second | +| Camera matrix normative; parallax translation only; projection authored once | Yes, §19.3 | Matrix mixes scene and display spaces | +| Seven effects, array order; blur/bloom two passes; approx warn; skip not substitute | Yes, §19.4 | No | +| Grain exempt, no stream | Yes | Scanlines also device-pixel but not exempt | +| §19.5 centralized; values provisional | Yes | Automation row in runtime table | + +**Sample-order clash (extra):** §18.3 “field order of its row” for `ring` is `center, radius, innerRadius, startAngle, endAngle, direction, mode`, but “polar forms angle before radius.” A fixture cannot satisfy both. **Severity: Major** for 4e; 4d should not implement distributions until this is fixed. + +--- + +## What was checked and found sound + +- Identifier regex and keyed `content`/`children` with no `id` field (§1.3, §17.8). +- Strict unknown-field policy as a general rule (the `automation` hole is the exception, not the rule). +- Three coordinate spaces named; `width`/`height` only for `virtual`; `viewport` rejects non-stretch `fit`. +- Layer cap 16; group nest 8; polygon/polyline 512; spline 256; path commands 512; gradient stops 16; filters 4. +- Transform composition string matches the locked order; hierarchy `M_parent × M_local` and `z_parent + z_local`. +- Negative scale = mirror; scale 0 legal and draws nothing; skew magnitude ≥ 90 is `ERR_OUT_OF_BOUNDS`. +- Safe blend set closed; stroke-only declared-fill vs inherited-fill distinction. +- Path must start with `move`; zero-radius path arc is `ERR_OUT_OF_BOUNDS` not a line; bezier spline `3n+1`. +- Fonts limited to three generic families; live numeric text explicitly deferred with rationale. +- Component parameter types (four scalars) vs audio number-only; `inputs.*` not a §8.1 row. +- Particle integrator formula and `dt`-correct drag; life-ramp-without-lifetime = `ERR_SCHEMA_VALIDATION`; exponential-through-zero reuses `WARN_AUTOMATION_FALLBACK`. +- Unbounded emission diagnostic; initial `count` without lifetime legal; oldest-first eviction. +- `links` on `emitter` = `ERR_UNKNOWN_FIELD`; ribbons as trail mode. +- Four §8.1 rows only; `visuals.layers..visible` absent on purpose; system `visible` bindable not automatable. +- Spawn refuse vs voice evict, with rationale; no `SCHEDULED` state; `0ms` release still enters `RELEASING`. +- Projection not automatable; grain stream exemption and rationale. +- Centralized ceiling table present; values labeled provisional pending 4h; runtimes may lower never raise. +- Diagnostic reuse policy (`ERR_COMPONENT_RECURSION`, `ERR_AUTOMATION_CONFLICT` widened, etc.) is consistent in intent. + +--- + +## 4d-specific recommendation (not a spec change, a sequencing note) + +Do not start the renderer against §17.16 as a closed gate. Minimum contract fixes before 4d code: + +1. Add `automation` to the §17.3 `visuals` key list. +2. Rewrite §17.16.14 to match §19.1’s four rows. +3. State Y-down + rotation sign in one sentence. +4. State box/ellipse local origin. +5. Give one numeric camera example (scene point → display point) that uses a single space for `V`. +6. Borrow §19.3 into the 4d scope list, or drop perspective from 4d traces. +7. Delete or define repeater `step`. +8. Fix “layer nesting” in trace 9 to group nesting / layer count. + +Until (3)–(5) land, two renderer cores can both “conform” and disagree on every rotated, zoomed, or letterboxed frame. diff --git a/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214822-grok4-6.md b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214822-grok4-6.md new file mode 100644 index 0000000..2fb36da --- /dev/null +++ b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260905-214822-grok4-6.md @@ -0,0 +1,491 @@ +# Visual contract review — Abacus AI Agent + +Independent adversarial read of XZBT Format Specification **rev 0.7**, sections **17–19**, before Phase 4 slice **4d** (renderer core). Existing files under `reviews/` were not opened. Written **2026-09-05 21:48:22 UTC** as a new file so any earlier review of the same name is left untouched. + +Standing decisions were treated as locked. This pass checks whether the spec text actually states them and whether it contradicts itself elsewhere. + +Severity: + +- **Blocker** — 4d cannot implement a unique, testable behavior without guessing. +- **Major** — two conforming 4d implementations can diverge, or a later slice will have to unwind 4d choices. +- **Minor** — underspecified edge, wrong diagnostic, or local inconsistency that 4d can paper over with a default. + +--- + +## Defects + +### 1. `visuals.automation` is both required and forbidden + +- **Severity:** Blocker +- **Location:** §17.3 *The `visuals` block* vs §19.1 *Two declaration scopes* +- **What's wrong:** §17.3 enumerates `scene`, `layers`, `systems`, `fields`, `camera`, `effects` and then: “any other property of `visuals` is `ERR_UNKNOWN_FIELD`.” §19.1 places an `automation` array on `visuals` itself (`visuals.automation`). Under the strict unknown-field policy that array is illegal at import. +- **Why it matters for 4d:** The renderer core will parse `visuals`. If it follows §17.3 it rejects every exhibit that uses the §19.1 example. If it follows §19.1 it violates the §17.3 table. Slice 4d must know the legal key set before it writes a schema or a loader. + +### 2. Trace 14 of §17.16 forbids the four §8.1 visual rows §19.1 adds + +- **Severity:** Blocker +- **Location:** §17.16 trace 14 vs §17.14 last paragraph vs §19.1 *The visual rows of the section 8.1 target-capability table* +- **What's wrong:** Trace 14: “A binding, `set`, or `override` addressing **any** visual property is `ERR_UNSUPPORTED_TARGET`, and the section 8.1 table is **unchanged by this slice**.” §19.1 then adds four families (`visuals.camera.`, `visuals.layers..opacity`, `visuals.systems..visible`, `visuals.effects[].`) that **are** binding/override targets. §17.14 already describes those four rows as if they exist. The acceptance gate for 4d therefore requires the opposite of the closed contract. +- **Why it matters for 4d:** 4d is the first code that will implement (or stub) target resolution. Implementing trace 14 literally means rejecting camera/layer/system/effect bindings that 4f traces 7 require to succeed. The 4d gate must be restated as “any visual property **outside the four §19.1 rows**.” + +### 3. Automatable registry names a repeater `step` block that does not exist + +- **Severity:** Blocker +- **Location:** §19.1 *The automatable registry* (“for `repeater`, the numeric fields of its `step` block”) vs §18.5 field table +- **What's wrong:** §18.5 has `repeat`, `count`, `distribution`, `position`, `inputs`, `behaviors`, `fields`, `links`. There is no `step` field, no `step` block, and no definition of what “numeric fields of its `step` block” are. Repeaters also have no `rate` (explicitly `ERR_UNKNOWN_FIELD`). +- **Why it matters for 4d:** Less for the first draw path than for the property-path resolver 4d will share with automation. A registry row with no corresponding schema is an unimplementable target. Either define `step` or delete the row. + +### 4. Y-down scene vs “positive angles toward +y” + +- **Severity:** Major +- **Location:** §17.2 *Canonical units* (“`0` points along `+x`; positive angles turn toward `+y`”) vs §17.4 (`x` and `y` run `0` to `1` **from the top-left**; virtual origin at top-left) +- **What's wrong:** Top-left origin with `y` increasing downward is a left-handed screen space. “Positive toward `+y`” is then **clockwise** on the display (standard canvas), not the mathematical CCW convention a reader of “toward +y” in a Cartesian plane will assume. The spec never says whether rotation is screen-clockwise or math-CCW, and never says whether `+y` in local object space is down. +- **Why it matters for 4d:** Transform order `R × K × S` is useless if the sign of `R` is ambiguous. Trace 3 of §17.16 (“a known local point maps to the documented device point”) cannot be written until this is fixed. Canvas 2D `rotate` is clockwise for y-down; a naive `cos/sin` toward +y with y-down will mirror every exhibit. + +### 5. Local origin of box primitives is unspecified + +- **Severity:** Major +- **Location:** §17.9 `rectangle`, `rounded-rectangle`, `ellipse`, `ring`, `arc`; §17.11 `origin` “in the object's own local space”; §17.13 text `align` “relative to the object's origin” +- **What's wrong:** For `rectangle` / `rounded-rectangle`, is `position` the top-left of `size`, the center, or something else? For `ellipse` / `ring` / `arc`, is `position` the center (implied by `radius` but never stated)? For `point`, is the dot centered on the origin? Text has `align`/`baseline`, which implies the origin is an anchor, but boxes have no equivalent. Default `origin` is `{0,0}`, so if a rectangle’s local space has its corner at 0, rotation is about the corner; if centered, about the center. These produce different pixels. +- **Why it matters for 4d:** First thing the primitive rasterizer needs. Two implementations will disagree on every rotated panel and every `contain`/`cover` fixture in trace 1/3. + +### 6. Perspective factor vs camera matrix: two post-multiplies, no combined formula + +- **Severity:** Major +- **Location:** §17.6 *Perspective scaling* (“object's transform is post-multiplied by `focalLength / (focalLength + z)` about the camera's projection center”) vs §19.3 (“applied **after** `V`”) +- **What's wrong:** §17.11 already post-multiplies `M_parent × M_local`. “Post-multiplied” in §17.6 does not say whether the factor is applied in local space, after `M_local`, after hierarchy, or after `V`. §19.3 says after `V`, about the **display** projection center. Applying a uniform scale about the display center to an already-view-transformed point is not the same as scaling the object about the camera look-at in scene space. `z` used in the factor is never named as **effective** `z` (`z_parent + z_local` from §17.11), though that is the only reading that matches hierarchy. +- **Why it matters for 4d:** Trace 6 of §17.16 and trace 14 of §19.7 are the 4d/4f camera tests. Without a single matrix (or a worked numeric example: one point, one `z`, one `V`, one output), 4d will pick an order that 4f then has to break. + +### 7. `translate.z` is excluded from `M_local` but perspective needs a 3D point + +- **Severity:** Major +- **Location:** §17.11 “The `z` component of `translate` adds to the object's `z` and … is **not** part of `M_local`.” +- **What's wrong:** `M_local` is therefore a 2D affine matrix. Perspective uses `z`. Hierarchy adds `z`. Particle integration moves `p` in 3D. The spec never says whether parent `M` rotates/scales child `z` (it cannot, if `M` is 2D) or only adds it. A child with local `z` under a rotated group: is depth the scalar sum, or a transformed coordinate? §17.11 says sum, which means rotation never tilts depth — consistent with 2.5D, but then “perspective about projection center” on a 2D matrix is a uniform XY scale, not a projective transform. That should be stated as “uniform XY scale, no vanishing-point projection.” +- **Why it matters for 4d:** Implementers will reach for a 4×4 perspective matrix. The contract wants a 2D scale. If 4d ships a real projection, every later fixture fails. + +### 8. Depth sort unit is inconsistent (object vs system vs particle cloud) + +- **Severity:** Major +- **Location:** §17.6 “Within one layer, **objects** are drawn farthest first” vs §18.2 “Particles of one system draw as **one unit** … The system's own position in its layer's depth sort is its **lowest-`z` particle**” vs §17.8 document key order of `content` +- **What's wrong:** Graphic systems contain many objects with independent `z`. Are those objects sorted **across systems** in the layer, or is each system an atomic band like particles? §17.6 says objects; §18.2 special-cases particles so they never interleave with unrelated objects. Emitters create full objects — §18.4 never says whether those objects join the layer object-sort or stay an atomic emitter band. Repeaters: same gap. A graphic system with `z=0` group and a child at `z=100` vs a sibling system at `z=50` has two legal draw orders. +- **Why it matters for 4d:** Painter’s algorithm is the entire 2.5D model. 4d must pick a sort key. Wrong choice is a visual bug in every multi-system scene and is expensive to change. + +### 9. Layer `visible` is a ValueSpec but is not automatable, bindable, or in §8.1 — and still “advances” + +- **Severity:** Major +- **Location:** §17.5 `visible` ValueSpec\, default `true`; §19.1 “`visuals.layers..visible` is deliberately **not** in the table”; §17.14 “every such field is resolved **once**, at its owning object's instantiation boundary” +- **What's wrong:** If layer `visible` resolves once, a ValueSpec is pointless except `random`/`choose` at activation. If it is meant to change, there is no writer: not automation (boolean), not binding (absent from §8.1), not `set`/`override`. The field is therefore a once-at-import flag dressed as a ValueSpec. Meanwhile system `visible` **is** an §8.1 binding target and is explicitly **not** automatable. The two `visible` flags look alike in §17 and behave unlike in §19. +- **Why it matters for 4d:** 4d will implement layer skip. It needs to know: evaluate once at activation, or subscribe to the pipeline? Implementing a live binding for layer `visible` would violate the locked “deliberately absent” decision; treating it as constant makes the ValueSpec type a lie. + +### 10. §17.16 trace 9 cites “9 layer nesting levels” — layers do not nest + +- **Severity:** Major +- **Location:** §17.16 trace 9 vs §17.5 (layers composite in **document key order**, no parent field) vs §17.9 (group nesting 8) +- **What's wrong:** Trace 9: “`9` layer nesting levels … are each `ERR_VISUAL_LIMIT_EXCEEDED`.” There is no layer nesting in §17.5. Group nesting is 8. Layer **count** is 16. The acceptance test for 4d is unrunnable as written. +- **Why it matters for 4d:** 4d’s exit gate includes a test that cannot be authored. Likely intent: group nesting 9 / 8, and separately 17 layers. + +### 11. Fit mapping is underspecified for `cover` crop and letterbox placement + +- **Severity:** Major +- **Location:** §17.4 `fit`; §17.16 trace 1 +- **What's wrong:** `contain` letterboxes “the remainder with `background`” but does not say the scene is **centered** in the display (vs aligned top-left). `cover` “cropping the overflow” does not say which region is kept (centered crop vs origin-aligned). `viewport` ignores `fit` except that non-`stretch` is an error — so the only legal `fit` under `viewport` is `stretch`, which is also ignored. Device-pixel-ratio (§19.5) is applied after fit; the spec never gives the composed scene→CSS→device transform. +- **Why it matters for 4d:** Trace 1 is the first 4d test and requires “expected display point” including letterbox offsets. Without centering/crop rules those points are not determined. + +### 12. Camera default “scene center” under `viewport` is not a scene quantity + +- **Severity:** Major +- **Location:** §19.3 “the display center for `viewport`” as default `x`,`y`; camera is in “scene units” +- **What's wrong:** Viewport scene units **are** CSS pixels of the display, origin top-left, and the scene has no intrinsic size. Defaulting the camera to “display center” makes the default camera depend on the window size. Two machines, same exhibit, different default view. That contradicts “an exhibit that never mentions the camera” looking identical (§19.3’s own rationale) and contradicts reproducibility across display sizes. +- **Why it matters for 4d:** Identity camera vs “centered on display” are different matrices as soon as the canvas is not the design size. 4d will bake this into every frame. + +### 13. Conic-gradient fallback axis is not a unique segment + +- **Severity:** Major +- **Location:** §17.12 *Conic gradient fallback* +- **What's wrong:** Fallback is “a `linear-gradient` with the same stops along the axis from `center` at `angle` to the paint's bounding-box edge.” A ray from `center` at `angle` intersects a bounding box at **one** point only if `center` is inside the box; if `center` is outside there may be two intersections or none. “Bounding-box edge” does not name which edge. Stop mapping from a 360° conic onto a 1D axis is lossy; the spec does not say whether offsets are used as-is (wrong) or the 0°–180° half is taken. +- **Why it matters for 4d:** This is the **only** appearance fallback 4d must implement, and trace 10 requires a documented linear gradient. Without a unique segment, the warning can fire and the pixels still differ. + +### 14. Fog blends “resolved fill, stroke, glow, and shadow colors” — gradients and alpha unspecified + +- **Severity:** Major +- **Location:** §17.6 *Depth fog* +- **What's wrong:** Fog fraction is exact. What is “blended toward `color`”? For a gradient, every stop? The rasterized samples? Glow/shadow have their own colors; is the fog color’s alpha used? Premultiplied? `density * clamp(...)` as a lerp in sRGB or linear? Particle `color` ramps already interpolate sRGB; fog does not say. Objects with `fill: null` still have glow/shadow. +- **Why it matters for 4d:** Trace 7 asks for blended colors at near/mid/far. Without a color space and a rule for paints, the trace has no oracle. + +### 15. Stroke-only primitives vs `point` fill; `style.fill` on `line` vs table + +- **Severity:** Minor +- **Location:** §17.9 `line` “`style.fill` on a `line` is `ERR_UNKNOWN_FIELD`” vs §17.12 *Stroke-only primitives* (`line`, `polyline`, `arc`, `bezier` — **not** `point`); `point` is “a filled dot” +- **What's wrong:** `point` uses fill, not stroke, but is not listed as fill-only (`style.stroke` on a point?). `polyline` is stroke-only in §17.9 notes but the stroke-only ERR list in §17.12 omits nothing it listed — OK — yet `path`/`spline` can be open and still accept fill. Open path with fill: legal? Canvas fills open subpaths by closing them; the spec is silent. +- **Why it matters for 4d:** Open-path fill is a classic renderer fork. + +### 16. Arc sweep, direction, and “magnitude exceeds 360” + +- **Severity:** Major +- **Location:** §17.9 `arc` / `ring`; §17.2 angles +- **What's wrong:** `startAngle`, `endAngle`, `direction` `clockwise` | `counter-clockwise`. Is the sweep the directed difference following `direction`, or the linear difference `end - start` with `direction` flipping the canvas arc flag? A sweep of 0: nothing, or a full ring (ring says “angles default to a full ring” — default values never given numerically)? `ERR_OUT_OF_BOUNDS` when magnitude exceeds 360: is that `|end-start|` or the directed sweep after wrapping? `end = start + 360` with clockwise: full circle or error? +- **Why it matters for 4d:** Arc/ring are in Primitive Set 0.1; 4d must emit a unique path. + +### 17. Path `arc` command vs SVG sweep/largeArc in y-down space + +- **Severity:** Minor +- **Location:** §17.13 path `op: arc` +- **What's wrong:** Endpoint-parameterized elliptical arc with `largeArc` and `sweep` booleans. SVG’s `sweep` is “positive angle” in y-down (clockwise). The visual contract’s positive angle is “toward +y.” If those disagree (defect 4), path arcs and `arc` primitives will rotate opposite ways. +- **Why it matters for 4d:** Path vs primitive inconsistency will show up in Exhibit geometry immediately. + +### 18. Spline Catmull-Rom: phantom points, closed form, and tension mapping + +- **Severity:** Major +- **Location:** §17.13 *Spline* +- **What's wrong:** Open spline: “first and last points are duplicated as the phantom endpoints.” That is one CR parameterization (uniform, centripetal?). `tension` `0` to `1` default `0.5` is not mapped to a formula (Kochanek–Bartels? `tau = 1 - tension`?). Closed spline: no phantoms specified; standard is wrap indices. `bezier` mode `count = 3n+1` is for **open** cubics; a `closed` bezier spline with that count cannot close smoothly without extra points — interaction of `closed` + `bezier` is undefined. +- **Why it matters for 4d:** Organic geometry and morph (equal point count) depend on a unique curve. 4d will pick a CR variant; 4e morph interpolation will bake it in. + +### 19. Text: `maxWidth` “condensed horizontally” without a scale rule; missing `font` on the 17.9 geometry row + +- **Severity:** Minor +- **Location:** §17.13 text; §17.9 `text` geometry fields omit `weight`, `italic`, `letterSpacing` that §17.13 adds +- **What's wrong:** Condensing to `maxWidth` — uniform scale of glyphs? Tracking only? What if the run is already shorter? Overflow of 256 characters after resolution: diagnostic? Generic CSS families: metrics differ per platform, so text layout is **not** reproducible even though 17.14 claims procedural identity. That may be acceptable (like grain) but is not exempted. +- **Why it matters for 4d:** 4d will measure text. Platform font metrics will fail any pixel fixture. The spec should either exempt text metrics from reproducibility or require a measurement rule (e.g. no pixel tests on text bounds). + +### 20. Graphic system has no system-level transform/position; camera and particles do + +- **Severity:** Minor +- **Location:** §17.7 / §17.8 vs §18.2 `position` on particles vs §19.3 camera +- **What's wrong:** A `graphic` system’s objects sit in scene space with no system origin. Particles add a system `position` offset to the distribution. Authors cannot move a whole graphic system without grouping. Not a contradiction, but 4d must not invent a system transform for `graphic`. +- **Why it matters for 4d:** Tempting to put a system matrix in the renderer. Spec does not allow it for `graphic`. + +### 21. `lifetime` on a visual object vs system lifecycle vs particle lifetime + +- **Severity:** Major +- **Location:** §17.10 object `lifetime`; §17.7 / §19.2 system lifecycle; §18.2 particle `lifetime` +- **What's wrong:** Object-level `lifetime` “removed from its container” has no state machine, no `release`, no ownership, no diagnostic, and is legal on persistent graphic content. Is that a 4d concern (remove from draw list on logical clock) or 4f? Removing a group’s child mid-run changes document key order used for equal-`z` ties. Spawned systems forbid lifecycle fields on persistent systems, but object `lifetime` remains legal on persistent content — a back door that looks like lifecycle. +- **Why it matters for 4d:** The draw list is 4d’s. If objects can disappear without 19.2, 4d needs a removal path now. + +### 22. Filters “after the object is drawn and before it composites into its layer” + +- **Severity:** Minor +- **Location:** §17.12 *Filters* vs *Cost* (offscreen buffer) +- **What's wrong:** CSS `filter` on a raster vs filter on vector before raster is different. Hue-rotate amount in degrees — range unset (unlike 0–4 / 0–1). Blend vs filter vs opacity vs layer opacity order: object opacity multiplies inherited and layer; blend requires offscreen; filters after draw. Glow/shadow vs filter order unset. `blur` style field vs `filters` vs post-effect `blur` — three blurs. +- **Why it matters for 4d:** Offscreen pass graph is 4d. Ambiguous order → different pixels and different pass counts vs §19.5. + +### 23. Clip inheritance and mask vs draw order + +- **Severity:** Minor +- **Location:** §17.12 clipping / masking +- **What's wrong:** Mask child “is not drawn; its rendered alpha multiplies the alpha of the group's remaining children.” Remaining children are still depth-sorted among themselves? Mask is rasterized untransformed? With the group’s transform? Does the mask include the mask child’s own style opacity? Clip intersected with ancestor — in which space after nested transforms? +- **Why it matters for 4d:** Mask/clip are in traces 12. Need a unique raster definition. + +### 24. Particle `size` ramp “multiplier on `render`'s own size” for `point` / `ellipse` / `component` + +- **Severity:** Minor +- **Location:** §18.2 `size` +- **What's wrong:** `point` has `pointSize`, not `size`. `ellipse` has `radius`. `component` has no size. Multiplying “size” is undefined for those. `rotation` at creation vs `angularVelocity` vs `align` on emitters. +- **Why it matters for 4d:** Particle drawing is likely in 4d even if emission is 4e; the instance transform must be defined. + +### 25. Particle / emitter motion in 3D vs 2D draw + +- **Severity:** Minor +- **Location:** §18.2 integrator (`p`, `v`, `a` have z) vs §17.11 2D `M_local` +- **What's wrong:** `p.z` updates; draw uses `z` for sort/fog/perspective scale only. Fine, but `align` “rotation … to its velocity direction” — 2D `atan2(vy,vx)` or 3D? Unspecified. +- **Why it matters for 4d:** Emitter `align` is a 4d rotation if 4d draws emitted objects. + +### 26. Index-driven `even` with `count === 1` and `i / (count - 1)` + +- **Severity:** Minor +- **Location:** §18.3 `line` mode `even`; §18.5 `repeat.fraction` already special-cases `count === 1` +- **What's wrong:** `i / (count - 1)` is division by zero when a burst or repeater has `count: 1`. Fraction documents the fix; distribution `even` does not. +- **Why it matters for 4d:** Only if 4d implements distributions; still a landmine for 4e that the contract should close now. + +### 27. Ring distribution: “not below `radius` is `ERR_INVALID_RANGE_ORDER`” + +- **Severity:** Minor +- **Location:** §18.3 `ring` vs §17.9 ring primitive (`innerRadius` **must be less than** `radius`) +- **What's wrong:** “not below `radius`” reads as `innerRadius >= radius` is the error, i.e. inner must be **below** radius — OK — but the wording is easy to invert. Primitive uses strict less-than; distribution does not say whether `innerRadius === radius` is a legal zero-width ring (perimeter) or an error. Primitive would error on equality. +- **Why it matters for 4d:** Shared geometry helpers. + +### 28. `grid` item count vs `repeater.count` / `columns * rows` + +- **Severity:** Major +- **Location:** §18.3 `grid`; §18.5 `count` +- **What's wrong:** Grid is `columns × rows` cells. Repeater has an independent `count` up to 1024. If `count != columns * rows`, extra items wrap? Truncate? Error? Unspecified. `jitter` consumes samples even though “grid without jitter consumes no samples” — with jitter, order of x/y jitter vs depth sub-block is only “field order of its row,” and `jitter` is not ordered relative to `depth`. +- **Why it matters for 4d/4e:** Placement fixtures (trace 10 of §18.10). + +### 29. Behaviors: `oscillate` formula `phase / 360` with frequency in Hz + +- **Severity:** Minor +- **Location:** §18.6 `oscillate` `center + amplitude * w(frequency * t + phase / 360)` +- **What's wrong:** If `w` expects cycles, `frequency * t` is cycles and `phase/360` is cycles — OK. If `w` is `sin(2π · …)` that must be stated. `square`/`sawtooth` polarity (does square start high?) unset. `orbit` does not say whether it **sets** position or **adds** to it; composition says accumulate on position, which implies orbit is an offset — then `center` is absolute or relative? +- **Why it matters for 4d:** 4d may stub behaviors; if it draws static poses only, less urgent. Still, `orbit` + `position` composition must be known before any motion lands. + +### 30. `follow-path` `path` as sibling key vs component encapsulation + +- **Severity:** Minor +- **Location:** §18.6 `follow-path`; §18.1 encapsulation +- **What's wrong:** Sibling key inside a component is OK; from outside, `ERR_INVALID_REFERENCE`. Cross-system path follow: not mentioned. `path` as inline `commands` on a behavior duplicates 17.13 without vertex limits. +- **Why it matters for 4d:** Low until 4e. + +### 31. Morph “equal type, point count, and spline mode” vs rectangles / text / groups + +- **Severity:** Minor +- **Location:** §18.6 *Morph compatibility* vs locked decision +- **What's wrong:** Locked decision matches the spline rule. Morph of two `rectangle`s (no points) — same type, point count 0=0, no spline mode: legal lerp of `size`? Morph of `group`s? Morph of `text`? “Point count” for primitives without points is undefined. `path` command morph: equal command count or equal endpoint count? +- **Why it matters for 4d:** Morph is 4e, but 4d may store geometry in a form that cannot lerp. + +### 32. Noise: 12 edge-midpoint gradients of a cube, 256 permutation, Fisher–Yates + +- **Severity:** Major (for 4e, but the algorithm is in-scope for this read) +- **Location:** §18.7 *Coherent noise is normative* +- **What's wrong:** Classic Perlin uses 12 **edge** vectors of a cube (the 12 midpoints of cube edges, yes). Permutation of 256 shuffled with one sample **per entry** — Fisher–Yates on 256 entries needs 256 samples if specified that way, but standard FY uses `i` from 255..1 with `j = floor(u * (i+1))`. The spec does not give the exact FY index formula or how a stream sample in `[0,1)` becomes `j`. Duplicate permutation (Perlin often repeats 0..255 twice to 512) unset. `curl` of a **scalar** 3D noise is zero if you take `∇ × (N, N, N)` or needs three offset noise fields; “curl of the noise potential” with one scalar is not a unique vector. This is the hardest reproducibility trap in §18. +- **Why it matters for 4d:** If 4d does not implement fields, still: do not invent a noise helper in 4d that 4e must match. Spec should give a tabulated sample (trace 15 of §18.10 promises “documented sample values” that are not in the spec). +- **Gap:** Trace 15 requires documented sample values; **the spec contains none**. That is a missing fixture, not just an algorithm gap. + +### 33. `visuals.fields` vs §17.3 — fields are listed; OK. Field IDs vs system `fields` array order + +- **Severity:** Minor +- **Location:** §18.7 +- **What's wrong:** Sum in system `fields` array order; addition commutative. Fine. `enabled` ValueSpec on a field — once at instantiation (17.14), so cannot be automated. Strength of a field is not in the automatable registry (locked). OK. + +### 34. Trail `interval` default “one logical tick” — tick length is a runtime, not an exhibit constant + +- **Severity:** Major +- **Location:** §18.8 `interval` default; §9.1 logical tick +- **What's wrong:** History sampled every tick by default means trail **shape depends on tick rate**, contradicting “a trail has the same shape at any **render** rate” (frame rate, not tick rate). Trace 17 of §18.10 only varies frame rate. If tick length differs across runtimes, trails diverge. Default should be a DurationSpec literal (e.g. the 9.1 tick) named explicitly. +- **Why it matters for 4d:** If 4d allocates trail buffers, stride is unknown. + +### 35. Links draw “before their system's items, at the system's own depth position” + +- **Severity:** Minor +- **Location:** §18.8 vs §18.2 lowest-`z` particle as system sort key +- **What's wrong:** Links at system depth vs particles sorted among themselves: links can draw in front of far particles of the same system. Maybe intended. `index` rule with `stride` on a changing particle pool — emitters cannot have links; particles can, and creation ordinals change under eviction. After oldest-first eviction, indices are not stable. Unspecified. +- **Why it matters for 4d:** Draw order inside a system. + +### 36. Automatable registry vs §8.1: emitter `rate` automatable but not bindable — registry allows it; graphic per-object properties automatable from **system** scope + +- **Severity:** Major +- **Location:** §19.1 registry “for a `graphic` system, any numeric `transform`, `style`, or geometry property of an object in its `content` tree” +- **What's wrong:** Locked decision: automatable registry is **broader** than §8.1 — confirmed. But §17.14 said visual object fields resolve **once** and are constant for the object’s lifetime, with time variation from behaviors, automation, and external control. Automation writing `content.band.style.opacity` is therefore an exception to “constant for lifetime.” 4d must keep those fields **mutable** even though 17.14 sounds like they are baked. Also: `style.fill` as color is non-numeric and excluded; `strokeWidth` is included. `visible` on an **object** is boolean — not automatable; system `visible` is bindable only. Object `visible` has **no** writer except a once-resolved ValueSpec — same trap as layer `visible`. +- **Why it matters for 4d:** 4d must not intern style as immutable GPU constants. Per-object opacity must remain a frame-time input for 4f automation. + +### 37. Cross-scope `target` strings: relative paths vs `camera.zoom` vs `visuals.camera.zoom` + +- **Severity:** Major +- **Location:** §19.1 example `"target": "camera.zoom"`; §8.1 rows `visuals.camera.`; §1.3 prefixes +- **What's wrong:** Example targets are scope-relative (`camera.zoom`). §8.1 uses absolute `visuals.camera.zoom`. Bindings presumably need the absolute form. Automation in system scope “addressed relative to the system” — is `rate` or `content.hull.transform.rotation` the form? No ABNF. `effects[]` vs `effects.0` — §1.3 says indexed form only. +- **Why it matters for 4d:** Path parser is shared. Wrong grammar → every 4f track fails. + +### 38. `loop` on visual tracks vs “audio track shape verbatim” + +- **Severity:** Minor +- **Location:** §19.1 vs locked decision +- **What's wrong:** Locked: audio shape **plus** `loop`. Text agrees. Ping-pong count = complete round trip: agrees. `infinite` loop on spawned system with finite lifetime is legal — OK. `infinite` “legal only on a persistent scope” in one sentence, then immediately allowed on spawned with finite lifetime — **contradiction in two consecutive sentences**. +- **Why it matters for 4d:** Validation of `loop.count`. + +### 39. `ERR_AUTOMATION_CONFLICT` for behavior + track on the same **object channel** + +- **Severity:** Minor +- **Location:** §19.1 vs §18.6 composition +- **What's wrong:** Locked decision matches. Unclear whether `oscillate` on `transform.scale.x` conflicts with a track on `transform.scale` (vector) or only `.x`. Unclear whether a system-level track on emitter `position.x` conflicts with a `drift` on each emitted item (different objects). +- **Why it matters for 4d:** Conflict detection may live next to the property store 4d creates. + +### 40. Lifecycle: `CREATED -> ACTIVE | FINISHED | FAILED` — when FINISHED from CREATED? + +- **Severity:** Minor +- **Location:** §19.2 state table +- **What's wrong:** No `SCHEDULED` (locked, OK). `CREATED -> FINISHED` is allowed but no cause (zero-lifetime spawn before first draw?). `release` default 0ms still one tick in `RELEASING` (locked, OK). Spawn at ceiling refused not evicted (locked, OK). `WARN_VISUAL_CEILING` once naming template — vs §19.5 “once per ceiling per second.” A refused spawn could warn every spawn or once per second; 19.2 says once (per event?), 19.5 says once per ceiling per second. +- **Why it matters for 4d:** 4d may not spawn yet, but the warning rate is a shared diagnostic policy. + +### 41. `cancelWithScenario: false` without persistent ownership is `ERR_UNSUPPORTED_TARGET` + +- **Severity:** Minor +- **Location:** §19.2 +- **What's wrong:** That is a schema/semantic conflict, not an unsupported **target**. `ERR_SCHEMA_VALIDATION` or `ERR_UNKNOWN_FIELD` would fit; `ERR_UNSUPPORTED_TARGET` is the 8.1 capability miss. Wrong code will be wired into tests. +- **Why it matters for 4d:** Low; 4f traces. + +### 42. Camera matrix `R(-rotation)` vs object `R(rotation)` + +- **Severity:** Major +- **Location:** §19.3 `V = T(c) × S(zoom) × R(-rotation) × T(-c) × T(-p·Δ)` +- **What's wrong:** Negating rotation is correct for a view matrix **if** object rotation uses the same convention. Combined with defect 4, the extra minus may double-correct. `T(-p * (x - c_x), …)` — camera `x,y` are scene-center defaults; `c` is **display** projection center after fit. Subtracting a scene coordinate from a display coordinate is a **unit/space mix** unless fit has already mapped them into one space. The formula never says whether `x,y` are converted to display pixels first. +- **Why it matters for 4d:** This is the **normative camera matrix** 4d must implement. As written it mixes scene units and display units. Trace 13 of §19.7 cannot have a unique documented display point. + +### 43. Parallax and perspective both claimed to affect “the layer” + +- **Severity:** Minor +- **Location:** §17.6 parallax on camera translation; §19.3 perspective **per object** after `V` +- **What's wrong:** Locked: parallax multiplies translation only — text agrees. Perspective is per-object `z`, so two objects on one layer at different `z` scale differently while sharing one `V`. A layer `parallax` does not change object `z`. OK if intended; worth stating that parallax is **not** a function of object `z` (only layer field). +- **Why it matters for 4d:** Do not implement depth-based parallax. + +### 44. Post-effects: `scanlines.spacing` in **device pixels** vs everything else in scene units + +- **Severity:** Major +- **Location:** §19.4 `scanlines.spacing`, `grain.scale` in device pixels; `blur.radius` scene units scaled by zoom +- **What's wrong:** Reproducibility: scanline period changes with DPR and window size. Grain is exempt; scanlines are **not**. Two machines at different DPR get different line density. `speed` of scanlines has range “—” (unlimited?). `color-adjust.saturation` vs filter type `saturate` naming. Disabled effect “costs nothing that frame” vs pass budget counted how when `enabled` is a ValueSpec resolved once — cannot toggle unless automated; `enabled` is boolean so **not** automatable. So `enabled` is another once-only ValueSpec that cannot be bound (not in §8.1 except `.` numeric). Cannot turn effects off at runtime despite the field. +- **Why it matters for 4d:** Effect chain is after the renderer; 4d may still allocate the composited frame. Scanlines/grain in device pixels must be a conscious 4d/4f choice or exhibits are resolution-dependent without a warning. + +### 45. `WARN_VISUAL_APPROXIMATION` used for three different events + +- **Severity:** Minor +- **Location:** §17.1 / §17.12 conic fallback; §19.4 reduced-res blur/bloom and skipped unavailable effect; §19.5 shedding offscreen buffers also raises `WARN_VISUAL_APPROXIMATION` (not `WARN_VISUAL_CEILING`) +- **What's wrong:** Buffer shedding is a ceiling event but uses the approximation warning. Authors cannot distinguish “GPU cannot conic” from “too many glow buffers.” Locked decision said reduced-res blur/bloom raises approximation once per effect instance — text agrees. Buffer shed using the same code is extra. +- **Why it matters for 4d:** 4d implements glow/blur buffers and must pick a diagnostic. + +### 46. §19.5 offscreen-buffer shed “farthest-`z` first” vs draw order farthest first + +- **Severity:** Minor +- **Location:** §19.5 compositing buffers +- **What's wrong:** Shed farthest-`z` first means distant fancy objects lose glow first (keep near effects). Opposite of painter priority. Fine if intended. “Draw the excess objects without their non-default blend/mask/blur/glow/filters” — dropping `mask` changes silhouette, which **is** a material substitution PRD 89 forbids, while the table claims neither kind of limit is silently transformed into something materially different. +- **Why it matters for 4d:** Pass budget enforcement. + +### 47. Authoring bound vs runtime ceiling: automation records listed in **both** tables + +- **Severity:** Minor +- **Location:** §19.5 aggregate table “Automation records … Authoring bound (19.1), not a shed” **inside** the runtime-ceiling table +- **What's wrong:** Mixes kinds. Spawned instances appear in both tables. Harmless but 4d/4f readers will implement a shed for automation that must not exist. +- **Why it matters for 4d:** Don’t shed tracks. + +### 48. §17.1 pipeline order vs actual data flow + +- **Severity:** Minor +- **Location:** §17.1 “primitives → components → procedural systems → layers → camera → post effects → display” +- **What's wrong:** Components and primitives are **inside** systems; systems **belong to** layers. Pipeline as a feed-forward list will mislead a 4d architecture (there is no “component pass” after primitives). Real order: tick systems → sort objects in layers → apply camera per layer → composite layers → effects. +- **Why it matters for 4d:** Don’t build seven sequential passes matching that list. + +### 49. §17.14 “Rendering consumes no procedural stream” vs grain vs wander offsets + +- **Severity:** Minor +- **Location:** §17.14; §18.6 wander consumes stream **once at instantiation**; §19.4 grain exempt +- **What's wrong:** Locked grain exemption is stated. 17.14’s “no visual analogue” of 14.6 is slightly overstated once grain exists. Not a 4d draw-loop sample bug if 4d respects 17.14. +- **Why it matters for 4d:** Frame loop must not call the PRNG. + +### 50. `components` root vs `components.visual` + +- **Severity:** Minor +- **Location:** §1.1 `components`; §18.1 `components.visual.` +- **What's wrong:** Audio components live under the same `components` object (presumably `components.audio` from 15.x). 4d loader must not treat every `components` entry as visual. Out of scope to fully check 15.x; flag if 18.1 is the first mention of the `visual` subkey. +- **Why it matters for 4d:** Schema. + +### 51. Implicit layer vs `layer` field required-by-reference + +- **Severity:** Minor +- **Location:** §17.5 “When `visuals.layers` is absent, one implicit layer … and a system's `layer` field is `ERR_INVALID_REFERENCE`.” +- **What's wrong:** Clear: you may not name a layer if none were declared. Default `layer` on §17.7 is “implicit layer.” Consistent. If `layers` is present, omitting `layer` on a system — §17.7 default “implicit layer” which **does not exist**. So every system **must** set `layer` whenever `layers` is declared. Never stated as required-conditional. +- **Why it matters for 4d:** Validation. + +### 52. `cover`/`contain` and camera projection center + +- **Severity:** Major (related to 11 and 42) +- **Location:** §19.3 “projection center is the center of the **display** rectangle after `fit`” +- **What's wrong:** Under `contain`, letterbox is background; projection center is display center, which may not be scene center on screen if letterboxing is not centered (defect 11). Under `cover`, cropped scene center may not match display center. Perspective about display center while scene origin is top-left is a specific choice that should have a numeric example. +- **Why it matters for 4d:** Same as camera matrix. + +### 53. Color alpha “multiplies the applicable opacity” vs fog vs layer + +- **Severity:** Minor +- **Location:** §17.2 color; §17.12 opacity +- **What's wrong:** `#rrggbbaa` × object opacity × group opacity × layer opacity × fog. Premultiply when, relative to blend `add`? Unspecified. Canvas 2D `globalAlpha` vs fillStyle alpha differ. +- **Why it matters for 4d:** Every translucent primitive. + +### 54. `ERR_INVALID_PRIMITIVE_TYPE` for fifteen types vs “Primitive Set 0.1 is fourteen” + +- **Severity:** Minor +- **Location:** §17.9 vs §18.1 vs §17.15 table +- **What's wrong:** Locked: fourteen closed; `component` is fifteenth object type. Text agrees. Diagnostic table §17.15 still says “not a member of Visual Primitive Set 0.1” without mentioning `component`. After 18.1, unknown type is “outside the fifteen.” 4d if implemented before 4e might reject `component` correctly as invalid primitive; 4e then accepts it. Trace 2 of 17.16 says unknown type is `ERR_INVALID_PRIMITIVE_TYPE` — `component` in a 4d-only renderer would fail that if exhibits use it. +- **Why it matters for 4d:** 4d scope: parse `component` as a stub group or reject? Spec does not give a 4d subset. + +### 55. Slice 4d is not given a subset of §17–19 + +- **Severity:** Blocker +- **Location:** §19 opening “Slice 4d begins the renderer”; §17.16 traces 1–14 as 4d acceptance; traces 15–16 are 4h; §18.10 traces belong to 4e; §19.7 to 4f +- **What's wrong:** 4d acceptance traces include perspective, fog, masks, conic fallback, bindings (trace 14), and “section 8.1 table unchanged.” They do **not** include camera matrix §19.3, yet §17.6 perspective is defined in terms of 19.3. 4d cannot implement trace 6 without 19.3, and 19.3 is a 4c/4f section. Circular implementation dependency. +- **Why it matters for 4d:** The renderer core **must** implement a camera to pass 17.16.6, but camera is specified in 19.3 and tested in 19.7.13–14. 4d needs an explicit borrow of 19.3 or a reduced orthographic-only 4d gate. + +### 56. Ring distribution sample order vs “angle before radius” + +- **Severity:** Major +- **Location:** §18.3 *Sample consumption* vs `ring` field table +- **What's wrong:** Sample order is normative: “`x` before `y` before `z`, and for polar forms angle before radius.” The `ring` row lists `center, radius, innerRadius, startAngle, endAngle, direction, mode`. “Field order of its row” therefore samples radius before angle. A fixture cannot satisfy both sentences. +- **Why it matters for 4d/4e:** Placement reproducibility. + +--- + +## Standing decisions — compliance check + +| Decision | Spec actually says it? | Contradicted elsewhere? | +| --- | --- | --- | +| Angles degrees, 0 along +x, positive toward +y | Yes, §17.2 | Yes — y-down top-left (§17.4) makes “toward +y” clockwise; not acknowledged | +| Increasing z farther; sort greater z first; ties document key order | Yes, §17.6 | Partially — particle systems are atomic (§18.2); emitters/repeaters unspecified | +| Perspective `focalLength / (focalLength + z)`; ortho: z for sort/parallax/fog only | Yes, §17.6, §19.3 | Formula vs `V` space mix (§19.3) | +| Visual objects keyed, no `id` | Yes, §17.8 | No | +| Transform order `T(pos+translate) × T(origin) × R × K × S × T(−origin)`; `M_parent × M_local` | Yes, §17.11 | `z` not in `M_local`; camera `V` separate | +| ValueSpec once at instantiation; render consumes no stream | Yes, §17.14 | Automation mutates later (§19.1); grain exempt (§19.4) | +| `layer` system property not object | Yes, §17.10 | No | +| Depth fog per object, exact, no diagnostic | Yes, §17.6 | Color-space unspecified | +| Conic only appearance fallback, warn once per paint | Yes, §17.12 | Fallback segment not unique | +| `font` sans-serif/serif/monospace | Yes, §17.13 | No | +| Live numeric text deferred | Yes, §17.13 | No | +| Fourteen primitives closed; `component` fifteenth | Yes, §17.9, §18.1 | §17.15 cause text stale | +| Component parameters number/boolean/string/color | Yes, §18.1 | No | +| `inputs.*` / `repeat.*` construct-scoped | Yes, §18.1, §18.5 | No | +| Expansion path is §9.3 key | Yes, §18.1 | No | +| Integrator `v ← (v+a·dt)·(1−drag)^dt`; `p ← p+v·dt` | Yes, §18.2 | No | +| Life ramps endpoints once | Yes, §18.2 | No | +| Emission accumulator; `floor(rate·t)`; no reset | Yes, §18.4 | Particles say “Emission timing is 18.4” — OK | +| `ERR_UNBOUNDED_EMISSION`; `count` alone legal | Yes, §18.2, §18.4 | No | +| Pool oldest-first; aggregate §19.5 | Yes | No | +| Index-driven + continuous rate = `ERR_INVALID_DISTRIBUTION` | Yes, §18.3 | No | +| Sample order x,y,z; angle before radius | Yes, §18.3 | Polar `ring` field order in table is radius before angles | +| Coherent noise 3D, 12 gradients, 256 perm, quintic, curl default | Yes, §18.7 | Curl of scalar potential not unique; no sample table | +| Ribbons = `trail.mode`; `links` on emitter = `ERR_UNKNOWN_FIELD` | Yes, §18.8 | No | +| Morph equal type, points, spline mode | Yes, §18.6 | Non-point primitives underspecified | +| Exactly four §8.1 visual rows; no layer.visible | Yes, §19.1 | §17.16.14 and §17.3 contradict | +| Two automation scopes; cross-scope `ERR_INVALID_REFERENCE` | Yes, §19.1 | `visuals.automation` vs unknown-field | +| Track shape = §16.1 plus `loop`; ping-pong count = round trip | Yes | `infinite` only-persistent vs spawned-legal clash | +| Automatable ⊃ bindable | Yes | Repeater `step` does not exist | +| Behavior + track = `ERR_AUTOMATION_CONFLICT`; else pipeline then behaviors | Yes, §19.1 | Channel granularity fuzzy | +| Lifecycle enum; fields on persistent = `ERR_UNKNOWN_FIELD`; spawn key `id#ordinal` | Yes, §19.2 | No | +| Five states + FAILED; no SCHEDULED; release 0ms still one RELEASING tick | Yes | CREATED→FINISHED unexplained | +| Spawn at ceiling refused, not evicted | Yes | Warn-once vs once-per-second | +| Camera matrix normative; parallax translation only; projection authored once | Yes, §19.3 | Matrix mixes scene and display spaces | +| Seven effects, array order; blur/bloom two passes; approx warn; skip not substitute | Yes, §19.4 | No | +| Grain exempt, no stream | Yes | Scanlines also device-pixel but not exempt | +| §19.5 centralized; values provisional | Yes | Automation row in runtime table | + +--- + +## What was checked and found sound + +- Identifier regex and keyed `content`/`children` with no `id` field (§1.3, §17.8). +- Strict unknown-field policy as a general rule (the `automation` hole is the exception, not the rule). +- Three coordinate spaces named; `width`/`height` only for `virtual`; `viewport` rejects non-stretch `fit`. +- Layer cap 16; group nest 8; polygon/polyline 512; spline 256; path commands 512; gradient stops 16; filters 4. +- Transform composition string matches the locked order; hierarchy `M_parent × M_local` and `z_parent + z_local`. +- Negative scale = mirror; scale 0 legal and draws nothing; skew magnitude ≥ 90 is `ERR_OUT_OF_BOUNDS`. +- Safe blend set closed; stroke-only declared-fill vs inherited-fill distinction. +- Path must start with `move`; zero-radius path arc is `ERR_OUT_OF_BOUNDS` not a line; bezier spline `3n+1`. +- Fonts limited to three generic families; live numeric text explicitly deferred with rationale. +- Component parameter types (four scalars) vs audio number-only; `inputs.*` not a §8.1 row. +- Particle integrator formula and `dt`-correct drag; life-ramp-without-lifetime = `ERR_SCHEMA_VALIDATION`; exponential-through-zero reuses `WARN_AUTOMATION_FALLBACK`. +- Unbounded emission diagnostic; initial `count` without lifetime legal; oldest-first eviction. +- `links` on `emitter` = `ERR_UNKNOWN_FIELD`; ribbons as trail mode. +- Four §8.1 rows only; `visuals.layers..visible` absent on purpose; system `visible` bindable not automatable. +- Spawn refuse vs voice evict, with rationale; no `SCHEDULED` state; `0ms` release still enters `RELEASING`. +- Projection not automatable; grain stream exemption and rationale. +- Centralized ceiling table present; values labeled provisional pending 4h; runtimes may lower never raise. +- Diagnostic reuse policy (`ERR_COMPONENT_RECURSION`, `ERR_AUTOMATION_CONFLICT` widened, etc.) is consistent in intent. + +--- + +## 4d-specific recommendation (not a spec change, a sequencing note) + +Do not start the renderer against §17.16 as a closed gate. Minimum contract fixes before 4d code: + +1. Add `automation` to the §17.3 `visuals` key list. +2. Rewrite §17.16.14 to match §19.1’s four rows. +3. State Y-down + rotation sign in one sentence. +4. State box/ellipse local origin. +5. Give one numeric camera example (scene point → display point) that uses a single space for `V`. +6. Borrow §19.3 into the 4d scope list, or drop perspective from 4d traces. +7. Delete or define repeater `step`. +8. Fix “layer nesting” in trace 9 to group nesting / layer count. + +Until (3)–(5) land, two renderer cores can both “conform” and disagree on every rotated, zoomed, or letterboxed frame. diff --git a/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260906-kimi-k3.md b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260906-kimi-k3.md new file mode 100644 index 0000000..41464ae --- /dev/null +++ b/reviews/.completed-artifacts/visual-contract-review-abacus-ai-agent-20260906-kimi-k3.md @@ -0,0 +1,154 @@ +# Visual Contract Review — §17–§19 (rev 0.7) + +**Reviewer:** Abacus AI Agent (independent adversarial pass, pre–slice 4d) +**Scope:** §17 (scene/primitives/transforms/appearance), §18 (components/procedural systems/behaviors/fields), §19 (automation/lifecycle/camera/effects/ceilings), plus the cross-referenced shared/audio sections (§1.3, §2, §4, §6, §7, §8, §9, §10, §14.4–14.7, §15.11/15.14/15.15, §16.1–16.7) used as consistency anchors. +**Method:** from-scratch read; no other file in `reviews/` was opened before or during this pass. +**Line numbers** refer to `docs/XZBT_0-1_Format_Specification.md` rev 0.7. + +Result: **33 defects** (4 high, 13 medium, 16 low). All locked decisions were checked against the text; every one is present as claimed **except** "coherent noise is fully specified," which does not hold (H3). + +--- + +## High severity + +### H1. Perspective factor: §17.6 and §19.3 give two different, incompatible compositions + +- **Location:** §17.6 "Perspective scaling" (line 1630) vs §19.3 (line 2507). +- **What:** §17.6 says "an object's transform is **post-multiplied** by the uniform factor `focalLength / (focalLength + z)` about the camera's projection center." §19.3 says each object "additionally receives the uniform factor … about the projection center, exactly as 17.6 states, **applied after `V`**." Post-multiplying the object's local matrix by a scale applies the scale in *local* space (about the object's origin, also scaling its own translation); scaling after `V` about the projection center is a *display-space* operation. These produce different pixels whenever the object is off-center, and the projection center is not even expressible in local space, so the 17.6 phrasing is not realizable as written. "Exactly as 17.6 states" is false. A knock-on gap: §19.3 (line 2511) pins stroke/blur/glow/shadow scaling for camera *zoom* only; whether the perspective factor also scales stroke width is undecidable while the composition point is ambiguous. +- **Why it matters for 4d:** the camera/depth path is core renderer work, and acceptance traces 17.16 #6 and 19.7 #14 both assert "the documented factor" — but the document specifies two. Whichever an implementer picks, a second conforming renderer can pick the other and both cite the spec. + +### H2. `loop.count: "infinite"` is asserted legal and illegal in the same sentence + +- **Location:** §19.1 "Loop modes" (line 2398). +- **What:** "…it is legal **only on a persistent scope**, **and** an `infinite` loop on a spawned system with a finite `lifetime` (19.2) **is legal** and simply ends with the system." The second clause flatly contradicts the first. Remaining holes: an `infinite` loop on a spawned system *without* `lifetime` (which may run indefinitely until removed) is unaddressed, and no diagnostic is named for the illegal case (`ERR_SCHEMA_VALIDATION`? `ERR_VISUAL_LIMIT_EXCEEDED`?). +- **Why it matters for 4d:** automation-track validation ships with the document model the renderer core builds; this rule cannot be implemented, and 19.7 trace 2 cannot be extended to spawned scopes, until the sentence is rewritten with a single rule and a named error. + +### H3. Coherent noise is *not* fully specified, despite the locked decision and §18.7's own claim + +- **Location:** §18.7 "Coherent noise is normative" (lines 2246–2254); §18.6 `wander`/`twinkle`/`point-wander` (lines 2185, 2189, 2187); 18.10 trace 15–16 (line 2332–2333). +- **What:** The text fixes "3D gradient noise, twelve edge-midpoint gradients, 256-entry permutation shuffled from the field's own stream, quintic fade" — but omits everything that makes two implementations agree: (a) how the permutation table is **indexed** from a lattice coordinate (the hash composition, e.g. `perm[x+perm[y+perm[z]]]`, and any wrapping/doubling); (b) how the hash **selects** one of 12 gradients (256 is not divisible by 12 — `mod 12` biases four gradients); (c) the exact Fisher-Yates loop — "consuming one sample per entry" is 256 draws, which matches neither the standard backward (n−1 draws) nor forward variants, so the *table itself* differs across implementations; (d) the octave normalization — "normalized so the result stays within `[-1, 1]`" states a bound, not a formula (divide by `Σp^k`? by the analytic max?); (e) the derivative method for `curl`/`gradient` modes (analytic gradient of the faded noise vs finite differences, and at what epsilon) — trace 16 requires curl to be divergence-free "to within the documented tolerance," a tolerance documented nowhere. Separately, §18.6's `wander` and `twinkle` are driven by "a value-noise sample (18.7)" — but §18.7 defines **gradient** noise, a different function, and the sampling coordinates for behavior noise (a function of what? `t*rate` alone?) are never fixed. +- **Why it matters for 4d:** reproducibility of identical decisions from identical seeds is the format's core promise (§9.3), and every field-, wander-, twinkle-, point-wander-, and noise-displace-driven fixture depends on this function being byte-exact. Renderer core decides where noise evaluation lives and how the `visual` stream is plumbed; if the function is not pinned before fixtures are written, 18.10 traces 15–16 are unwriteable. + +### H4. §18.2's "every ValueSpec resolves once per particle" contradicts itself and §19.1's automation registry + +- **Location:** §18.2 "Per-particle resolution boundary" (line 2027) vs the field table it covers (lines 2002–2025) and §19.1 registry (line 2408); parallel text §18.4 (line 2124). +- **What:** "Every ValueSpec above resolves **once per particle, at that particle's creation**." The table above includes `count` and `rate` — which *drive* particle creation and cannot resolve per particle (circular), and `position`, `acceleration`, `drag` — which §19.1 then lists as **system-scope automatable** channels (`rate`, `position.x/y`, `acceleration.x/y/z`, `drag`). Automation is time-varying by definition; per-particle creation-time resolution is fixed by definition. The spec never says which wins: does automating `drag` rewrite live particles' drag each tick, or only re-base future creations? §18.4 carves `rate` and `burst[].at` out as emitter-level ("properties of the emitter, not of an item") but leaves `position`/`acceleration`/`drag` per item, so emitters inherit the same ambiguity. (The registry's omission of `velocity` while including `acceleration` suggests an intent — live channels vs creation channels — that is never stated.) +- **Why it matters for 4d:** who owns these values — the system or the item — determines the runtime data layout and the per-tick evaluation order the renderer core is built around. Guessing wrong here is a structural rework in 4e/4f, not a patch. + +--- + +## Medium severity + +### M1. The 128-track/2048-point automation limit is runtime-dependent but classified as an import-time authoring bound + +- **Location:** §19.1 "Limits" (line 2431); §19.5 two-kinds table (line 2563) and aggregate row (line 2582). +- **What:** The limit counts "across both scopes and **across every live spawned instance**" — a quantity that only exists at runtime — yet §19.5 lists it as "Authoring bound (19.1), not a shed," and authoring bounds are defined as "checked at import; the exhibit is rejected." Nothing says what happens when a `spawn` action at runtime pushes the live count past the bound: spawn refused (with `WARN_VISUAL_CEILING`? as 19.2's instance ceiling does)? Action failure with `ERR_VISUAL_LIMIT_EXCEEDED`? Exhibit failure? It is also unspecified whether a spawned *template's* tracks count once at import. +- **Why for 4d:** the import/runtime staging split is exactly what the validator half of renderer core encodes; 19.7 trace 6 asserts the numeric boundary but cannot be implemented without the runtime rule. + +### M2. §17.12 types `fill`/`stroke` as literal-only, but §18.1's normative example uses a `ref` ValueSpec as `fill` + +- **Location:** §17.12 table (lines 1766–1767) vs §18.1 example (line 1945: `"fill": { "ref": "inputs.tint" }`). +- **What:** `fill` is "`color`, paint object, or `null`" — a `{"ref": …}` is none of those. §17.14 licenses ValueSpec "exactly where each field table says so," and 17.12's table does not say so. The same question is unanswered for gradient stop colors (can a stop be `{"ref": "inputs.tint"}`?). As written, the spec's first visual-component example is invalid, and with it the whole `color`-typed component-parameter mechanism (18.1, line 1964) has no legal consumer. +- **Why for 4d:** paint parsing and style resolution are 4d core; the ValueSpec-vs-literal decision determines the shape of the entire style pipeline. + +### M3. §19.1's registry automates a repeater `step` block that does not exist + +- **Location:** §19.1 registry (line 2408) vs §18.5 field table (lines 2146–2155). +- **What:** "for `repeater`, the numeric fields of its `step` block." §18.5 defines `repeat`, `count`, `distribution`, `position`, `inputs`, `behaviors`, `fields`, `links` — no `step`. The row is dead text (or a remnant of a dropped design), and with it the only repeater automation surface. +- **Why for 4d:** target validation must assign `ERR_UNSUPPORTED_TARGET` vs `ERR_INVALID_REFERENCE` per the registry; a registry entry whose referent doesn't exist can't be classified, and 19.7 trace 4's expectations wobble with it. + +### M4. §19.5's "centralized" ceiling table omits enforced ceilings and mis-cites one + +- **Location:** §19.5 restated table (lines 2587–2614) vs §17.13 (line 1829: path commands 512), §18.2/§18.4 (lines 2008, 2107: burst entries 16), §18.3 (line 2076: grid columns/rows 256), §15.14 (line 1148: oscillator partials 64), §15.10 (line 1042: resonator modes 16). +- **What:** The table claims "every ceiling the engine enforces appears here," but path commands (512), burst entries (16), and grid dimensions (256) are absent, as are custom oscillator partials (64) from the audio side. The "Resonator modes" row gives the value as "Per section 14" with "Fixed in: 15.10" — the value 16 lives in 15.10; "section 14" is a stale citation. +- **Why for 4d:** 4d implements `path` rendering; an implementer coding limits from the central table — the table's stated purpose — will ship without the 512-command bound. + +### M5. The offscreen-buffer ceiling's shed rule doesn't cover layer buffers, and its warning has no rate limit + +- **Location:** §17.5 (line 1620) vs §19.5 aggregate table (line 2578). +- **What:** Layers with non-default `opacity`/`blend` "require an offscreen compositing buffer" counted against the 19.5 budget of 16 — but the shed rule speaks only of "the excess **objects**" drawn "without their non-default `blend`, `mask`, `blur`, `glow`, or `filters`, farthest-`z` first." There is no rule for a *layer* whose buffer is shed (draw at opacity 1? drop the blend? in what order relative to object sheds?). The `WARN_VISUAL_APPROXIMATION` this shed raises also has no stated rate (per object? per frame? once ever?) — unlike every other diagnostic in the table, and unlike the conic-fallback and post-effect uses of the same code, which are explicitly "once per instance." +- **Why for 4d:** the compositor's buffer allocator and degradation path are renderer core. + +### M6. Emitted items and repeater copies have no draw-order rule in their layer + +- **Location:** §18.2 "Drawing" (line 2059) — particles only; §18.4 and §18.5 have no drawing paragraph; §18.8 (line 2294) references "the system's own depth position (18.2)." +- **What:** Particles get an explicit rule: drawn as one unit, system's sort key is its lowest-`z` particle, internal order `z` descending then creation ordinal. Nothing says whether an emitter's items or a repeater's copies draw as one unit (and at what `z`) or interleave per-item with `graphic` objects in the layer's depth sort. §18.8's link drawing rule cross-references a per-system depth position that only §18.2 defines, and only for particles — so links on a repeater (explicitly legal) have an undefined depth position. +- **Why for 4d:** the layer sort and scene-graph structure are built in 4d; the unit-vs-interleave decision is architectural, not a detail. + +### M7. §18.3 distribution math has holes that break its own "byte-identical" guarantee + +- **Location:** §18.3 (lines 2067–2083); relatedly §18.6 `follow-path` (line 2186). +- **What:** (a) `depth`'s `linear` and `exponential` curves have no formulas — "biasing items toward `near`" admits many functions (`u²`, `1−√(1−u)`, any exponential rate). (b) `ring`/`path` `even` modes have no placement formula at all ("distributes angle evenly by index" — `i/n` or `i/(n−1)`? measured from `startAngle`?); `line` `even` uses `i/(count−1)`, which divides by zero at `n = 1` — NaN, which §2 forbids outright (the `count = 1` guard exists for `repeat.fraction` in §18.5 but was not carried here). (c) The normative sample-consumption order covers `x,y,z` and polar forms but not select-then-place distributions: `rectangle` `perimeter` needs an edge-selection sample and an along-edge sample, in an unstated order. (d) `path` `random` is "uniform by arc length," and `follow-path` moves at scene units/second — both require an arc-length parameterization (flattening tolerance? adaptive quadrature?) that is never fixed, contradicting §18.3's stated goal that placements be "byte-identical across renderers." +- **Why for 4d:** placement resolves at instantiation — a renderer-core code path — and 18.10 trace 10 demands documented positions from a fixed seed; without formulas there is nothing to document. + +### M8. `fit`'s default collides with the `viewport` validation rule + +- **Location:** §17.4 (lines 1591, 1600). +- **What:** `fit` defaults to `contain`; for `viewport`, "`fit` is ignored and a `fit` other than `stretch` is `ERR_SCHEMA_VALIDATION`." Read literally, an absent `fit` resolves to `contain` — "a `fit` other than `stretch`" — so every viewport-space exhibit that doesn't explicitly write `fit: "stretch"` is invalid. If "ignored" is meant to cover absence, the default never engages and the sentence contradicts itself. Either way it needs an "explicitly authored" qualifier. +- **Why for 4d:** scene/fit resolution is the subject of 17.16 trace 1 — the first acceptance trace of the slice. + +### M9. Post-effect `blur`/`bloom` radii are in undefined units + +- **Location:** §19.4 table (lines 2534, 2537). +- **What:** `blur.radius` is "Scene units, scaled by camera `zoom`"; `bloom.radius` is "Scene units" without even the zoom note. But post-effects run on the composited *frame* (line 2515) — display space, after `fit` resolution, and after per-object perspective factors that make scene scale non-uniform across the frame. The scene→display conversion for the post chain (fit scale? coordinate space? which layer's parallax?) is never defined. `scanlines` and `grain` in the same table use device pixels, so the scene-unit choice is conspicuous. +- **Why for 4d:** pass sizing and backing-store math for blur/bloom are set up by the renderer core; two renderers will pick different conversions and both will be "conforming." + +### M10. §17.16 trace 14 contradicts §8.1/§19.1 (and §19.7 trace 7) as of rev 0.7 + +- **Location:** §17.16 trace 14 (line 1910) vs §8.1 rows (lines 329–332), §19.1 (lines 2416–2427), §19.7 trace 7 (line 2645). +- **What:** Trace 14: "A binding, `set`, or `override` addressing **any visual property** is `ERR_UNSUPPORTED_TARGET`." That was true when 4a landed; it is false now — `visuals.camera.zoom` et al. are legal binding targets, and 19.7 trace 7 requires them to *work*. §19.1 (line 2425) carefully preserves §17.14 and 18.10 trace 19 ("survive this slice unchanged") but overlooks trace 14's blanket wording, which was not scoped to per-object properties. +- **Why for 4d:** 17.16 *is* the 4d acceptance suite; as written it fails the document model 4d is required to build. + +### M11. §18.6 behavior field contracts are incomplete against §12's template, and `oscillate`'s waveforms are undefined + +- **Location:** §18.6 table (lines 2179–2197); §12 (line 542). +- **What:** §12 requires every construct to record required/optional fields, ranges, and defaults. The behavior table lists field names only: `oscillate` has no stated default for `center` (0? the authored base?), no requiredness for `amplitude`/`frequency`, no `phase` default; `pulse` never defines how `duty` splits between rise and fall, nor its `curve` default; `wander`, `twinkle`, `noise-displace` give no ranges or defaults (`noise-displace`'s `scale`/`speed`/`octaves`/`persistence` presumably borrow 18.7's — unstated); `rotate.speed`, `orbit.radius`/`speed`/`phase`, `follow-path.offset`, `attract`/`repel.strength`, `morph.curve`, `drift.damping` likewise. And the waveform functions themselves — `triangle`, `square`, `sawtooth` polarity, duty, and phase origin in `w(frequency·t + phase/360)` — are never defined, so even with defaults the behavior is not reproducible across renderers. +- **Why for 4d:** the document parser and object model ship behavior schemas with renderer core; every missing default is a guess two implementers will make differently. + +### M12. `vortex` (and `orbit`) direction is ambiguous in the y-down scene space + +- **Location:** §18.7 (line 2239), §18.6 `orbit` (line 2184) vs §17.2 (line 1543) and §17.4 (lines 1599–1601). +- **What:** All three coordinate spaces put +y *downward*; §17.2's positive angles turn toward +y, i.e. clockwise *on screen*. `vortex` is "perpendicular to the outward ray, **counter-clockwise** for positive `strength`" without saying whether that is display-visual counter-clockwise or the 17.2 convention — in a y-down space those are opposite vectors. `orbit` never states the direction of positive `speed` at all. +- **Why for 4d:** sign conventions get baked into the field evaluator and behavior math; a flipped sign passes every automated trace that isn't written and fails the visual acceptance that is. + +### M13. `links` block: dangling `style` default, undefined fade case, and an over-broad static check + +- **Location:** §18.8 links table (lines 2281–2296). +- **What:** (a) `style` defaults to "the system's style" — no system type has a `style` field (§17.7, §18.2, §18.4, §18.5 define none); the default has no referent. (b) `fadeWithDistance` fades "from full at distance `0` to zero at `maxDistance`," but for rule `nearest` `maxDistance` is optional — fading with it absent is undefined. (c) The 256-population validation uses `particles.capacity` as the static proxy, which rejects systems whose live count can never approach it (e.g. `capacity: 400`, `count: 10`, no `rate`/`burst` → rejected at import); the false positive is unacknowledged. And when `repeater.count` is a non-literal ValueSpec, the "statically known" precondition silently fails and the instantiation-time diagnostic is unstated. +- **Why for 4d:** links validation and default-style resolution land in the renderer core's system model. + +--- + +## Low severity + +- **L1 — §19.2 vs §10.1 (lines 2444, 2476, 483):** `cancelWithScenario` semantics are hollow. With `ownership: "persistent"` the instance transfers to the performance root, so scenario cleanup would not touch it regardless; meaning is given only to `false`. Either the default `true` lets a scenario cancel a root-owned instance (making the transfer hollow) or the flag is redundant. 19.7 trace 11 tests a distinction the text never defines. *(4d relevance: lifecycle flags ship in the system object model.)* +- **L2 — §19.5 (lines 2564, 2583) and §19.2 (line 2480):** `WARN_VISUAL_CEILING` has two unreconciled triggers — "once per ceiling per second" on any shed, and a separate "120 consecutive ticks" degradation report using the same code; and 19.2's spawn refusal "raises once" without saying whether the 1/sec rate limit applies. *(Renderer core emits these.)* +- **L3 — §17.16 trace 9 (line 1905):** "`9` layer nesting levels" — layers don't nest; presumably group nesting (already trace 4) or the 16-layer count was meant. *(Corrupts the 4d acceptance suite.)* +- **L4 — §19.7 trace 9 (line 2647) vs §19.2:** the trace requires "a second `remove` on a `DISPOSED` instance is a no-op"; §19.2 never states this (§16.3 states it for audio). A trace without normative basis. +- **L5 — §18.2/§18.4/§18.5 `count` fields (lines 2006, 2107, 2149):** integer-typed ValueSpecs with no rounding rule when they resolve non-integer (contrast §8.1's integer-target rounding and §13.2's half-away-from-zero); `burst[].count`'s resolution *timing* (emitter instantiation vs burst tick) is also unstated — §18.4 (line 2124) carves out `burst[].at` only. +- **L6 — §17.9 (line 1704) vs §17.6/§19.3:** per-point `z` "offsets the object's z for depth purposes" but the object "is sorted and drawn as one unit" — with points at differing `z`, which `z` drives the single perspective factor and fog fraction? `z_parent + z_local` (17.11) covers groups, not point offsets. A spline spanning z=0..100 needs one number; the spec doesn't say which. +- **L7 — §18.3 `path` distribution (line 2075):** the sibling-key form ("the key of a sibling `path`/`spline`/`polyline`/`polygon` object in the same container") has no possible referent — distributions live on systems, and systems have no sibling object containers (`render`/`emit`/`repeat` are singular). Dead syntax, or a missing referent such as a graphic system's `content`. +- **L8 — §18.6 `morph` (lines 2197, 2214):** the operand set is undefined for point-less primitives — compatibility keys on point count, which is meaningless for `rectangle`/`ellipse`/`arc`/`ring`/`text` and for `path` (commands, not points); whether morph there is `ERR_INVALID_BEHAVIOR_TARGET` or interpolates `size`/`radius` is unstated. "Reads the target's resolved points" is also ambiguous when the target's points are moving under `point-wander`. +- **L9 — §18.1 (lines 1966, 1976) vs §15.11 (line 1059):** visual component diagnostics diverge from the audio contract they claim to mirror: undeclared `inputs` key → `ERR_INVALID_REFERENCE` (visual) vs `ERR_UNKNOWN_FIELD` (audio); missing required parameter → `ERR_INVALID_REFERENCE` *at instantiation* (visual) vs import-time `ERR_SCHEMA_VALIDATION` (audio); supplied-value-out-of-range is specified for audio (`ERR_OUT_OF_BOUNDS`) and unstated for visual. The instantiation staging also means a persistent system passes import and fails at activation — a new failure mode the strict-validation posture elsewhere avoids. +- **L10 — §18.4/§18.5 (lines 2117, 2152) vs §18.1 (line 1974):** dual `inputs` paths — the `component` object carries its own `inputs`, and the emitter/repeater carries a separate system-level `inputs` "only when `emit`/`repeat` is a `component` object," both sampled per item/copy, with no merge or conflict rule when both are present. +- **L11 — §19.2 (line 2450) vs §1.3 (line 58):** "the `instances.*` runtime namespace section 1.3 reserves" — §1.3's prefix list omits `instances.*`; only §8.1/§8.3 mention it. Dangling cross-reference; the reservation 19.2 relies on doesn't exist where it says it does. +- **L12 — §19.2 (line 2472):** release "ramps the instance's composited opacity linearly from `1` to `0`" — systems have no `opacity` property (§17.7), so where the release factor multiplies the compositing stack is undefined, and "from 1" reads as absolute where audio's 16.4 says "from its current value"; the literal reading pops a 0.5-opacity instance to full before fading. +- **L13 — §19.4 (line 2532) vs §17.12 (line 1799):** `color-adjust` claims "the 17.12 filter semantics" but renames the parameters (`saturation` vs `saturate`, `hueRotate` vs `hue-rotate`) — an unforced inconsistency for implementers mapping one onto the other. §19.4 also never states the resolution boundary for effect parameters (presumably activation; §17.14 covers object fields). +- **L14 — §8.1 (line 329) vs §19.3 (line 2493):** the camera row's safety clamp is "the per-field range of 19.3," but `focalLength`'s range is the open interval "above `0`" — automation or binding driving it ≤ 0 has no finite bound to clamp *to*. +- **L15 — §19.2 (line 2439) vs §17.7 (line 1654):** the `lifecycle` row claims it is "Added to the 17.7 system field table by this section," but 17.7 already contains it in rev 0.7. Stale double-definition; harmless, but it signals the two tables can drift. +- **L16 — §19.5 (lines 2557, 2620) and §17.7–17.9:** no ceiling bounds declared systems per exhibit or authored objects per system, while governance "never sheds an authored persistent object or a declared system." A document can author unbounded per-frame draw load with no runtime recourse (audio bounds nodes per sound and voices; visuals bounds neither objects per system nor systems per exhibit). Deliberate or not, the gap is unacknowledged in a table that claims completeness. + +--- + +## Checked and found sound + +Verified directly against the text (presence, internal consistency, and cross-references), no defect found: + +- **All locked §17 decisions present and consistent:** degrees/0-+x/positive-toward-+y (17.2, applied to arc/ring/skew/conic); z sign, greater-z-first sort with document-key-order ties and stability (17.6); perspective factor formula and `z <= -focalLength` culling with no diagnostic (17.6, 19.3, traces 17.16#6 / 19.7#14 mutually consistent); orthographic z behavior; keyed objects with no `id` field (17.8); transform order `T(position+translate) × T(origin) × R × K × S × T(−origin)` and `M_parent × M_local`, `z_parent + z_local` (17.11); once-at-instantiation ValueSpec resolution and no procedural stream in rendering (17.14, 9.3); `layer` system-only with `ERR_UNKNOWN_FIELD` on objects (17.10); fog formula exact, per object, no diagnostic (17.6); conic fallback the sole appearance fallback, warn once per paint instance (17.12); three-font `font` enum (17.13); live readouts deliberately deferred (17.13). +- **All locked §18 decisions present:** fourteen primitives + `component` as fifteenth object type (17.9, 18.1); component parameter types number/boolean/string/color vs audio's number-only (18.1, 15.15); `inputs.*`/`repeat.*` construct scope with `ERR_INVALID_REFERENCE` and no §8.1 capability (18.1, 18.5); expansion path as the §9.3 stable key (18.1); the normative integrator `v ← (v + a·dt)·(1−drag)^dt; p ← p + v·dt` (18.2); life ramps interpolating once-resolved endpoints (18.2); fractional accumulator, cumulative `floor(rate·t)`, no reset on automated rate change (18.4); `ERR_UNBOUNDED_EMISSION` with `count`-alone legal (18.2/18.4); oldest-first eviction (18.2/18.4); `ERR_INVALID_DISTRIBUTION` for index-driven placement on continuous emission (18.3); normative sample order x,y,z / angle-before-radius (18.3, as far as it goes — see M7); noise parameters (3D, 12 gradients, 256-entry permutation, quintic fade) present in text (completeness defect H3 notwithstanding); `curl` default (18.7); ribbon as `trail.mode` (18.8); `links` as `ERR_UNKNOWN_FIELD` on emitters (18.8); morph compatibility rule (18.6). +- **All locked §19 decisions present:** exactly four system-level §8.1 rows, verified in the §8.1 table itself (lines 329–332) with `visuals.layers..visible` deliberately absent (19.1); two automation scopes with spawn-relative time origin and cross-scope `ERR_INVALID_REFERENCE` (19.1); 16.1 track shape plus `loop`, ping-pong count = full round trip (19.1); automatable registry broader than the §8.1 rows (19.1); behavior/track `ERR_AUTOMATION_CONFLICT` with pipeline-then-behaviors array-order composition (19.1); explicit `lifecycle` enum and `ERR_UNKNOWN_FIELD` on persistent systems (19.2, 17.7); spawn stream key `#` (19.2); five states plus `FAILED`, no `SCHEDULED`, with a stated reason (19.2); `release` default `0ms` with one `RELEASING` tick (19.2); spawn-at-ceiling refusal, never eviction (19.2); normative camera matrix with parallax on translation only (19.3 — algebra spot-checked: reduces to `T(c)·S·R·T(−x,−y)` at p=1 and to one-fifth camera translation at p=0.2, matching trace 13); `projection` authored once (19.3); seven effects in normative array order, blur/bloom two passes and the rest one, 4-entry/8-pass arithmetic checks out (19.4); reduced-resolution and unavailable-effect warnings once per effect instance, never substituted (19.4); `grain` the sole reproducibility exemption, consuming no stream, with a correct justification against §9.3/§14.6 precedent (19.4); §19.5 centralization with provisional values and the Phase-6 gap for scenario/timeline ceilings explicitly named. +- **Diagnostic-code parity:** every code introduced by 17.15 (5), 18.9 (7), and 19.6 (2) is present in §7 with matching stage and cause; §7's `ERR_AUTOMATION_CONFLICT` text is already widened to the visual scope exactly as 19.6 claims; §19.6's "two, and no more" holds. +- **§19.5 restated values** match their subsystem sources for every row checked (16 layers, 8 group nesting, 512 vertices, 256 spline points, 16 stops, 4 filters, 8 component nesting, 4096/512 capacities, 1024 repeater count, 8 behaviors, 8/4 fields, 128 trail, 1024 maxLinks, 4 effects, 32 radius, 64 spawned, 64/16 voices, 64/256 audio automation, 1024 dispatch units, 16 event depth, 256 hook actions). +- **§16.1 → §19.1 track-shape reuse:** field-for-field match (target/mode/interpolation/points, duration-literal `at`, strictly-increasing rule, hold-first/hold-last), with `loop` the only addition, as claimed. +- **State machine:** the visual machine is the audio machine minus `SCHEDULED`, transitions otherwise parallel, and the reasoning (no second clock) is stated; cleanup deadline interplay with §10.2 (shorter of authored release and 5s, `WARN_CLEANUP_FORCED`) is consistent. +- **17.16 / 18.10 / 19.7 trace lists** are internally consistent with their sections everywhere except the flagged items (M10, L3, L4). diff --git a/reviews/00-pre-phase6-reconciliation.md b/reviews/00-pre-phase6-reconciliation.md new file mode 100644 index 0000000..88935d9 --- /dev/null +++ b/reviews/00-pre-phase6-reconciliation.md @@ -0,0 +1,72 @@ +# Pre-Phase 6 Reconciliation Plan + +**Status:** draft — not yet actioned. Nothing in this plan has been executed; it records what needs to happen and in what order before Phase 6 (scenario director) work begins. + +**Why this exists:** two sessions worked in this repo on 2026-09-06. Committed history (`main` @ `0af58da`) carries only Phase 4 through slice 4d. Everything past that — slices 4e/4f/4g and a full Phase 5 (events/cadence) implementation, reported as 229/229 tests green — exists only as uncommitted working-tree changes, and is not in git history. Two independent review passes against that uncommitted tree (`reviews/02-triage.md`, Phase 4d–4g; `reviews/03-phase5-triage.md`, Phase 5) found confirmed defects, some Tier-1. Phase 6 is defined against a working scenario director sitting on top of the visual engine and the cadence/event subsystem, so it cannot be started on an unreconciled, unfixed foundation. + +This plan does not re-derive those findings — it sequences fixing and landing them. See the two triage docs for full technical detail; only what's needed for sequencing is repeated here. + +--- + +## 0. Preconditions (verify before anything else) + +1. Confirm no session is still writing to the tree: `find . -path ./.git -prune -o -type f -newermt '-10 minutes' -print`, twice, a minute apart. If anything is changing, stop. +2. Confirm current state matches this plan's assumptions: `git log --oneline -5` should show `0af58da` at HEAD of `main`; `git status --short` should show the modified/untracked set described in `claude/status.md`. +3. Get explicit sign-off from the user before committing any of the uncommitted tree under a new session's authorship — this was called out as a standing rule and isn't relaxed by this plan existing. + +## 1. Fix Tier-1 defects in the uncommitted tree, before it is treated as done + +These are correctness/security-boundary bugs, not style. Fix them in place in the working tree, in this order, then re-run the full suite before moving to reconciliation: + +| Priority | Source | Defect | Fix | +| --- | --- | --- | --- | +| 1 | Phase 5 F1+F2 | `actions.js` uses an invented diagnostic code (`ERR_SOUND_USAGE_MISMATCH`) and its underlying permission check has a logic bug (`permitsAction` short-circuits on "the sound allows some action context" instead of "the sound allows *this caller's* context") — a scenario-only sound is currently playable from a manual action. | Replace with `if (isActionCaller && !allowedUsage.includes(callerUsage)) throw ERR_UNSUPPORTED_TARGET`; delete `permitsAction`. Add the two missing test cases (manual-only invoked from scenario, scenario-only invoked from manual). | +| 2 | Phase 4 C1 | Production render path never creates compositing surfaces; a masked group or non-opaque layer throws, is caught in `app.js`, and permanently deactivates the visual subsystem. | Pass a real surface factory from `VisualSubsystem.render` (per R2's fix, not R3's guard-and-skip). Add a subsystem-level render test for a masked group and a non-opaque layer. | +| 3 | Phase 4 C2 | System-level `behaviors` arrays skip semantic validation (`validateBehaviors` only runs for object hosts), so six classes of invalid document fail at activation/runtime instead of import. | Run `validateBehaviors` at system scope during import, with `options.fields` set from system context. | +| 4 | Phase 5 F3 | Second invented diagnostic code, `ERR_UNKNOWN_PARAMETER`, for a check §20.11 doesn't actually require. | Either drop the check, or rename to `ERR_UNKNOWN_FIELD` (the spec's existing convention) and document the new normative surface in §20.11. | +| 5 | Phase 5 F4+F5 | Traces 7–10 of the Phase 5 acceptance suite exercise `triggerSound()`/`calculateEligiblePool()`, a dead duplicate scheduler with no production call site — not `advance()`→`fireClass()`, the only path a real exhibit runs. The two implementations disagree on recency-history semantics (F5), undetected because nothing calls both. | Rewrite Traces 7–10 to drive `update()`/`advance()` with a seeded RNG, matching Traces 11–14's existing pattern. Then delete `triggerSound()`/`calculateEligiblePool()` as dead code — don't reconcile them. | + +Remaining Phase 4 defects (C3–C20, B1, S1, and the P3 list in `02-triage.md`) are real but not blocking in the same way — see §2 below for how they're triaged, not skipped. + +## 2. Triage the remaining Phase 4 findings (C3–C20, B1, S1, P3s) + +`02-triage.md`'s own "Recommended correction order" table is the working list — don't re-litigate it, execute it, with one exception: promote anything from it that touches a code path Exhibit E or the scenario director will exercise (ownership/cleanup of visual instances, `remove` action semantics — **C20** — and non-integer resolved counts — **C18** — are the two most likely to matter early, since Phase 6 does spawn/cleanup accounting). Everything else in that table can land after Phase 6 starts, tracked as follow-up debt, provided it's written down as such rather than silently dropped. + +One open design question blocks nothing but should be decided, not deferred indefinitely: **C5**, whether `spawn.lifetime`/action lifetime accepts numeric milliseconds or the spec is restricted to the literal string form `parseDuration` currently requires. Flag it for the user; don't guess. + +## 3. Correct the documentation claims that outran the code + +- `docs/IMPLEMENTATION_STATUS.md` and the Phase 5 evidence doc (`docs/evidence/phase5/2026-09-06-phase5-events-cadence.md`) currently present `ERR_UNKNOWN_PARAMETER` and `ERR_SOUND_USAGE_MISMATCH` as normative contract surface (F7). Correct both once §1's fixes land — this is downstream cleanup, not a separate defect to fix independently. +- The working tree's `docs/XZBT_0-1_Format_Specification.md` claims revision 0.9 (adding §20) while committed history is at 0.8. Confirm 0.9 is intentional and ready to be the committed revision once the tree lands — it should not be committed with the F1/F3 invented codes still described as normative. +- Devlog entries are missing for slice 3c-2 (lifecycle and voices) and slice 4d. Per the standing rule in `claude/status.md`, the public-facing write-up (`.labyricorn/project/contents.lr`, the Claude project description) still waits for Phase 3 completion — do not re-offer that separately. Per-milestone devlog entries are not covered by that hold and should be backfilled for 3c-2 and 4d before or alongside this reconciliation, so the devlog record isn't missing entries for work that's about to be several phases behind HEAD. +- Two devlog entries (`visual-contract-before-the-renderer` citing `245af6e`, `closing-the-visual-contract` citing `d3cd688`) cite `source_commit` values that aren't yet ancestors of the pushed branch, per the devlog's own `AGENTS.md` rule. This resolves naturally once `main` is pushed (§5) but is worth a final check afterward. + +## 4. Land the reconciled tree + +Once §1–3 are done and `npm test` (all phase suites) is green against the fixed code: + +1. Re-run `tools/verify-spec-contract.py` against the spec text to confirm it's still internally consistent after any §20 corrections. +2. Stage and commit in slices that mirror the phase boundaries already in use elsewhere in this repo, rather than one giant commit — e.g. one commit for the 4e slice (noise/distributions/behaviors/fields/systems/motion/particles/trails/links), one for 4f (execution/bundling), one for 4g (challenges/exhibits), one for Phase 5 (cadence/events), one for the spec revision to 0.9. This matches the existing commit granularity (`1bc4901`, `6587d3e`, `699492f`, `0af58da`) and keeps `git bisect` useful. +3. Use `git -c user.name='Labyricorn' -c user.email='labyricorn@gmail.com'` for authorship, per the working-environment notes. +4. New untracked directories not part of the runtime (`.abacusai/`, `"Claude outputs/"`, `.labyricorn/devlog/second-test-entry/`) should be reviewed individually — confirm with the user whether each belongs in the commit, in `.gitignore`, or should be deleted, rather than committing them by default via a blanket `git add`. + +## 5. Push and close out + +1. `main` is currently 10 commits ahead of `origin/main`, unpushed, even before this reconciliation's new commits. Confirm with the user before pushing — this plan doesn't assume permission to push on its own. +2. Push using the Gitea token in the gitignored `.env` via `GIT_ASKPASS`; never echo the values. +3. After push, re-check the two devlog `source_commit` citations from §3 — they should now resolve as ancestors. +4. Update `docs/IMPLEMENTATION_STATUS.md`'s top-line state summary to reflect the corrected, committed state (Phase 4 through 4g, Phase 5 complete, defects fixed) rather than the current draft language. + +## 6. Only then: Phase 6 entry criteria + +Phase 6 (per `docs/XZBT_0-1_Implementation_Plan.md`) can start once: + +- §1's Tier-1 fixes are committed and covered by tests that exercise the real production code path (not a duplicate/dead one). +- The visual engine (through 4g) and cadence/event subsystem (Phase 5) are on `main`, not just in a working tree. +- GC5 ownership/failure semantics are ready to be re-run "with real resource counters" per the Phase 6 plan entry — confirm the counters Phase 6 needs actually exist post-reconciliation, since several of the Phase 4 fixes (C1, C20) touch instance lifecycle/removal accounting that Phase 6's ownership and cleanup work will depend on. + +Not required before Phase 6 starts (tracked separately, per `claude/status.md`'s "still user-observed" list): the Phase 4h GC6 hardware measurement, the PRD 130 visual challenge judged on a real display, and Phase 3's audible acceptance. Those are real open items but don't block scenario-director work the way the Tier-1 fixes above do. + +--- + +No files were changed in producing this plan. §1–5 above are proposed actions awaiting the user's go-ahead per this project's standing rule (summary + explicit permission before acting on the repo). diff --git a/reviews/02-triage.md b/reviews/02-triage.md new file mode 100644 index 0000000..056f1aa --- /dev/null +++ b/reviews/02-triage.md @@ -0,0 +1,386 @@ +# XZBT 0.1 Phase 4d–4g — code review triage + +Inputs: + +- **R1** — [review-173154-a7c3e91b](review-2026-09-06-unknown-model-173154-a7c3e91b.md) (4 findings, code-focused). +- **R2** — [review-180353-6b68e324](review-2026-09-06-unknown-model-180353-6b68e324.md) (9 findings, deep runtime probes). +- **R3** — [review-183137-f3a91c2d](review-2026-09-06-unknown-model-183137-f3a91c2d.md) (8 numbered + 6 P3, broad coverage). +- **SR** — [spec-review-173816-c4f8a2d1](spec-review-2026-09-06-unknown-model-173816-c4f8a2d1.md) (4 findings, spec-vs-impl traceability). + +This follows [01-triage.md](.completed-artifacts/01-triage.md) (visual contract §§17–19 specification triage) and [00-triage.md](.completed-artifacts/00-triage.md) (§§14–16 specification triage). Those triages addressed defects in the format specification text; this triage addresses defects in the **implementation code** and its conformance to the specification, discovered by four independent reviews of the Phase 4d–4g working tree at HEAD `0af58da`. + +All claims below were verified against the source files named, the specification (`docs/XZBT_0-1_Format_Specification.md` rev 0.8), and the test suites. No source files were modified. Where a reviewer's proposed fix is wrong or incomplete, the correction is noted. + +**Result: 20 distinct defects confirmed across 27 reported findings. No false positives.** 3 findings are duplicates (merged below), and 1 finding is confirmed but overstated in severity. All four reviews found real issues; R2 and R3 are substantially stronger — each found issues missed by the other three. The overlap pattern demonstrates the value of independent multi-pass review: **only 4 of 20 defects were found by more than one reviewer.** + +--- + +## Scorecard + +| | R1 | R2 | R3 | SR | +| --- | :---: | :---: | :---: | :---: | +| Findings reported | 4 | 9 | 14 | 4 | +| Real | 4 | 9 | 14 | 4 | +| Unique catches | 0 | 4 | 8 | 0 | +| Proposed fix wrong/incomplete | 0 | 0 | 0 | 0 | +| Overstated severity | 0 | 0 | 1 (P2-7) | 0 | + +R2 is the strongest single pass: it is the only reviewer to identify the compositing surface gap (C1), the validator divergence (C6), the schema system-variant contradictions (C7), and the bundle self-containment regex weakness (C14). R3 is comparably strong, uniquely finding the velocity-integration gap (C3), the glow rendering defect (C10), the boolean coercion gap (C11), the face-motion smoothing error (C12), the trail fade defect (C13), the ring zero-sweep defect (C16), and several P3 issues. R1 and SR found nothing missed by R2 and R3, but independently confirmed critical issues. + +--- + +## Tier 1 — Production failures: crashes, silent subsystem shutdown, or spec violations at wrong lifecycle stage + +### C1. Production render path never creates compositing surfaces; masks crash, layer opacity silently ignored + +*Reported by: R2 §1. R3 P3.3 (partial). R1 did not find.* + +**Confirmed.** `VisualSubsystem.render` calls `renderFrame` without a `createSurface` factory. The mask branch of `drawNode` (`visual-canvas2d.js:233`) gets `null` from the dummy surface provider and throws `TypeError`. `app.js:42-49` catches, logs, and calls `visual.deactivate()` — permanent visual shutdown on the first masked-group frame. Layer `opacity`/`blend` are only applied in the buffered composite branch (`visual-canvas2d.js:236`); without a surface pool, layer opacity is silently dropped — a bindable 8.1 property does nothing in the browser. + +R3 P3.3 identifies the same null-surface crash but proposes only a guard/null-skip with warning and considers re-attaching visuals on a later frame. **R2's fix is more complete:** pass a surface factory from `VisualSubsystem.render` and add a render-level test exercising a masked group and a non-opaque layer through the subsystem, not just `renderFrame` with a manually-injected factory. + +**Files:** `src/runtime/visual-subsystem.js:61-63`, `src/runtime/visual-canvas2d.js:99,230-243`, `src/runtime/app.js:42-49`. + +**Correction:** Pass a surface factory (`OffscreenCanvas` or `document.createElement('canvas')` fallback) from `VisualSubsystem.render`. Add subsystem-level test for masked groups and non-opaque layers. Consider whether `app.js` should re-attach visuals on subsequent frames rather than permanently deactivating. + +--- + +### C2. System-level `behaviors` arrays bypass semantic validation; accepted documents fail at wrong lifecycle stage + +*Reported by: R2 §2. R3 P1-3 (same defect, different angle). R1 did not find.* + +**Confirmed.** `visual-validation.js:332-333` checks only array length for system `behaviors`; `validateBehaviors` at `:614` runs only for object hosts. Six classes of semantic error (unknown type, invalid property channel, undeclared field, point-wander on point render, follow-path conflicts, and more) import clean and then throw at activation or silently no-op at runtime. Spec §18.9/18.10 trace 12 requires these errors at import (`ERR_INVALID_BEHAVIOR_TYPE`, `ERR_INVALID_BEHAVIOR_TARGET`, `ERR_INVALID_REFERENCE`, `ERR_SCHEMA_VALIDATION`). + +R2 and R3 converge on the same fix with different emphasis. R2 focuses on the system-scope call to `validateBehaviors` with field context; R3 adds that `options.fields` must be set and behavior/target incompatibility (e.g. point-wander on a non-point-list template) should be rejected before activation. + +**Files:** `src/runtime/visual-validation.js:332-333, :614, :653, :789`. + +**Correction:** Run `validateBehaviors` for system-level `behaviors` arrays at import, with `options.fields` set from the system context. Reject point-list/morph behaviors at system scope unless the template provides point lists. Reject unknown types, invalid references, and invalid targets with the documented diagnostic codes. + +--- + +### C3. Velocity-accumulating behaviors are silently inert on graphic objects and repeater copies + +*Reported by: R3 P1-2 only.* + +**Confirmed.** Graphic-object hosts run `advanceItem` with a fake `{type:'repeater'}` context (`visual-motion.js:47`); the integrator in `visual-systems.js:314-325` is gated by `this.type !== 'repeater'`. Behaviors like `attract`, `repel`, `bounce`, `field-follow` force/velocity, and `wander` accumulate velocity (`item.vx`) but displacement is never applied. Probe: graphic point with `attract {x:500,y:300}, strength:80` had `vx ≈ 0.235` but remained at (0,0) after 2s of ticks. `drift` works on the same host, masking the gap in fixtures. Spec §18.6: "the integrator of 18.2 turns into displacement." + +**Files:** `src/runtime/visual-motion.js:47`, `src/runtime/visual-systems.js:314-325`, `src/runtime/visual-behaviors.js:307-319, 332`. + +**Correction:** Integrate `vx/vy` for object/repeater hosts in `advanceVisualMotion`, or reject velocity-accumulating behaviors on hosts without an integrator at semantic validation with the documented diagnostic code. + +--- + +### C4. `morph` corrupts point `z` to NaN on z-less geometry; poisons perspective projection + +*Reported by: R2 §3. R3 P1-1 (same defect). R1 §3 (related, lower severity assessment).* + +**Confirmed.** `visual-behaviors.js:348`: `item.points[index].z += ((target[index].z ?? 0) - item.points[index].z) * amount` — the base `item.points[index].z` is `undefined`, so `undefined + … → NaN`. Under perspective projection, NaN flows through `primitiveSubpaths` into plan coordinates; `project()` guards with `subpath.start[2] ?? 0`, which does not catch NaN (not nullish) → coordinates become NaN → object disappears. Orthographic hides this because `z` is unused; the current suite has no perspective-morph case. + +R3 provides the most precise fix: guard both sides as `(item.points[index].z ?? 0)`, matching `point-wander`'s existing guard at line 235. + +R1's §3 is a different but related issue: bloom/color-adjust unclamped channel math and blur radius capped at `max(width,height)`. **Not a duplicate of C4** — see C15 below. + +**Files:** `src/runtime/visual-behaviors.js:348`. + +**Correction:** `item.points[index].z += ((target[index].z ?? 0) - (item.points[index].z ?? 0)) * amount;`. Add a perspective-scene morph test asserting finite plan coordinates. + +--- + +### C5. `spawn.lifetime` / action lifetime: `parseDuration` rejects non-string values + +*Reported by: R1 §1. R2 §8. SR §1. All converge.* + +**Confirmed.** `visual-engine.js:466-468` always calls `parseDuration(sampleTree(…))`, which requires `typeof value === 'string'` (`types.js:55-56`). A numeric or already-resolved lifetime throws `ERR_INVALID_DURATION` instead of applying the duration. The spec (§6.1, §19.2) defines `spawn.lifetime` and `spawn.release` as `DurationSpec`, which includes the bounded `{random: …}` TimeSpec form. + +R1 proposes accepting already-numeric milliseconds. SR correctly identifies the design ambiguity: the Action Model (Phase 5/6) has not yet fixed `action.lifetime` typing, so this is a defect only for `spawn.lifetime` after `sampleTree`; action-lifetime typing needs a separate decision. R2 notes the dual-filing issue between spec and validator (validator is literal-only; spec says DurationSpec). + +**Files:** `src/runtime/visual-engine.js:466-468`, `src/runtime/actions.js:48-50`, `src/runtime/types.js:55-56`, `src/runtime/visual-validation.js:361-364`. + +**Correction:** Convert with `parseDuration` only when the value is a string; if already a finite number, treat as milliseconds. Alternatively, restrict the §19.2 table to "duration literal" — pick one and align spec, validator, and runtime. The validator's current literal-only check (`visual-validation.js:361-364`) is consistent with a restriction decision; the spec text needs to match whichever option is chosen. **Design decision required.** + +--- + +### C6. The two repo validators disagree: standalone CLI rejects legal visual bindings + +*Reported by: R2 §6 only.* + +**Confirmed.** `tools/validate-exhibit.mjs:572` falls through to `ERR_UNSUPPORTED_TARGET` for every `visuals.*` binding target. `exhibits/minimal-visual.xzbt` (committed in `6587d3e`) and `exhibits/exhibit-d.xzbt` (untracked) both FAIL the CLI validator while passing the production validator (`parseAndValidateExhibit`). The gc2/gc3 tests instantiate `ExhibitValidator` only with their own fixtures, so the divergence is uncaught. + +**Files:** `tools/validate-exhibit.mjs:572`, `src/runtime/validator.js`. + +**Correction:** Either delegate from `tools/validate-exhibit.mjs` to `src/runtime/validator.js` (single source of truth), or port the four §8.1 visual target families into `ExhibitValidator`. Add a test that runs the CLI validator over `exhibits/*.xzbt` and `exhibits/visual-challenges/*.xzbt` to keep both paths in sync. + +--- + +## Tier 2 — Spec violations, silent data corruption, or incorrect rendering (non-crashing) + +### C7. JSON Schema system variants contradict the validator and spec §§18.4, 19.2 + +*Reported by: R2 §7 only.* + +**Confirmed.** `schema/xzbt-0.1.schema.json` system variants (`:2878/:2920/:3081/:3229`) allow top-level `release`, `ownership`, `inputs`, `cancelWithScenario` — spec §19.2 and the validator (`visual-validation.js:314`) make each `ERR_UNKNOWN_FIELD`. The emitter variant allows `links` (validator rejects at `:325`). The repeater variant allows `rate`/`burst`/`limit`/`capacity` (see C8). The runtime never executes the schema, so this ships an inaccurate contract artifact rather than breaking imports. + +**Files:** `schema/xzbt-0.1.schema.json`. + +**Correction:** Move spawn-only fields out of the variants' property lists. Drop `links` from the emitter variant. Align the repeater variant with the §18.5 table. Reconcile required-field declarations (see C9). + +--- + +### C8. Repeater silently accepts emitter-only fields (`rate`/`burst`/`limit`/`capacity`/`trail`) + +*Reported by: R2 §5 only.* + +**Confirmed.** Spec §18.5 (line 2399): "A `repeater` has no `rate`, `burst`, `limit`, `capacity`, `lifetime`, or `inputs`; each is `ERR_UNKNOWN_FIELD`." Validation accepts all of them. `rate` alone then throws `ERR_UNBOUNDED_EMISSION` at activation (misleading for a system type with no emission); `rate` + `limit` passes and `rate` is silently ignored; `trail` silently records/draws trail history even though the field is not in the repeater table (the schema, inconsistently, *does* forbid `trail`). + +**Files:** `src/runtime/visual-validation.js:325-333`, `src/runtime/visual-systems.js:80,96`, `schema/xzbt-0.1.schema.json:3229`. + +**Correction:** Extend per-type unknown-field validation to reject `rate`/`burst`/`limit`/`capacity`/`trail` (and `fields` if unintended) on repeaters. Remove these from the schema's repeater variant. + +--- + +### C9. Procedural system admission gaps: required fields and emission semantics deferred to activation + +*Reported by: R3 P2-6 only.* + +**Confirmed.** `particles` without `render` (spec §18.2 table: required), `emitter` without `emit` (spec §18.4: required), and `particles` with `rate` and neither `lifetime` nor `limit` — all import VALID. Spec §18.2/18.4 and the semantic-stage list (§18.9) demand import-time rejection. Instead they pass validation and only fail (or silently draw nothing) at activation/instantiation. + +**Files:** `src/runtime/visual-validation.js`, `src/runtime/visual-systems.js:96-99`. + +**Correction:** Enforce per-type required fields at import. Enforce the static half of the unbounded-emission rule at import (runtime check stays for stochastic rates). + +--- + +### C10. Glow paints over the object interior; silently dropped on text + +*Reported by: R3 P2-2 only.* + +**Confirmed.** `visual-canvas2d.js:162-181`: the point disc is re-filled with `glow.color` (a white point with red glow renders as a solid red disc), shapes are re-stroked on the identical path with glow color overpainting the real stroke, and text gets no glow branch at all (silent, while the engine still grants text a buffer). Spec §17.12 adds glow *around* the result, not over it. + +**Files:** `src/runtime/visual-canvas2d.js:162-181`. + +**Correction:** Draw only the halo via a zero-offset blurred shadow of the original geometry without re-filling/re-stroking the path. Apply the same technique to `fillText`/`strokeText` for text. + +--- + +### C11. Boolean leaves accept any literal; `visible: 0` renders visible, `enabled: "false"` executes + +*Reported by: R3 P2-3 only.* + +**Confirmed.** `visual-validation.js:233` validates effect `enabled` and object/layer/system `visible` only as ValueSpecs (structure); consumers use `=== false` semantics. Spec §2 bans coercion: "a non-zero number will not be coerced into a boolean. Type mismatches produce `ERR_TYPE_MISMATCH`." Probes: `visible: 0` imports valid and the runtime draws the object (expectation: hidden); `enabled: "false"` and `enabled: 0` execute the effect (only exact `false` skips). + +**Files:** `src/runtime/visual-validation.js:233`, `src/runtime/visual-engine.js:529, :643-645, :698`. + +**Correction:** Type-check boolean leaves against the declared type at validation (`ERR_TYPE_MISMATCH`), applying the effect color-literal check pattern (`visual-validation.js:244-247`) to booleans. + +--- + +### C12. `face-motion` snaps on first tick and reverts on zero velocity + +*Reported by: R3 P2-4 only.* + +**Confirmed.** `visual-behaviors.js:279-283`: `const current = instance.state.rotation ?? target` initializes follow state to the *target*, so the first tick contributes the full turn regardless of smoothing. When XY speed drops below 1e-6, the behavior breaks, the fresh rotation bucket was zeroed, and the object pops back to the authored rotation. Spec §18.6: "An object with zero velocity holds its previous rotation." + +**Files:** `src/runtime/visual-behaviors.js:279-283`. + +**Correction:** Initialize `instance.state.rotation` lazily to the current drawn rotation on first tick. On the zero-velocity branch, emit the held (previous) value. + +--- + +### C13. Trail `fade` is a flat uniform alpha; head never keeps the item's own opacity + +*Reported by: R3 P2-5 only.* + +**Confirmed.** `visual-engine.js:1014, :1053, :1064`: line/ribbon mode draws the whole trail at the flat tail factor; nothing multiplies by the item's ramped `opacityMultiplier`. Spec §18.8: "`fade` … Opacity multiplier at the tail; the head keeps the item's own opacity." An item at ramped opacity 0.81 renders a full-strength head point but a line trail at flat alpha 0.4. + +**Files:** `src/runtime/visual-engine.js:1014, :1053, :1064`. + +**Correction:** Per-vertex/per-segment alpha from head `itemOpacity` to tail `itemOpacity * fade` (segmented strokes), or state the uniform-color approximation with `WARN_VISUAL_APPROXIMATION`. + +--- + +### C14. Bundle self-containment regex weakened past side-effect imports + +*Reported by: R2 §9 only.* + +**Confirmed.** `test/phase1-runtime.test.mjs:41`: the regex was changed from `\bimport\s+[^;(]` to `^[ \t]*import[ \t][^;(\n]*from[ \t]*['"]` (multiline), requiring `from`. A side-effect import (`import './chunk.js';`) in the bundle would no longer be caught. + +**Files:** `test/phase1-runtime.test.mjs:41`. + +**Correction:** Add a second assertion for `^[ \t]*import[ \t]*['"]` (side-effect form) so both statement shapes are covered while prose mentions remain tolerated. + +--- + +### C15. Bloom/color-adjust write unclamped channel math; blur radius capped at `max(width,height)` pixels + +*Reported by: R1 §3. SR §4 (spec side).* + +**Confirmed.** `visual-effects.js:4-5`: `bloom` adds `pixels + glow * intensity`; `color-adjust` can emit values outside `[0,1]` before `* 255`. `Uint8ClampedArray` hides overflow (no crash), but a full-frame-radius blur is O(pixels × max(edge)) and can hitch on large backing stores. Spec §19.5/19.4 defines blur and bloom radius as 32 scene units; the device radius from `effectPlan` can exceed this under high zoom. SR's finding confirms that `POST_EFFECTS` max may be 32 in scene units, but the pixel filter can still run at huge projected radii. + +**Files:** `src/runtime/visual-effects.js:4-5, :38, :74`. + +**Correction:** Clamp processed channels explicitly before assignment. Cap device blur radius by a pixel budget derived from the scene-unit maximum. Assert §19.4 ranges in execution tests. + +--- + +### C16. `ring` with equal start/end angles draws a visible radial spoke instead of nothing + +*Reported by: R3 P2-8 only.* + +**Confirmed.** `visual-geometry.js:348-351`: when `startAngle === endAngle`, `directedSweep` returns sweep 0, but the partial-sector branch still emits outer-point → `lineTo` inner-point → closed, which strokes as a straight radial line. Spec §17.9: "`d == 0` draws nothing and raises no diagnostic." The sibling `arc` primitive handles sweep 0 correctly. + +**Files:** `src/runtime/visual-geometry.js:348-351`. + +**Correction:** Return no segments when `sweep === 0` (keep the full-annulus branch). + +--- + +### C17. `morph`/`point-wander` on `path` objects: validation allows, runtime silently no-ops + +*Reported by: R2 §4 only.* + +**Confirmed.** `visual-validation.js:31-33` includes `'path'` in `POINT_LIST_TYPES`/`MORPH_TYPES`. `pointsOf` (`visual-motion.js:44`) returns raw command objects (`{op, to}`) whose points live in `to`; the behaviors read/write `.x`/`.y`/`.z` on the command object itself, producing junk keys while geometry is untouched. An accepted, authored behavior silently does nothing. + +**Files:** `src/runtime/visual-validation.js:31-33`, `src/runtime/visual-motion.js:44`, `src/runtime/visual-behaviors.js:225-236, 344-349`. + +**Correction:** Either drop `'path'` from `POINT_LIST_TYPES`/`MORPH_TYPES` until command-point addressing exists, or make `pointsOf`/the behaviors address `command.to` points for `move`/`line` ops. + +--- + +### C18. Resolved non-integer creation counts silently rounded instead of faulting + +*Reported by: R3 P2-7 only. **Overstated severity** — downgraded from P2 to borderline P2/P3.* + +**Confirmed.** `visual-systems.js:59, 63`: `Math.round` on resolved `count` and burst `count`. Spec §18.2: "an integer field whose resolved value is not an integer is `ERR_TYPE_MISMATCH` at that boundary rather than being rounded." The authoring error is unmarked. Severity is lower than R3's P2 because the behavior (rounding) is deterministic and reproducible — it masks an error but doesn't cause incorrect rendering in practice. + +**Files:** `src/runtime/visual-systems.js:59, 63`. + +**Correction:** After sampling, throw `RuntimeFault('ERR_TYPE_MISMATCH', …)` when `!Number.isInteger(resolved)` for `count` and each burst count. + +--- + +## Tier 3 — Low-severity, editorial, or testing gaps + +### C19. Local visual automation crashes on missing target object + +*Reported by: R1 §2. SR §2 (spec side).* + +**Confirmed.** `visual-automation.js:11-13`: `while (object?.children.some(…))` — if `find` returns `undefined`, `.some` is called on `undefined`. Should throw `RuntimeFault('ERR_INVALID_REFERENCE', …)` rather than an unguarded `TypeError`. + +SR correctly notes this should be `ERR_INVALID_REFERENCE` per §19.1 (undeclared object), not an untyped crash. R1 proposes `object?.children?.some(…)` with a no-op or fault. + +**Files:** `src/runtime/visual-automation.js:11-13`. + +**Correction:** Use `object?.children?.some(…)`. When `object` is missing, throw `RuntimeFault('ERR_INVALID_REFERENCE', …)`. + +> [!NOTE] +> R1 rated this P1; however, the validator is supposed to reject bad targets at import. The runtime crash occurs only for exhibit-scope fallbacks and spawned substitutes that bypass validation. Downgraded to T3 for triage because the happy path (validated documents) is not affected. + +--- + +### C20. `remove` actions always report `executed` even when the instance ID is unknown + +*Reported by: R1 §4 only.* + +**Confirmed.** `actions.js:53-55`, `visual-engine.js:475`: `this.instances.get(id)?.remove(this.time)` — callers cannot distinguish a successful release from a no-op. Low severity because spawn returns the ID and tests use that ID. + +**Files:** `src/runtime/actions.js:53-55`, `src/runtime/visual-engine.js:475`. + +**Correction:** Return `refused` / `failed` when the map has no entry. + +--- + +## Additional P3 findings (from R3, confirmed) + +These are confirmed but lower-priority items from R3's P3 section: + +1. **Diagnostics `raised` list grows unbounded** — `visual-diagnostics.js:19, 39, 63`: push per tick, no pruning in `endTick`, per-frame `slice()` copy. Slow memory growth over long sessions. **Fix:** clear or ring-buffer after the per-tick plan copy. + +2. **Degenerate path arcs** — `visual-geometry.js:144-149, 153-186`: coincident-endpoint `arc` forces `acos(0)` and emits a phantom bulge; negative radius drawn mirrored instead of `ERR_OUT_OF_BOUNDS`. **Fix:** early-return on coincident endpoints; check raw radii before `Math.abs`. + +3. **Post-effect transient memory unbounded at backing ceiling** — `visual-effects.js:6-39`: each blur pass allocates full-frame copies at up to 4096 device-px. No tie-in to the §19.5 pass budget. **Fix:** tile the blur or cap effect radius relative to the backing store. + +4. **`window-traffic` challenge fixture** — `tools/visual-challenge-fixtures.mjs:56-60`: per-copy `wrap` bounds are ±30 px around each copy's own origin; cars cycle in place, never crossing the window. The 4g suite only checks finiteness/equality. **Fix:** if window-crossing is intended, use the window rect as bounds. If cell-stutter is intended, document it. + +5. **Schema has no consumer; already diverges from validator** — `schema/xzbt-0.1.schema.json`: no code in `src/tools/tests` uses the schema for validation. Schema marks per-system fields like `render`/`emit` as required that the validator admits without (see C9). **Fix:** consider validating exhibit fixtures against the schema in CI. + +--- + +## Spec-impl alignment items (from SR, confirmed) + +### S1. Required traces 19.7.3 and 19.7.4 are not implemented as specified + +*Reported by: SR §3 only.* + +**Confirmed.** `test/phase4-execution.test.mjs` "19.7.3" only checks one exhibit-scope stream; "19.7.4" only checks `ERR_AUTOMATION_CONFLICT`. The spec requires: (3) system-scope `at` from instantiation — two spawns 4s apart produce identical curves offset by 4s; (4) cross-scope / undeclared / out-of-registry targets raise `ERR_INVALID_REFERENCE` vs `ERR_UNSUPPORTED_TARGET`. + +**Files:** `test/phase4-execution.test.mjs`. + +**Correction:** Add the two-spawn 4s offset assertion and the four negative target cases from §19.7.4. These are test gaps, not runtime defects. + +--- + +## Canvas 2D backend rendering gaps (from R3, confirmed but implementation-layer) + +### B1. Per-object buffer grants not consumed by the canvas backend; §17.12 stage order realized per-op + +*Reported by: R3 P2-1 only.* + +**Confirmed.** `visual-canvas2d.js:88-96, 97-106, 227-241` never reads `node.buffered`; `visual-engine.js:1171-1201` allocates per-object buffers and sets `plan.buffered`. Fill, stroke, glow, and filter ops each composite individually under the node's blend/alpha instead of the object being rasterized once and composited once (spec §17.12 buffer table/stage order). Observable: double-blending on translucent/blended stroke-over-fill overlap. + +**Files:** `src/runtime/visual-canvas2d.js:88-96, 97-106, 227-241`, `src/runtime/visual-engine.js:1171-1201`. + +**Correction:** When `node.buffered` is true, draw the node (subtree for groups) into a pool surface with default state, then composite once with the node's alpha/blend, mirroring the layer path at `visual-canvas2d.js:234-241`. + +--- + +## Testing gaps summary + +No test covers: + +- Perspective-projected morph (C4) +- Velocity behaviors on graphic/repeater hosts (C3) +- Semantic validation on per-item behavior arrays (C2) +- Backend rendering of buffered nodes, glow, blur, filters, or masked groups through a real surface pool (C1, C10, B1) +- Zero-sweep ring / degenerate path arcs (C16, P3.2) +- Typed-boolean leaves (C11) +- Face-motion smoothing / zero-velocity (C12) +- Trail head-opacity / fade taper in line/ribbon (C13) +- Non-integer resolved counts (C18) +- Long-run diagnostics growth (P3.1) +- Post-effect runs against real readback (P3.3) +- System-scope spawn-relative automation offset (S1 / 19.7.3) +- Target-error taxonomy (S1 / 19.7.4) +- CLI validator over all exhibits (C6) +- Side-effect imports in bundle (C14) + +--- + +## Recommended correction order + +Ordered by impact and dependency. Items marked **(design)** require a decision before the fix can be written. + +| # | Files | Change | Kind | +| --- | --- | --- | --- | +| C1 | `visual-subsystem.js`, `visual-canvas2d.js`, `app.js` | Pass surface factory; add masked-group + layer-opacity render test | Fix + test | +| C2 | `visual-validation.js` | Run `validateBehaviors` for system-level behavior arrays at import | Fix + test | +| C3 | `visual-motion.js`, `visual-systems.js`, `visual-behaviors.js` | Integrate velocity for object/repeater hosts, or reject at validation | Fix + test | +| C4 | `visual-behaviors.js` | Guard both sides of the `z` delta; add perspective-morph test | Fix + test | +| C5 | `visual-engine.js`, `types.js`, `visual-validation.js`, spec | Accept numeric ms or restrict spec to literal — **(design)** | Design + fix | +| C6 | `tools/validate-exhibit.mjs`, `src/runtime/validator.js` | Unify or sync the two validators; add cross-exhibit test | Fix + test | +| C7 | `schema/xzbt-0.1.schema.json` | Remove spawn-only fields from variants; drop `links` from emitter | Schema fix | +| C8 | `visual-validation.js`, schema | Reject emitter-only fields on repeaters; align schema | Fix + schema | +| C9 | `visual-validation.js` | Enforce per-type required fields at import | Fix + test | +| C10 | `visual-canvas2d.js` | Draw glow as blurred shadow halo, not re-fill/re-stroke; add text glow | Fix | +| C11 | `visual-validation.js` | Type-check boolean leaves (`ERR_TYPE_MISMATCH`) | Fix + test | +| C12 | `visual-behaviors.js` | Lazy init rotation; hold previous on zero velocity | Fix + test | +| C13 | `visual-engine.js` | Per-segment alpha taper from head to tail | Fix + test | +| C14 | `test/phase1-runtime.test.mjs` | Add side-effect import assertion | Test | +| C15 | `visual-effects.js` | Clamp channels; cap device radius to pixel budget | Fix + test | +| C16 | `visual-geometry.js` | Return no segments when sweep === 0 | Fix + test | +| C17 | `visual-validation.js`, `visual-motion.js`, `visual-behaviors.js` | Drop `'path'` from morph/wander types, or address `command.to` | Fix | +| C18 | `visual-systems.js` | Throw `ERR_TYPE_MISMATCH` on non-integer resolved count | Fix + test | +| C19 | `visual-automation.js` | Guard `?.children?.some`; throw `ERR_INVALID_REFERENCE` | Fix | +| C20 | `actions.js`, `visual-engine.js` | Return `refused`/`failed` for unknown instance ID | Fix | +| B1 | `visual-canvas2d.js`, `visual-engine.js` | Consume `node.buffered` grants in the canvas backend | Fix + test | +| S1 | `test/phase4-execution.test.mjs` | Add 19.7.3 spawn-offset and 19.7.4 target-error tests | Test | diff --git a/reviews/03-phase5-triage.md b/reviews/03-phase5-triage.md new file mode 100644 index 0000000..6c40ea5 --- /dev/null +++ b/reviews/03-phase5-triage.md @@ -0,0 +1,116 @@ +# XZBT 0.1 Phase 5 — Cadence and Event Subsystems: code review triage + +**Scope:** independent verification of the Phase 5 implementation (`src/runtime/cadence.js`, `src/runtime/cadence-validation.js`, `src/runtime/actions.js`, and the `cadence`/`events`/`SoundCadence` schema definitions) against Format Specification revision 0.9, §20 ("Cadence and Event Subsystems Contract"). Requested because Phase 5 was reported complete (`docs/evidence/phase5/2026-09-06-phase5-events-cadence.md`, 229/229 tests passing) and needed review against the spec text itself rather than against its own self-authored evidence doc. + +**Method:** read §20 normatively line by line, then checked the runtime code, the JSON Schema, and `test/phase5-cadence.test.mjs` against it directly — the same method as `01-triage.md` and `02-triage.md`. `npm test` was re-run and confirmed green (229/229) independently of the evidence doc's claim. `tools/verify-spec-contract.py` was re-run and confirms the *specification text* is internally consistent (46 declared codes, 43 used, all resolve; 2 unresolved cross-refs are pre-existing and unrelated to §20). That script checks codes used *in the spec* against the §7 table — it does not, and cannot, check codes used *in the code* against the spec, which is where the defects below live. All Phase 5 changes are currently uncommitted in the working tree (`git status` shows them as modified/untracked against HEAD `0af58da`). + +**Result: the automatic-scheduling algorithm (§20.5) itself appears correctly implemented in the code path that actually runs (`CadenceSubsystem.advance` → `fireClass`). The defects are concentrated in (1) two invented diagnostic codes that do not exist anywhere in the specification, one of which masks a real, verifiable permission-check bug, and (2) four of the fourteen acceptance traces exercising a parallel/duplicate implementation instead of the production code path they are supposed to certify.** Nothing here is a matter of interpretation — every finding below is anchored to an exact spec line and an exact code line. + +--- + +## Tier 1 — Confirmed defects + +### F1. `ERR_SOUND_USAGE_MISMATCH` does not exist in the specification; the spec requires `ERR_UNSUPPORTED_TARGET` for this exact violation + +**Locations:** `src/runtime/actions.js:189,192`; `test/phase5-cadence.test.mjs:365-415`; `docs/evidence/phase5/...md` §1, Trace 6; `docs/IMPLEMENTATION_STATUS.md:158`. + +§20.2.2 states plainly: *"A sound action executed from a manual context naming a sound whose `usage` does not include `"manual"` is rejected with `ERR_UNSUPPORTED_TARGET`."* §20.12 repeats this for all three contexts (manual/scenario/cadence) and ends: *"Violations produce `ERR_UNSUPPORTED_TARGET`."* §20.14's diagnostic table lists `ERR_UNSUPPORTED_TARGET` for exactly this case and does **not** list `ERR_SOUND_USAGE_MISMATCH` anywhere. A full-text search of the 3,576-line specification confirms `ERR_SOUND_USAGE_MISMATCH` appears zero times outside the implementation and its own tests. + +The implementation throws this invented code instead of the mandated one, and the evidence doc, `IMPLEMENTATION_STATUS.md`, and the test file all cite it as if it were the normative code. This is not a naming nit — a runtime or validator that checks diagnostics against the §7/§20.14 registry (as `tools/verify-spec-contract.py` does for the spec text) would flag this as an unrecognized code if pointed at the implementation. + +**Fix:** replace `ERR_SOUND_USAGE_MISMATCH` with `ERR_UNSUPPORTED_TARGET` at both throw sites in `actions.js`, and update the test/evidence text accordingly. + +### F2. The usage-permission check it's protecting is itself broken — a sound can be triggered from a context its `usage` array does not authorize + +**Locations:** `src/runtime/actions.js:184-193`; demonstrated by the implementation's own test, `test/phase5-cadence.test.mjs:410-415`. + +```js +const isActionCaller = callerUsage === 'manual' || callerUsage === 'scenario'; +const permitsAction = allowedUsage.includes('manual') || allowedUsage.includes('scenario'); +if (isActionCaller && !permitsAction) { throw ERR_SOUND_USAGE_MISMATCH; } +if (!allowedUsage.includes(callerUsage) && !permitsAction) { throw ERR_SOUND_USAGE_MISMATCH; } +``` + +`permitsAction` is true whenever the sound's `usage` contains **either** `"manual"` **or** `"scenario"` — it never checks which one the caller actually needs. Walk the second `if` with `callerUsage = 'manual'` and `allowedUsage = ['scenario']` (a sound declared scenario-only): `!allowedUsage.includes('manual')` is `true`, but `!permitsAction` is `false` (since `'scenario'` is present) — the whole condition is `false`, so **no error is thrown** and the sound plays. §20.12 requires this to be rejected: a scenario-only sound must not be playable from a manual sound action. + +This is not a hypothetical — it's exactly what `test/phase5-cadence.test.mjs:410-415` (part of the Trace 6 test) does. It calls `executor.executeAction({ type: 'sound', sound: 'manualOrScenario' })` with no context override, so `callerUsage` defaults to `'manual'`. The target sound `manualOrScenario` is declared with `usage: ['scenario']` only (line 380 of the test file) — not `'manual'`. The test asserts this **succeeds** (`res2 === true`, zero diagnostics, one voice played). The test's own name ("manualOrScenario") suggests the author believed this sound was reachable from either context, but its declared `usage` says otherwise — the test is asserting the bug's behavior, not the spec's. + +**Fix:** the check should simply be `if (isActionCaller && !allowedUsage.includes(callerUsage)) throw ERR_UNSUPPORTED_TARGET` (per F1). Delete the `permitsAction` short-circuit entirely — it has no basis in §20.12, which conditions the check on the *caller's* context, not on whether the sound permits *some* action context. Add a test case with `usage: ['manual']` invoked from a scenario context and `usage: ['scenario']` invoked from a manual context — the two cases the current Trace 6 test never exercises. + +### F3. `ERR_UNKNOWN_PARAMETER` does not exist in the specification, and §20.11 does not actually require this rejection + +**Locations:** `src/runtime/actions.js:154-156`; `test/phase5-cadence.test.mjs:272-280`; evidence doc §1, Trace 3. + +§20.11 defines exactly two failure modes for an event's `with` map: type mismatch (`ERR_TYPE_MISMATCH`) and a missing required input with no default (`ERR_SCHEMA_VALIDATION`). It says nothing about rejecting a `with` key that isn't a declared input — and §20.15's own Trace 3 description (*"isolates inputs across concurrent invocations"*) doesn't mention it either. The implementation added this check on its own initiative, which is a defensible strictness choice, but it invented a diagnostic code to go with it (`ERR_UNKNOWN_PARAMETER`) that appears nowhere in the 46-code §7/§20.14 registry. The spec's own convention for "you named a field that doesn't exist here" is `ERR_UNKNOWN_FIELD` — used for exactly this shape of error everywhere else in §20 (the `cadence` container, `sounds..cadence`, `events.`, `inputs.`). + +**Fix:** either drop the check (it's not required) or rename it to `ERR_UNKNOWN_FIELD` for consistency with the rest of the section, and add a line to §20.11 documenting the behavior since it's new normative surface, not an interpretation of existing text. + +--- + +## Tier 2 — Test coverage gap: four of fourteen traces certify the wrong code path + +### F4. Traces 7, 8, 9, and 10 exercise `CadenceSubsystem.triggerSound()` / `calculateEligiblePool()`, not the production scheduling path `advance() → fireClass()` + +**Locations:** `test/phase5-cadence.test.mjs:440-599` (Traces 7, 8, 9, 10); `src/runtime/cadence.js:141-180` (`triggerSound`), `182-206` (`calculateEligiblePool`), `273-383` (`fireClass`, the method actually called from `advance()`). + +`CadenceSubsystem` has three separate, independently-written implementations of overlapping logic: + +1. `fireClass(cls)` — called from `advance()`, which is called from `update()`, which is the only method `performance.js` ever calls. **This is the only code path a real exhibit exercises.** It does its own cooldown check, its own overlap check, its own weight evaluation, its own anti-repetition lookup, and its own recency-history update. +2. `triggerSound(soundId, timeMs)` — a second, hand-rolled implementation of cooldown and overlap checking and recency-history update, called from nowhere in production code (`grep` across `src/runtime/` confirms zero call sites outside the test file). +3. `calculateEligiblePool(className, soundDefs, timeMs)` — a third, pure calculation helper that takes an externally-supplied `soundDefs` array (bypassing `fireClass`'s own eligibility filtering by class/usage/`when`/cooldown/overlap entirely) and only computes weights and multipliers. + +Cooldown (Trace 7), overlap (Trace 8), anti-repetition multipliers (Trace 9), and pool relaxation (Trace 10) are all tested exclusively via `triggerSound()` and `calculateEligiblePool()` (confirmed by grep: no test calls `.advance()` or `.update()` in any of these four test bodies). Traces 11–14 correctly use `.update()`/`.advance()`. + +This matters because `fireClass()` re-implements the same cooldown/overlap/weight/multiplier logic independently, and nothing currently checks that its version agrees with `triggerSound()`'s. It doesn't, in one respect (F5 below) — and the test suite, as written, cannot catch a divergence between the two because it only ever calls one of them per behavior. + +**Fix:** rewrite Traces 7–10 to drive the scheduler through `update()`/`advance()` with a document containing appropriately-configured sounds and a controlled RNG seed, the same way Traces 11–14 already do. This is very likely a straightforward test-code change, not a runtime change, since `fireClass()`'s own logic (independently re-derived from the spec text above) reads as correct. + +### F5. `triggerSound()` and `fireClass()` disagree on recency-history semantics (found because of F4, not by the suite) + +**Locations:** `src/runtime/cadence.js:170-174` vs. `:372-376`. + +§20.5.9 requires: *"Append the winning sound ID to the front of the class recency queue, trimming the queue to maximum depth 4."* Read together with §20.5.5's "N selections ago" phrasing, this is a plain FIFO of the last four **firings** (not the last four *distinct* sounds) — a sound selected twice in a row should occupy two of the four slots, not be moved to the front of an existing entry. + +- `fireClass()` (line 373: `cls.recencyHistory.unshift(chosen.id)`) implements this literally — no dedup, matching the spec text. +- `triggerSound()` (lines 170-172) instead searches for an existing occurrence and splices it out before unshifting (`indexOf` + `splice` + `unshift`), i.e. "move existing entry to the front" rather than "record a new firing." This is a different, incompatible interpretation of the same requirement, and it only exists in the code path the test suite actually calls (F4) — meaning Trace 9's "verify multipliers 0.0/0.25/0.50/0.75" claim is validated against the non-shipping interpretation. + +In practice the two produce identical multiplier results when only one sound ever repeats, and only diverge when two-plus sounds interleave with repeats within a 4-firing window — a case neither `triggerSound()`'s nor `fireClass()`'s tests currently construct. + +**Fix:** once F4 is addressed and Trace 9/10 exercise `fireClass()` directly, delete `triggerSound()` (dead code with no production caller and an incorrect implementation) rather than reconciling it — there's no reason to keep two schedulers. + +--- + +## Tier 3 — Minor / documentation + +### F6. `ActionSpec.ownership` and `sound` action `ownership` schema/validator accept `"persistent"`, which §20.12 does not mention + +**Locations:** `schema/xzbt-0.1.schema.json` (`ActionSpec.ownership` enum); `src/runtime/cadence-validation.js:316`. + +§20.12 documents exactly two values for a sound action's `ownership`: `"performance"` and `"scenario"`. Both the schema and `cadence-validation.js`'s allow-list also accept `"persistent"`. This may be intentional carry-over from the general Action Model 0.1 ownership vocabulary (§10.1 mentions persistent resource ownership generally), but §20.12 doesn't say so for the `sound` action specifically, and it isn't in the four ownership fields called out in the earlier phase's "Key decisions" pattern this project has used to record such extensions. Worth a one-line confirmation in §20.12 if it's deliberate, since it's currently unstated. + +### F7. `docs/IMPLEMENTATION_STATUS.md` and the evidence doc report the invented codes as if normative + +Both documents (`IMPLEMENTATION_STATUS.md:158`; evidence doc §1 and Trace 3/Trace 6 rows) present `ERR_UNKNOWN_PARAMETER` and `ERR_SOUND_USAGE_MISMATCH` as part of the delivered contract surface, which will need correction once F1/F3 are resolved — these are downstream of the code fix, not independent defects. + +--- + +## What is *not* a defect (checked and confirmed correct) + +- The core §20.5 selection algorithm as implemented in `fireClass()` — eligible-pool filtering by class/usage/`when`, cooldown exclusion, overlap exclusion, weight evaluation, the exact `[0.0, 0.25, 0.50, 0.75]` multiplier table applied by recency-queue position, pool relaxation when total effective weight is zero, and reschedule-by-uniform-sample-divided-by-intensity — all match §20.5 line for line. +- §20.6 minimum-gap and priority servicing (`advance()`'s `CADENCE_PRIORITY` loop with the `minGap` guard and single-fire-per-window `break`) matches §20.6, including deferred-class retention. +- §20.7 intensity clamping to `[0, 1]`, the `<= 1e-6` pause threshold, and ambient continuing to run under paused intensity — matches §20.7 exactly, including the ordering (`maintainAmbience()` runs before the intensity gate returns). +- §20.13.3's dispatch-budget accounting for `type: "event"` actions consuming **two** units (one for "attempting an action," a second explicit `consumeUnit` for "entering an event") looks like a double-count at first read but is the correct implementation of both stated rules in §20.13.3, not a bug. +- Event nesting depth: `depth > 16` throws at depth 17, matching the spec's "up to depth 16... depth 17 raises" boundary exactly. +- `docs/evidence/phase5/...md`'s claims about test counts (229/229), exhibit validation, and the build hash were independently reproduced. + +--- + +## Recommended sequencing + +1. F1 + F2 together (one code change: fix the permission check and use the correct diagnostic code) — this is the only finding with a real behavioral consequence (a security/contract-boundary leak, not just a wrong error name). +2. F3 (either remove or rename the invented code) — small, but blocks calling Phase 5's diagnostic surface conformant. +3. F4 rewrite (test-only) — restores actual confidence in Traces 7–10; do this before relying on "14/14 traces pass" as acceptance evidence. +4. F5 (delete dead code) — falls out of F4. +5. F6/F7 — documentation, whenever convenient. + +No specification or implementation file was changed in producing this triage. diff --git a/reviews/04-followup-debt.md b/reviews/04-followup-debt.md new file mode 100644 index 0000000..6437029 --- /dev/null +++ b/reviews/04-followup-debt.md @@ -0,0 +1,35 @@ +# Follow-up debt after the pre-Phase 6 reconciliation + +**Status:** current as of the reconciliation commits landing on `main` (2026-09-06). This is the written-down remainder that [`00-pre-phase6-reconciliation.md`](00-pre-phase6-reconciliation.md) §2 asks for: findings from the two triages that were *not* dropped silently, recorded here so Phase 6 can start without losing them. + +## What was closed + +Every Tier-1 finding of both triages is fixed in the code that shipped in these commits, and each is covered by a test that drives the production path rather than a duplicate of it: + +| Finding | Source | Closed by | +| --- | --- | --- | +| F1 + F2 — invented `ERR_SOUND_USAGE_MISMATCH`, and the `permitsAction` short-circuit that let a scenario-only sound play from a manual action | `03-phase5-triage.md` | `actions.js` now checks `allowedUsage.includes(callerUsage)` against the caller's own context and raises `ERR_UNSUPPORTED_TARGET` per §20.2.2/§20.12. `permitsAction` is deleted. | +| F3 — the second invented code, `ERR_UNKNOWN_PARAMETER` | `03-phase5-triage.md` | Resolved as the *rename* option, not the drop: the check raises `ERR_UNKNOWN_FIELD`, the section's own convention, and §20.11 documents the behavior as normative surface. | +| F4 + F5 — Traces 7–10 certifying a duplicate scheduler that disagreed with the shipping one on recency semantics | `03-phase5-triage.md` | Traces 7–10 now drive `CadenceSubsystem.update()`/`advance()` with a seeded RNG, as Traces 11–14 already did. `triggerSound()` and `calculateEligiblePool()` are deleted rather than reconciled. | +| C1 — production render path never created compositing surfaces; the first masked group permanently deactivated the visual subsystem | `02-triage.md` | `VisualSubsystem.render` passes a real `createSurface` factory (`OffscreenCanvas`, falling back to a detached canvas element), per R2's fix rather than R3's guard-and-skip. | +| C2 — system-level `behaviors` arrays skipped semantic validation, so six classes of invalid document failed at activation instead of import | `02-triage.md` | `validateBehaviors` runs at system scope during import with `options.fields` set from the system context. | + +The two items §2 asked to be **promoted** because Phase 6 will exercise them are also closed: **C20** (`remove` on an unknown instance ID now returns `refused`, not `executed`) and **C18** (a resolved non-integer `count` or burst count now raises `ERR_TYPE_MISMATCH` at the boundary instead of being rounded). Both touch the spawn/cleanup accounting the scenario director's ownership work depends on. + +**C5** — the one open design question — was decided by the user rather than guessed: a DurationSpec accepts the authored **duration literal** *or* a **non-negative finite number already in milliseconds**. The numeric form exists because a DurationSpec may be the resolved output of a ValueSpec or a bounded `TimeSpec`. Runtime (`types.js`), validator (`visual-validation.js`), and specification (§6.1) now agree, with the single documented exception of an automation track's `at`, which §19.1 keeps literal-only so point ordering stays decidable at import. + +Beyond the reconciliation plan's required set, the working tree also carries fixes for C3, C4, C6, C8, C9, C10, C11, C12, C13, C14, C15, C16, C17, C19, B1, S1, and P3.1. These were verified present in the source before the reconciliation commits; they are not separately re-certified here. + +## What remains open + +None of the following blocks Phase 6. Each is real, and each should be picked up rather than forgotten. + +1. **C7 — the JSON Schema is not a checked artifact.** The schema has no consumer: nothing in `src/`, `tools/`, or `test/` validates against it, so it can drift from the validator without any test noticing (P3.5 makes the same point from the other direction). The concrete fix is to validate the exhibit fixtures against the schema in CI; until then, treat `validator.js` as the single source of truth and the schema as documentation. +2. **P3.2 — degenerate path arcs.** A coincident-endpoint `arc` forces `acos(0)` and emits a phantom bulge; a negative radius is drawn mirrored rather than raising `ERR_OUT_OF_BOUNDS`. Early-return on coincident endpoints and check raw radii before `Math.abs`. +3. **P3.3 — post-effect transient memory.** Each blur pass allocates full-frame copies at up to 4096 device pixels, with no tie-in to the §19.5 pass budget. The device radius is now capped at 128, which bounds the work but not the allocation. Tiling the blur is the real fix. +4. **P3.4 — the `window-traffic` challenge fixture.** Per-copy `wrap` bounds are ±30px around each copy's own origin, so cars cycle in place instead of crossing the window. Either use the window rect as bounds, or document the cell-stutter as intended. The 4g suite only asserts finiteness and equality, so it passes either way. +5. **F6 — `ownership: "persistent"` on a sound action.** The schema and `cadence-validation.js` accept it; §20.12 documents only `"performance"` and `"scenario"`. This is probably deliberate carry-over from the §10.1 ownership vocabulary, but it is currently unstated. One line in §20.12 settles it either way — and it is worth settling before Phase 6, which is where scenario ownership becomes load-bearing. + +## Still user-observed, unchanged by this reconciliation + +Carried forward verbatim from the project status, because nothing automated can close them: the slice 4h / GC6 hardware measurement (every §19.5 aggregate stays **provisional** until trace 21 of §19.7 runs on recorded hardware), the PRD 130 visual challenge judged on a real display, Phase 3's audible acceptance — no sound has yet been heard from a production build — and Phase 1's direct-file two-fixture restart observation. diff --git a/reviews/review-2026-09-06-unknown-model-173154-a7c3e91b.md b/reviews/review-2026-09-06-unknown-model-173154-a7c3e91b.md new file mode 100644 index 0000000..c6b68c7 --- /dev/null +++ b/reviews/review-2026-09-06-unknown-model-173154-a7c3e91b.md @@ -0,0 +1,61 @@ +# Independent review — 2026-09-06 (America/Los_Angeles) + +## Review scope + +- **Local date:** 2026-09-06 (America/Los_Angeles, PDT / UTC−7). Review written 2026-09-06 17:31 UTC. +- **Branch:** `main` +- **HEAD:** `0af58da89dd6095fa9ca3ed546a2e48d7c7971b7` — `feat(visual): implement the slice 4d renderer core` (2026-09-06 16:38:44 +0000) +- **Today’s commits (LA calendar day, since 2026-09-06 07:00 UTC):** + - `1bc49018` docs(visual): apply the sections 17-19 review triage in full at revision 0.8 + - `6587d3e4` feat(visual): align the schema, validator, and resolution engine with the 0.8 visual contract + - `699492f8` build(visual): bundle the visual contract and validation modules into the standalone artifact + - `0af58da8` feat(visual): implement the slice 4d renderer core +- **Uncommitted work (staged: none):** modified runtime/docs/build files plus untracked Phase 4e–4g modules, tests, exhibits, evidence, and tools. **When those uncommitted edits were made cannot be established from Git;** they are included because they are the current tree. +- **Out of scope:** existing files under `/reviews`, `.abacusai/`, `.labyricorn/devlog/second-test-entry/`, `Claude outputs/`. No implementation files were modified for this review. +- **Limitation:** review is of the working tree vs HEAD plus today’s commits; Phase 4a–4c *docs* commits from late 2026-09-05 PDT are not treated as “today.” + +## Findings (by severity) + +### 1. Spawn `lifetime` is passed through `parseDuration`, which only accepts duration strings + +- **Priority:** P1 +- **Where:** `src/runtime/visual-engine.js:466-468`, `src/runtime/actions.js:48-50`, `src/runtime/types.js:55-56` +- **Trigger:** `ActionExecutor` evaluates `action.lifetime` (or spawn-input substitution yields a number) and `VisualEngine.spawn` always does `parseDuration(sampleTree(value, …))`. `parseDuration` throws `ERR_INVALID_DURATION` unless `typeof value === 'string'`. +- **Impact:** A legal numeric or already-resolved lifetime cannot spawn; the action fails as a runtime fault instead of applying the duration. Graphic-object `lifetime` in `visual-motion.js:70` has the same string-only assumption after sampling. +- **Evidence:** `spawn` helper `duration = (value, fallback) => value === undefined ? fallback : parseDuration(...)`. Tests in `test/phase4-execution.test.mjs` never pass `action.lifetime` or a numeric lifetime. +- **Correction:** Accept already-numeric milliseconds (and duration strings) in one helper; evaluate action lifetime to that type consistently with the spec. + +### 2. Local visual automation crashes if the target object is missing + +- **Priority:** P1 +- **Where:** `src/runtime/visual-automation.js:11-13` +- **Trigger:** `visualLocalTracks` walks `root.find(...)` then `while (object?.children.some(...))`. If `find` returns `undefined`, `object?.children` is `undefined` and `.some` is still invoked. +- **Impact:** Instantiating or advancing a system with a dangling automation target throws `TypeError` instead of a `RuntimeFault` / import error, taking down the visual tick (`failSystem` only catches errors *inside* the per-system try after tracks are built at `createSystem`). +- **Evidence:** Optional chaining stops at `children`, not at `.some`. Validator is supposed to reject bad targets, but runtime still concatenates tracks for exhibit-scope fallbacks (`visual-engine.js:403-406`) and spawned substitutes. +- **Correction:** Use `object?.children?.some(...)` and no-op or fault when `object` is missing. + +### 3. Bloom / color-adjust write unclamped channel math into `Uint8ClampedArray` without documenting wrap vs clamp; blur radius is clamped to `max(width,height)` + +- **Priority:** P2 +- **Where:** `src/runtime/visual-effects.js:4-5`, `38`, `74` +- **Trigger:** `bloom` adds `pixels + glow * intensity`; `color-adjust` can emit values outside `[0,1]` before `* 255`. `visualBoxBlur` sets `r = min(max(width,height), round(radius))`. +- **Impact:** Clamped arrays hide overflow (no crash), but a full-frame-radius blur is O(pixels × max(edge)) and can hitch on large backing stores when `deviceRadius` is large. Tests only assert “pixels changed,” not energy conservation or radius semantics (`test/phase4-execution.test.mjs` 19.7.15). +- **Evidence:** No `Math.min(255, …)` before assignment; radius cap is the long edge, not a contract ceiling. +- **Correction:** Clamp processed channels explicitly; cap blur radius to the post-effect numeric range / a small pixel budget. + +### 4. `remove` actions always report `executed` even when the instance id is unknown + +- **Priority:** P3 +- **Where:** `src/runtime/actions.js:53-55`, `src/runtime/visual-engine.js:475` +- **Trigger:** `this.visual.remove(action.target)` → `this.instances.get(id)?.remove(this.time)`. +- **Impact:** Callers cannot distinguish a successful release from a no-op (typo’d id). Low severity because spawn returns the id and tests use that id. +- **Correction:** Return `refused` / `failed` when the map has no entry. + +## Verification + +- `node --test test/phase4-*.test.mjs test/phase1-runtime.test.mjs`: **116 pass, 0 fail** (≈2.5s). +- Inspected Git status, today’s LA-window commits, unstaged diffs, and untracked `src/runtime/visual-*.js` plus tests. +- Traced spawn/remove, tick (`performance.js` `onTick` → `VisualSubsystem.advance` → `VisualEngine.advance`), automation loop, effects, lifecycle, and resolution effect sampling. +- **Not verified:** browser/Canvas `getImageData` on the real `XZBT.html` artifact; audio regression for `automationValueAt` loop (shared with visual); exact wall-clock of uncommitted files; full `test/*.test.mjs` outside phase1/phase4. + +No P0 defects were confirmed. Remaining risk is concentrated in uncommitted Phase 4e–4g runtime (lifetime typing, automation target walk) rather than the four committed 4d/contract commits. diff --git a/reviews/review-2026-09-06-unknown-model-180353-6b68e324.md b/reviews/review-2026-09-06-unknown-model-180353-6b68e324.md new file mode 100644 index 0000000..d992000 --- /dev/null +++ b/reviews/review-2026-09-06-unknown-model-180353-6b68e324.md @@ -0,0 +1,93 @@ +# Independent review — 2026-09-06 (America/Los_Angeles) + +## Review scope + +- **Local review date:** 2026-09-06 (America/Los_Angeles; review executed ~10:30–18:04 UTC = 03:30–11:04 PDT). +- **Branch:** `main`. **HEAD:** `0af58da89dd6095fa9ca3ed546a2e48d7c7971b7` (`feat(visual): implement the slice 4d renderer core`). +- **Reviewed commits** (authored 2026-09-06 in America/Los_Angeles; day boundary = 07:00 UTC): + - `1bc4901` docs(visual): sections 17–19 review triage at revision 0.8 (spec/plan docs, new `tools/verify-spec-contract.py`). + - `6587d3e` feat(visual): align schema, validator, resolution engine with the 0.8 visual contract (`schema/xzbt-0.1.schema.json`, `resolution.js`, `validator.js`, new `visual-contract.js` / `visual-validation.js`, `exhibits/minimal-visual.xzbt`, tests). + - `699492f` build(visual): bundle visual modules into `XZBT.html`. + - `0af58da` feat(visual): slice 4d renderer core (`visual-engine.js`, `visual-geometry.js`, `visual-math.js`, `visual-canvas2d.js`, `visual-diagnostics.js`, `visual-subsystem.js`, app/performance wiring). + - Boundary note: `527220e` and earlier (04:30 UTC and before) fall on 2026-09-05 in America/Los_Angeles and were excluded. +- **Uncommitted work reviewed:** 17 modified tracked files (`XZBT.html`, three docs, `package.json`, `src/runtime/{actions,app,audio-automation,performance,resolution,visual-canvas2d,visual-diagnostics,visual-engine,visual-subsystem,visual-validation}.js`, `test/phase1-runtime.test.mjs`, `tools/build-xzbt.mjs`) and today's untracked additions: 9 new `src/runtime/visual-*.js` modules, `test/phase4-{procedural,execution,challenges}.test.mjs`, `tools/{build-visual-acceptance,visual-challenge-fixtures}.mjs`, `exhibits/exhibit-{a..d}.xzbt`, `exhibits/visual-challenges/` (14 fixtures), `prototypes/phase4/`, `docs/evidence/phase4/2026-09-06-phase4{e,f,g}-*.md`. +- **Timing uncertainty:** uncommitted changes carry no commit timestamps. The visual runtime modules' file mtimes (today 09:44–10:19 PDT) and the dated evidence docs indicate they are today's slices 4e–4g work. `.labyricorn/devlog/second-test-entry/` (content dated 2026-08-13), `Claude outputs/` (2026-09-05), and `.abacusai/` are older/unrelated leftovers, not reviewed as today's work. Nothing is staged. +- **Scope limitations / method:** Per instructions, existing files under `reviews/` were not opened. Conclusions are drawn from the code, the format specification (docs/XZBT_0-1_Format_Specification.md), the test suites, and runtime probe scripts (written, executed, and deleted; no implementation files modified). Two untracked files initially served stale read content, so every finding below was re-verified against current disk state and/or an executing probe. Browser rendering, GC6 performance measurement, and audio behavior are out of verifiable scope (no display/hardware here). + +## Findings (ordered by severity) + +### 1. P1 — Production render path never creates compositing surfaces: group masks crash the visual subsystem; layer opacity/blend silently ignored + +- **Files:** `src/runtime/visual-subsystem.js:61-63`; `src/runtime/visual-canvas2d.js:99,230-243`; `src/runtime/app.js:42-49`. +- **Triggering conditions:** Any exhibit rendered through the product (`XZBT.html`) or the new acceptance page (both use `VisualSubsystem.render`) that (a) puts a `mask` on a `group` object, or (b) sets `opacity`/`blend` on a layer — including via the new 8.1 binding/automation on `visuals.layers..opacity` (today's `exhibits/exhibit-d.xzbt` does exactly this). +- **Impact:** (a) `renderFrame` is called without `createSurface`, so the mask branch of `drawNode` gets `null` from the dummy surface provider (`visual-canvas2d.js:233` returns `target`, which is `null` for unbuffered layers) and throws `TypeError: Cannot read properties of null (reading 'context')`; `app.js` `onFrame` catches, logs an error, and calls `visual.deactivate()` — the entire visual subsystem shuts down permanently on the first frame. (b) Layer `opacity`/`blend` are only applied in the buffered composite branch (`visual-canvas2d.js:236`); with no surface pool every layer draws inline and layer opacity is never applied — a bindable 8.1 property silently does nothing in the browser. +- **Evidence:** Probe: engine + `renderFrame(stub, plan, { warnEffect })` (the exact production call shape) on a masked-group document → `TypeError`; same plan with a `createSurface` factory → renders fine. Layer probe: `layers.main.opacity = 0.5`, plan reports `buffered: true`; with factory, `globalAlpha` sequence `[1, 1, 0.5]`; production path `[1, 1]` (opacity dropped). `tools/build-visual-acceptance.mjs:34` instantiates `VisualSubsystem` the same way, so the 4g acceptance page has the same gap. +- **Suggested correction:** Pass a surface factory from `VisualSubsystem.render`, e.g. `createSurface: (w, h) => typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(w, h) : Object.assign(document.createElement('canvas'), { width: w, height: h })`, and add a render-level test that exercises a masked group and a non-opaque layer through `VisualSubsystem` (not just `renderFrame` with a factory). + +### 2. P1 — System-level `behaviors` bypass semantic validation; accepted documents kill systems or the whole visual subsystem at runtime + +- **Files:** `src/runtime/visual-validation.js:332-333` (only array length is checked for `system.behaviors`; `validateBehaviors` at :614 runs for objects only); runtime effects in `src/runtime/visual-behaviors.js:97,155,225-236` and `src/runtime/visual-systems.js:164` (`points: null` for procedural items). +- **Triggering conditions:** A `particles`/`emitter`/`repeater` system whose top-level `behaviors` array contains an unknown `type`, a `field-follow` naming an undeclared field, or a `point-wander`. +- **Impact:** Unknown type passes import validation and throws `ERR_INVALID_BEHAVIOR_TYPE` at activation inside the `VisualEngine` constructor → app's `attachVisuals` catch → the *entire* visual subsystem fails (spec §18.6 item 12 expects this error at import). `field-follow` with an undeclared field throws `ERR_INVALID_REFERENCE` from `FieldSet.sample` on the first tick → `failSystem` removes the system. System-level `point-wander` throws `ERR_INVALID_BEHAVIOR_TARGET` on the first tick (procedural items have `points: null`; only template-level behaviors get point lists via `node.motion`) → system removed. All three documents report `valid: true` at import. +- **Evidence:** Probes: `behaviors: [{type:'levitate'}]` → `valid: true`, runtime `threw: ERR_INVALID_BEHAVIOR_TYPE`; `[{type:'field-follow', field:'nope'}]` → `valid: true`, after two ticks `systems alive = 0`; system-level `point-wander` on a polyline render template → `valid: true`, `systems alive = 0` (the same behavior object-level on the template validates and runs fine). +- **Suggested correction:** Call `validateBehaviors` for system-level `behaviors` too — with `options.fields` set and either rejecting point-list/morph behaviors at system scope or wiring `item.points` from the item template's point list — so every behavior/type/field-reference error is an import error, and behavior/target incompatibility (e.g. point-wander on a non-point-list template) is rejected before activation. + +### 3. P2 — `morph` writes NaN `z` into z-less point lists, poisoning geometry under perspective projection + +- **File:** `src/runtime/visual-behaviors.js` (morph case, :335-350 — the line `item.points[index].z += ((target[index].z ?? 0) - item.points[index].z) * amount;`). +- **Triggering conditions:** A `morph` behavior between point-list objects (`polyline`/`polygon`/`spline`) whose points carry no `z`, in a scene using `perspective` projection. +- **Impact:** `item.points[index].z` is `undefined`, so `z` becomes `NaN` every tick. `visual-engine.js` `project()` guards with `subpath.start[2] ?? 0`, which does not catch `NaN` (not nullish) → perspective factor and screen coordinates become `NaN` → the object disappears (canvas ignores non-finite coordinates). The 4g suite's recursive `finite()` plan check passes because today's fixtures run orthographic, where `z` is unused. +- **Evidence:** Probe morphing two 2-point polylines: after one tick `points = [{"x":25,"y":25,"z":null(NaN)},…]`, `geometry start = [25, 25, null(NaN)]`. +- **Suggested correction:** `(item.points[index].z ?? 0)` on the source side (and add a perspective-scene morph test asserting finite plan coordinates). + +### 4. P2 — `morph`/`point-wander` on `path` objects: validation explicitly allows, runtime silently no-ops + +- **Files:** `src/runtime/visual-validation.js:31-33` (`POINT_LIST_TYPES`/`MORPH_TYPES` include `'path'`, with `MORPH_PATH_OPS` support); runtime `src/runtime/visual-motion.js:44` (`pointsOf` falls back to `commands`) and `src/runtime/visual-behaviors.js:225-236,344-349`. +- **Triggering conditions:** `point-wander` or `morph` on a `path` object (commands with `move`/`line` ops). +- **Impact:** `pointsOf` returns the raw command objects (`{op, to}`), whose points live in `to`; the behaviors read/write `.x`/`.y`/`.z`, producing junk keys (`"x":null,…` = NaN) while geometry is untouched — an accepted, authored behavior silently does nothing (no diagnostic, no failure). +- **Evidence:** Probe: path→path morph and path point-wander validate clean; after a tick, geometry JSON identical, commands polluted with NaN `x`/`y`/`z` fields. +- **Suggested correction:** Either drop `'path'` from `POINT_LIST_TYPES`/`MORPH_TYPES` (and the schema behavior target implications) until command-point addressing exists, or make `pointsOf`/the behaviors address `command.to` points for the `move`/`line` ops. + +### 5. P2 — Repeater silently accepts `rate`/`burst`/`limit`/`capacity` (and `trail`) instead of `ERR_UNKNOWN_FIELD` + +- **Files:** `src/runtime/visual-validation.js` `validateSystems` (:325 rejects `links` on emitters, :328 adds a graphic-only rejection list, but no repeater-specific check); `src/runtime/visual-systems.js:80,96`; schema `schema/xzbt-0.1.schema.json:3229` (repeater variant lists `rate`/`burst`/`limit`/`capacity` as allowed properties). +- **Triggering conditions:** A `repeater` system declaring `rate`, `burst`, `limit`, `capacity`, or `trail`. +- **Impact:** Spec 18.5 (docs line 2399): "A `repeater` has no `rate`, `burst`, `limit`, `capacity`, `lifetime`, or `inputs`; each is `ERR_UNKNOWN_FIELD`." Import validation accepts all of them. `rate` alone then throws `ERR_UNBOUNDED_EMISSION` ("Emission declares neither a lifetime nor a limit") at activation — a misleading error for a system type that has no emission; `rate` + `limit` passes and the rate is silently ignored forever; `trail` silently records/draws trail history even though the field is not in the repeater table (the schema, inconsistently, *does* forbid `trail` — so schema and validator disagree). +- **Evidence:** Probes: repeater + `rate: 5` → `valid: true`, activation throws `ERR_UNBOUNDED_EMISSION`; repeater + `trail` → `valid: true`, runs with 3 copies and live trail state. +- **Suggested correction:** Extend the per-type unknown-field validation to reject `rate`/`burst`/`limit`/`capacity`/`trail` (and `fields`, if unintended — it validates but is skipped by the repeater integrator at `visual-systems.js` `advanceItem`) on repeaters, and remove `rate`/`burst`/`limit`/`capacity` from the schema's repeater variant. + +### 6. P2 — The two repo validators disagree: the standalone CLI rejects legal visual bindings + +- **File:** `tools/validate-exhibit.mjs:572` (`ExhibitValidator.validateBindingTarget` falls through to `ERR_UNSUPPORTED_TARGET` … "not exposed by the shared 0.1 target registry" for every `visuals.*` target). +- **Triggering conditions:** Running `node tools/validate-exhibit.mjs` on an exhibit with bindings to the new 8.1 visual families (`visuals.camera.*`, `visuals.layers..opacity`, `visuals.effects[i].*`, `visuals.systems..visible`). +- **Impact:** `exhibits/minimal-visual.xzbt` (committed today in `6587d3e`) and `exhibits/exhibit-d.xzbt` (untracked today) both FAIL the repo's own validation CLI (`[FAIL]`, exit 1) while passing the production validator (`parseAndValidateExhibit` → `valid: true`, zero errors). The gc2/gc3 tests instantiate `ExhibitValidator` only with their own fixtures, so the divergence is uncaught; anyone gating on the CLI rejects conforming exhibits. +- **Evidence:** CLI run over all exhibits: the two files above fail with `ERR_UNSUPPORTED_TARGET`/`ERR_INVALID_REFERENCE` on legal targets; runtime validator accepts both. All 14 challenge fixtures and exhibits A–C pass both validators. +- **Suggested correction:** Make `tools/validate-exhibit.mjs` delegate to `src/runtime/validator.js` (single source of truth), or port the four 8.1 visual target families into `ExhibitValidator`; add a test that runs the CLI validator over `exhibits/*.xzbt` and `exhibits/visual-challenges/*.xzbt` so the artifact set is continuously checked by both paths. + +### 7. P3 — JSON Schema system variants contradict the validator and spec 19.2/18.4 + +- **File:** `schema/xzbt-0.1.schema.json` (system variants at :2878/:2920/:3081/:3229), committed in `6587d3e`. +- **Details:** All four variants allow top-level `release`, `ownership`, `inputs`, `cancelWithScenario` — spec 19.2 and the validator (`visual-validation.js:314`) make each `ERR_UNKNOWN_FIELD` ("a field of the spawn container"). The emitter variant allows `links`, which the validator rejects (:325). The repeater variant allows `rate`/`burst`/`limit`/`capacity` (finding 5). The runtime never executes the schema (only a $ref-resolution test reads it), so this ships an inaccurate contract artifact rather than breaking imports directly. +- **Suggested correction:** Move the four spawn-only fields out of the variants' property lists (they already exist via the `spawn` sub-object), drop `links` from the emitter variant, and align the repeater variant with the 18.5 table. + +### 8. P3 — `spawn.lifetime`/`spawn.release`: spec says DurationSpec, implementation requires literals + +- **Files:** `src/runtime/visual-validation.js:361-364` (literal-only check); spec table docs/XZBT_0-1_Format_Specification.md:2936-2937 (`DurationSpec`, and section 6.2 defines DurationSpec to include the bounded `{"random": …}` TimeSpec); runtime `src/runtime/visual-engine.js` `spawn()` → `parseDuration(sampleTree(...))`, and `parseDuration` (`src/runtime/types.js`) throws on non-strings. +- **Impact:** A document using the spec-permitted random form is rejected at import; had it passed, spawn would throw `ERR_INVALID_DURATION` when the resolved value is a number. Currently consistent only because validation blocks first. +- **Suggested correction:** Either change the 19.2 table to "duration literal", or accept TimeSpec in `validateSpawn` and resolve it to milliseconds in `spawn()` before `parseDuration`. + +### 9. P3 — Bundle self-containment regex weakened past side-effect imports + +- **File:** `test/phase1-runtime.test.mjs:41`. +- **Details:** The check changed from `\bimport\s+[^;(]` to `^[ \t]*import[ \t][^;(\n]*from[ \t]*['"]` (multiline). The new pattern requires `from`, so a side-effect import (`import './chunk.js';`) in the bundle would no longer be caught; the change was made because bundled prose comments mention import syntax. +- **Suggested correction:** Add a second assertion for `^[ \t]*import[ \t]*['"]` (side-effect form) so both statement shapes are covered while prose mentions remain tolerated. + +## Verification performed + +- `npm test` (all suites): **212/212 tests pass**, 0 failures (16 suites, 15 files) — includes the 109 Phase 4 subtests; consistent with the 4f evidence doc's "223 checks" once the 12 GC2 fixture sub-checks are counted individually (212 − 1 + 12 = 223). +- `npm run build`: OK; two consecutive builds produce byte-identical output (SHA-256 46619d8f…, 464,653 bytes), confirming determinism and that the rebuild did not alter the pre-existing working-tree diff (diff stat unchanged at 2401 lines). `git diff --check` clean. +- `npm run build:visual-acceptance`: OK — acceptance page rebuilt with 18 fixtures; `phase4-challenges` test also validates determinism and fixture/file parity. +- `python tools/verify-spec-contract.py`: OK — 46 diagnostic codes declared / 43 used, 0 unresolved cross-references, fences/tables balanced. +- `node tools/validate-exhibit.mjs` over every `exhibits/**/*.xzbt` and `Claude outputs`: all pass except `exhibit-d.xzbt` and `minimal-visual.xzbt` (finding 6); both pass the production validator (verified directly via `parseAndValidateExhibit`). +- Runtime probes (temporary scripts, since deleted) against the live modules: system-scope automation on a behavior-less graphic object (works — `raw` refreshed every tick via `advanceVisualMotion`); the six defect confirmations cited in findings 1–5; repeat/point and burst/limit/capacity accounting in `ProceduralSystem` (correct by inspection + probes); lifecycle release/dispose transitions exercised by the passing 4f suite. +- **Could not verify:** actual browser pixels (no display available here — mask/layer findings are established at the `renderFrame` call-contract level, not by screenshot), slice 4h GC6 performance ceilings, audible acceptance, and long-run memory behavior of the offscreen `SurfacePool` (unbounded `allocated` growth across frames is untested). Untracked-file mtimes were used as a proxy for "today"; uncommitted work has no authoritative timestamps. diff --git a/reviews/review-2026-09-06-unknown-model-183137-f3a91c2d.md b/reviews/review-2026-09-06-unknown-model-183137-f3a91c2d.md new file mode 100644 index 0000000..96c7b9b --- /dev/null +++ b/reviews/review-2026-09-06-unknown-model-183137-f3a91c2d.md @@ -0,0 +1,98 @@ +# XZBT Daily Code Review — 2026-09-06 (America/Los_Angeles) + +## Review scope + +- Local date/time (America/Los_Angeles, PDT): 2026-09-06 ~11:30. Branch: `main`. HEAD: `0af58da` (feat(visual): implement the slice 4d renderer core). +- Reviewed commits (all within LA 2026-09-06, per author dates): `1bc4901` docs(visual) sections 17-19 review triage @ rev 0.8; `6587d3e` schema/validator/resolution aligned to the 0.8 visual contract; `699492f` standalone-artifact bundle of contract+validation; `0af58da` slice 4d renderer core. Earlier same-session commits `3e27a66`…`527220e` are LA 2026-09-05 20:57-21:30 and were treated as the contract baseline, not reviewed work. +- Uncommitted changes: nothing staged; 17 tracked files modified (+3188/-119), ~35 untracked paths, including 9 new runtime modules (visual-noise/behaviors/distributions/fields/motion/systems/effects/automation/lifecycle), 3 new test files (phase4-procedural/execution/challenges), tools/visual-challenge-fixtures.mjs, tools/build-visual-acceptance.mjs, exhibits/exhibit-a..d.xzbt, prototypes/phase4/, docs/evidence/phase4/*.md, regenerated XZBT.html. This is the phase 4e-g layer. +- Dating of uncommitted work: file mtimes (2026-09-06 09:44-11:13 LA) and the self-dated evidence docs are consistent with a single this-morning session, and XZBT.html is byte-identical to a fresh build of current sources, so the tree is coherent; but git cannot strictly prove when each edit was made, and some files may have been touched over several hours. +- Scope limitations: commits were not checked out individually (no interference); everything was reviewed at HEAD + working tree. Canvas-pixel output was not observed on a real display; claims about rendering are from code reading plus the mock-context test oracles. + +## Findings + +### P1-1 — `morph` corrupts point `z` to NaN on z-less geometry +- File: `src/runtime/visual-behaviors.js:348` (untracked, phase 4e). +- Condition: any morph between geometries whose points omit `z` (the normal 2D case), ≥1 tick, rendered under `camera.projection: "perspective"`. +- Impact: `item.points[index].z += ((target[index].z ?? 0) - item.points[index].z) * amount` reads an undefined base (`undefined + …` → NaN); NaN then flows through `primitiveSubpaths` (visual-motion.js:73) into plan coordinates; under perspective the object renders NaN (invisible/poisoned), orthographic hides it, so the current suite (no perspective-morph case) is green. Reproduced by probe: morphed point `z` became NaN after 3 ticks. +- Fix: guard the base read as well: `item.points[index].z += ((target[index].z ?? 0) - (item.points[index].z ?? 0)) * amount;` (as `point-wander` already does at line 235). + +### P1-2 — Velocity-accumulating behaviors are silently inert on graphic objects and repeater copies +- Files: `src/runtime/visual-motion.js:47` (graphic-object host runs `advanceItem` with a fake `{type:'repeater'}` context), `src/runtime/visual-systems.js:314-325` (integrator gated by `this.type !== 'repeater'`), `src/runtime/visual-behaviors.js:307-319, 332` (attract/repel/field-follow write `item.vx` only). +- Condition: spec 18.6 accumulating behaviors (`attract`, `repel`, `bounce`, `field-follow` force/velocity, `wander`) on a graphic object, graphic-system content, or repeater copy — validation accepts them (spec 18.6 attaches behaviors to objects/items; no host restriction enforced). +- Impact: the behavior accumulates velocity forever with no displacement: probe — graphic point with `attract {x:500,y:300}, strength:80` had `vx ≈ 0.235` but still sat at (0,0) after 2 s of ticks (the identical behavior moves particles); `drift` on the same host works, which masks the gap in fixtures. Silent, no diagnostic, violates 18.6 ("the integrator of 18.2 turns into displacement"). +- Fix: integrate `vx/vy` for object/repeater hosts in `advanceVisualMotion`, or reject these behaviors on hosts without an integrator at semantic validation with the documented code. + +### P1-3 — Per-item/system behavior arrays escape semantic validation (wrong stage; silent no-op channels) +- Files: `src/runtime/visual-validation.js:332-333` (systems: only the `≤ 8` count check) vs `:653`/`:789` (`validateBehaviors` invoked only for object hosts). +- Condition: any spec-18.9 semantic violation inside a `particles`/`emitter`/`repeater` `behaviors` array. +- Impact: 18.9/18.10 trace 12 requires `ERR_INVALID_BEHAVIOR_TYPE`, `ERR_INVALID_BEHAVIOR_TARGET`, `ERR_INVALID_REFERENCE`, `ERR_SCHEMA_VALIDATION` at import; instead probe: six cases (bogus type on particles/repeater, invalid property channel, undeclared field, point-wander on point render, follow-path speed+duration) all import clean. Unknown types then throw inside `createBehavior` at activation (whole-system failure at the wrong stage); invalid property channels (e.g. `style.hue`) write inert keys silently; undeclared fields throw per tick until `failSystem`. +- Fix: run the object-host `validateBehaviors` over each procedural system's `behaviors` at import with field context. + +### P2-1 — Canvas 2D backend never consumes per-object buffer grants; 17.12 stage order realized per-op +- Files: `src/runtime/visual-canvas2d.js:88-96, 97-106, 227-241` (no read of `node.buffered` anywhere in src/runtime) vs `src/runtime/visual-engine.js:1171-1201` (allocates per-object buffers, sets `plan.buffered`, sheds beyond 16). +- Condition: any object/group with non-default `blend`, `blur`, `glow`, or `filters` inside an unbuffered layer (the common case). +- Impact: fill, stroke, glow, and filter ops each composite individually against the live backdrop under the node's blend/alpha instead of the object being rasterized once and composited once (spec 17.12 buffer table/stage order; "A buffer is an offscreen surface the renderer must allocate to compute a stage correctly"). Observable: double-blending on translucent/blended stroke-over-fill overlap and on glow under non-normal blend; grant/shed bookkeeping has no rendering effect (granted and shed nodes differ only by feature presence). Only the layer-buffer and mask paths get real isolation. +- Fix: when `node.buffered` is true, draw the node (subtree for groups) into a pool surface with default state, then composite once with the node's alpha/blend, mirroring the layer path at visual-canvas2d.js:234-241. + +### P2-2 — Glow paints over the object interior and is silently dropped on text +- File: `src/runtime/visual-canvas2d.js:162-181`. +- Condition: `style.glow` on a point with a fill, on any strokable shape, or on text. +- Impact: 17.12 adds glow *around* the result; here the point disc is re-filled with `glow.color` (full-strength disc at alpha 1/strength 1 — a white point with red glow renders as a solid red disc), shapes are re-stroked on the identical path with glow color (`lineWidth = max(strokeWidth,1)`), overpainting the real stroke, and text gets no glow branch at all (silent, while the engine still grants text a buffer — visual-engine.js:901). Verified by code read. +- Fix: draw only the halo via a zero-offset blurred shadow of the original geometry without re-filling/re-stroking the path; apply the same to `fillText`/`strokeText` for text. + +### P2-3 — Boolean leaves accept any literal; `visible: 0` renders visible, `enabled: "false"` executes +- Files: `src/runtime/visual-validation.js:233` (effect `enabled` and object/layer/system `visible` are only structure-validated as ValueSpecs); consumers use `=== false` semantics: `visual-engine.js:529` (effectPlan), `:643-645` (systemVisible), `:698` (layerValue). +- Condition: authoring `visible: 0`/`1`/`"no"` on any object/layer/system, or `enabled: 0`/`1`/`"false"` on an effect entry. +- Impact: spec section 2 bans coercion — "a non-zero number will not be coerced into a boolean. Type mismatches produce `ERR_TYPE_MISMATCH`". Probes: all of the above import VALID, and the runtime draws a `visible: 0` object (expectation: hidden) and executes an `enabled: "false"`/`enabled: 0` effect (exact `false` is the only skip). No diagnostic anywhere. +- Fix: type-check boolean leaves against the declared type at validation (`ERR_TYPE_MISMATCH`), i.e. apply the effect `color`-literal check pattern (visual-validation.js:244-247) to booleans. + +### P2-4 — `face-motion` snaps on its first tick and reverts on zero velocity +- File: `src/runtime/visual-behaviors.js:279-283`. +- Condition: `face-motion` with any `smoothing`, including 1 ("never turns", spec 18.6). +- Impact: `const current = instance.state.rotation ?? target;` initializes the follow state to the *target*, so the first tick contributes the full turn regardless of smoothing (probe: base 45, +y velocity → rotation 90 on tick 1 for smoothing 0, 0.5 and 1); when XY speed drops below 1e-6 the behavior `break`s, the fresh rotation bucket was zeroed, and the object pops back to the authored rotation instead of holding the previous one (18.6: "An object with zero velocity holds its previous rotation"). +- Fix: initialize `instance.state.rotation` lazily to the current drawn rotation on first tick; on the zero-velocity branch emit the held value. + +### P2-5 — Trail `fade` is a flat uniform alpha; head never keeps the item's own opacity +- File: `src/runtime/visual-engine.js:1014` (points mode per-sample taper), `:1053` (ribbon), `:1064` (line). +- Condition: any trail in `line`/`ribbon` mode, or any trail on an item whose opacity ramps/fades. +- Impact: spec 18.8: "`fade` … Opacity multiplier at the tail; the head keeps the item's own opacity." Line/ribbon draw the whole trail at the flat tail factor and nothing multiplies by the item's ramped `opacityMultiplier`; probe: item at ramped opacity 0.81 rendered a full-strength head point but a line trail at flat alpha 0.4 (default fade 1.0 would leave the trail at 1.0 while the head fades to 0). Fading particles leave permanent-looking trails. +- Fix: per-vertex/per-segment alpha from head `itemOpacity` to tail `itemOpacity * fade` (segmented strokes), or state the uniform-color approximation with `WARN_VISUAL_APPROXIMATION`. + +### P2-6 — Procedural system admission gaps at import: required fields and emission semantics deferred to activation +- Files: `src/runtime/visual-validation.js` (system per-type field checks, cf. `:332-333` region) vs `src/runtime/visual-systems.js:96-99` (`ERR_UNBOUNDED_EMISSION` raised at construction). +- Condition/probes (all import VALID): `particles` without `render` (spec 18.2 table: required), `emitter` without `emit` (spec 18.4: required), `particles` with `rate` and neither `lifetime` nor `limit`. +- Impact: spec 18.2/18.4 render/emit-required rows and the semantic-stage list (18.9) demand import-time rejection; the docs instead pass validation and only fail (or silently draw nothing, in the render-less case) at activation/instantiation — wrong stage, and one case is silent. +- Fix: enforce per-type required fields and the static half of the unbounded-emission rule at import (runtime check stays for stochastic rates). + +### P2-7 — Resolved non-integer creation counts silently rounded +- File: `src/runtime/visual-systems.js:59, 63` (`Math.round` on resolved `count` and burst `count`). +- Condition: `count`/`burst[].count` authored as a ValueSpec resolving non-integer (e.g. `{random:{min:2.4,max:2.6}}`). +- Impact: spec 18.2: "an integer field whose resolved value is not an integer is `ERR_TYPE_MISMATCH` at that boundary rather than being rounded". Runtime rounds silently, so the same seed is reproducible but the authoring error is unmarked. +- Fix: after sampling, throw `RuntimeFault('ERR_TYPE_MISMATCH', …)` when `!Number.isInteger(resolved)` for `count` and each burst count. + +### P2-8 — `ring` with equal start/end angles draws a visible radial spoke +- File: `src/runtime/visual-geometry.js:348-351`. +- Condition: `{type:'ring', startAngle: 90, endAngle: 90, …}` (validation accepts; `directedSweep` returns sweep 0). +- Impact: the partial-sector branch emits outer-point → `lineTo` inner-point → closed, which strokes as a straight radial line; spec 17.9: "`d == 0` draws nothing and raises no diagnostic" (the sibling `arc` primitive handles sweep 0 correctly via `arcSegments`). +- Fix: return no segments when `sweep === 0` (keep the full-annulus branch). + +### P3 findings +1. Diagnostics `raised` list grows unbounded and is copied every frame — `src/runtime/visual-diagnostics.js:19, 39, 63` (push), `:45-52` (`endTick` prunes nothing), `src/runtime/visual-engine.js:734` (per-frame `slice()`). Slow memory/frame-cost growth over long sessions with sustained sheds/onces. Fix: clear or ring-buffer after the per-tick plan copy. +2. Degenerate path arcs: `src/runtime/visual-geometry.js:144-149, 153-186` — a path `arc` whose `to` equals the current point (`dx = dy = 0`) forces `acos(0)` and emits a phantom 90°/270° cubic bulge; and `Math.abs` is applied to the radius before the `≤ 0` throw, so a negative radius component (spec 17.13: `ERR_OUT_OF_BOUNDS`, "not silently degraded") draws a mirrored arc instead of failing. Fix: early-return on coincident endpoints; check the raw radii before `Math.abs`. +3. Masked-group rendering dereferences a null surface — `src/runtime/visual-canvas2d.js:97-106, 233` — `surface.create()` is unguarded; with no usable `createSurface` (Node/headless, no `document`/`OffscreenCanvas`; subsystem fallback at visual-subsystem.js:63-65 can return undefined) a granted masked group throws `TypeError` out of `renderFrame`, and the app treats any render exception as fatal: `src/runtime/app.js:42-49` calls `visual.deactivate()` with no reattach, permanently blacking visuals for the activation (a transient fault should not be terminal; audio faults only diagnose). Fix: guard/null-skip the mask with a warning; consider re-attaching visuals on a later frame. +4. Post-effect transient memory is unbounded at the backing ceiling — `src/runtime/visual-effects.js:6-39`: each blur pass allocates a full-frame copy + `Float32Array` + output (bloom adds a bright pass + glow blur); at the 4096-device-px ceiling this is on the order of several hundred MB per effect with no tie-in to the 19.5 pass budget. Fix: tile the blur or cap effect radius relative to the backing store. +5. `window-traffic` challenge fixture: per-copy `wrap` bounds (tools/visual-challenge-fixtures.mjs, ~lines 56-60) are ±30 px around each copy's own origin, so cars cycle in place every ~3 s and never cross the window — the authored bounds appear intended to be the window rect. The 4g suite only checks finiteness/equality, so this passes. Hedged: if cell-stutter was intended it should be documented. +6. `schema/xzbt-0.1.schema.json` (visual additions, committed in 6587d3e) has no consumer anywhere in src/tools/tests — the hand-written validator is the sole admission authority, and the schema already diverges from it (schema marks per-system fields like `render`/`emit` required that the validator admits without, cf. P2-6), so the schema can silently rot. Consider validating the exhibit fixtures against it in CI. (Schema itself parses cleanly: no duplicate keys; all 191 `$ref`s resolve.) + +## Verification performed + +- Full suite in a temp copy of the repo (`node --test test/*.test.mjs`, node v24.11.0): 212/212 pass, 0 fail; Phase 4 = exactly 109 tests (renderer 22, visual-contract 29, procedural 19, execution 20, challenges 19) + gc2-runner's single test covering 12 fixtures → evidence's "223 checks" accounting (212 tests + 11 extra counted GC2 fixture sub-checks) is consistent. +- Artifact integrity: two clean `npm run build` runs are byte-identical and identical to the working-tree `XZBT.html` (SHA-256 `46619d8ff2fbadf188136409d5cfe658bf5492ba0ba111d58ad488e95b343770`); `npm run build:visual-acceptance` output matches the evidence hash `42981219…db74` (18 fixtures embedded). Evidence claims in docs/evidence/phase4/2026-09-06-phase4f-execution.md verified. `git diff --check` clean; nothing staged. +- `python tools/verify-spec-contract.py`: 0 unresolved cross-references, fences/tables balanced, 46 codes declared / 43 used (3 declared-only: ERR_INVALID_ARITY, ERR_INVALID_OPERATOR, ERR_UNSUPPORTED_VERSION). +- Independent probes (node, read-only): attract-on-graphic inertness (P1-2); six-case behavior-array import acceptance (P1-3); `visible:0`/`enabled:0|1|"false"` accepted (P2-3); particles/emitter/repeater required-field admission (P2-6); morph NaN via code trace (P1-1); plus positive controls: audio-bus draws are bit-identical with and without visual effects blocks; both automation call sites use consistent ms time bases; 120-tick runs at 1/2/4 draws/frame agree (execution suite). +- Clean areas (verified by direct reading/tests): math/geometry conventions and sweep normalization; layer ordering and buffer-refusal order (nearest-first grants, farthest-first refusals); fog/gradient/alpha arithmetic; determinism story (keyed RNG substreams, no Math.random/Date/performance.now in the visual path); effect pass budget and per-effect warn routing; build determinism; phase1 bundler top-level-identifier collision scan is a good net for the concatenated artifact. +- Not verified: per-commit isolation of the four reviewed commits; real-browser pixel output, post-effect numerics on a real surface, and hardware performance (evidence doc itself disclaims these); memory footprint measurements; exact provenance/dating of each uncommitted edit. + +## Testing gaps (relevant to the findings) + +No test covers: perspective-projected morph (P1-1); velocity behaviors on graphic/repeater hosts (P1-2); semantic-validation cases on per-item behavior arrays (P1-3); backend rendering of buffered nodes, glow, blur, filters, or masked groups through a real surface pool (P2-1, P2-2, P3.3); zero-sweep ring / degenerate path arcs (P2-8, P3.2); typed-boolean leaves (P2-3); face-motion smoothing/zero-velocity (P2-4); trail head-opacity/fade taper in line/ribbon (P2-5); non-integer resolved counts (P2-7); long-run diagnostics growth (P3.1); post-effect runs against real readback (P3.4). phase4-procedural/execution trace coverage is strong where it operates (draw counts, closed-form motion, budgets, ceilings, cadence). diff --git a/reviews/spec-review-2026-09-06-unknown-model-173816-c4f8a2d1.md b/reviews/spec-review-2026-09-06-unknown-model-173816-c4f8a2d1.md new file mode 100644 index 0000000..5d7578b --- /dev/null +++ b/reviews/spec-review-2026-09-06-unknown-model-173816-c4f8a2d1.md @@ -0,0 +1,87 @@ +# Specification review — 2026-09-06 (America/Los_Angeles) + +## Scope + +- **Local date:** 2026-09-06 (America/Los_Angeles, PDT / UTC−7). Written 2026-09-06 17:38 UTC. +- **Branch:** `main` +- **HEAD:** `0af58da89dd6095fa9ca3ed546a2e48d7c7971b7` (`feat(visual): implement the slice 4d renderer core`) +- **Today’s commits (since 2026-09-06 07:00 UTC):** `1bc4901` (spec §§17–19 rev 0.8), `6587d3e` (schema/validator/resolution), `699492f` (standalone bundle), `0af58da` (4d renderer). +- **Uncommitted work:** Phase 4e–4g runtime (`src/runtime/visual-*.js`, engine/actions/app/resolution/performance), tests, exhibits, evidence. **Git cannot date uncommitted files;** they are in scope as the current tree. +- **Authoritative sources:** `docs/XZBT_0-1_Format_Specification.md` rev **0.8**, especially §§6, 8.1, 9.1, 10.1–10.2, **17–19** (19.1–19.7). Supporting: `docs/XZBT_0-1_Implementation_Plan.md` Phase 4 slice table. PRD cited only where the spec restates it. +- **Not used:** any file under `/reviews`. +- **Limitations:** Action Model (spawn/remove *shapes*) is explicitly not fixed in §19.2; Phase 6 scenarios and 4h/GC6 (traces 20–21) are deferred and not treated as defects. Display judgment not run. + +## Traceability (today’s visual work → spec) + +| Item | Spec | Implementation | Status | +| --- | --- | --- | --- | +| Scene / fit / primitives / compositing | 17.4–17.13 | `visual-engine.js`, `visual-geometry.js`, `visual-canvas2d.js` | **Satisfied** (4d tests 17.16.*) | +| Camera matrix, parallax, perspective, focalLength clamp | 19.3 | `cameraMatrix`, `planFrame` | **Satisfied** (static + execution tests) | +| Components, particles, emitters, repeaters, behaviors, fields, trails, links | 18.1–18.8 | `visual-systems.js`, `visual-behaviors.js`, `visual-fields.js`, `visual-distributions.js` | **Satisfied** (18.10 traces in `phase4-procedural.test.mjs`) | +| Exhibit + system automation, shared curves/modes, live vs authoring budgets | 19.1, 16.1 | `resolution.js`, `visual-automation.js`, `audio-automation.js` | **Partial** (curves/modes/budgets tested; local-track target walk unsafe; 19.7.3/4 coverage incomplete) | +| Lifecycle, spawn/remove, ownership, release factor | 19.2 | `visual-lifecycle.js`, `VisualEngine.spawn/remove/cleanup` | **Partial** (`DurationSpec` after evaluation vs `parseDuration`; action lifetime not specified in §19) | +| Post-effects chain, grain isolation, pass ceiling | 19.4 | `visual-effects.js`, `effectPlan` | **Partial** (pixel tests exist; radius/energy vs 19.4 table not fully asserted) | +| Aggregate ceilings + cadence | 19.5–19.6 | `enforcePopulations`, `visual-diagnostics.js` | **Satisfied** for automated 19.7.18 cases; **unverified** vs measured 4h values (provisional by spec) | +| 19.7 traces 1–19 | 19.7 | `test/phase4-execution.test.mjs` | **Partial** (many named tests; 19.7.3/4/8/13 not all matching the prose) | +| 19.7 traces 20–21 | 19.7 | — | **Deferred** (4h / display); not a defect | + +## Findings (confirmed gaps) + +### 1. `spawn.lifetime` / action lifetime after evaluation is not a duration literal + +- **Priority:** P1 +- **Spec:** §6.1 — durations are unit strings converted internally to milliseconds. §19.2 — `spawn.lifetime` and `spawn.release` are **DurationSpec**; instance duration is that logical duration from instantiation. +- **Impl:** `visual-engine.js:466-468` always `parseDuration(sampleTree(...))`, which requires `typeof value === 'string'` (`types.js:55-56`). `actions.js:48-50` may `evaluateValue(action.lifetime)` first. +- **Expected:** Authored DurationSpec remains a duration literal through sampling, then converts to ms. If evaluation yields a number (ms), it must still be accepted or rejected with `ERR_TYPE_MISMATCH`, not `ERR_INVALID_DURATION`. +- **Actual:** Any non-string (including a resolved number) throws `ERR_INVALID_DURATION` and aborts spawn. +- **Trigger:** `spawn` action with evaluated `lifetime`, or a DurationSpec that the value resolver does not leave as a string. +- **Impact:** Legal instance lifetimes fail at runtime; §19.2 “absent means until remove” path is the only reliably tested path. +- **Correction:** Convert DurationSpec with `parseDuration` only when the value is still a string; if already a finite number, treat as milliseconds (or reject per strict coercion ban §2 — pick one and match §6.1). + +### 2. Local automation target walk throws `TypeError` instead of `ERR_INVALID_REFERENCE` + +- **Priority:** P1 +- **Spec:** §19.1 — target naming an undeclared object is `ERR_INVALID_REFERENCE`; outside the registry is `ERR_UNSUPPORTED_TARGET`. Import should reject; runtime must not invent a third failure mode. +- **Impl:** `visual-automation.js:11-13`: `while (object?.children.some(...))` — if `find` returns `undefined`, `.some` is called on `undefined`. +- **Expected:** Validator fault at import, or runtime `RuntimeFault` with those codes. +- **Actual:** Uncaught `TypeError` during `createSystem` / instantiate (outside the per-tick `try` in `advance`). +- **Trigger:** Dangling graphic-object key on a system `automation` track, or exhibit-scope fallback path `visual-engine.js:403-406`. +- **Impact:** Tick/activation abort rather than a documented diagnostic. +- **Correction:** Guard `object?.children?.some`; if missing, throw `RuntimeFault('ERR_INVALID_REFERENCE', ...)`. + +### 3. Required traces 19.7.3 and 19.7.4 are not implemented as specified + +- **Priority:** P2 +- **Spec:** §19.7 items 3–4 (lines ~3255–3258 of the format spec): (3) system-scope `at` from instantiation — two spawns 4s apart produce identical curves offset by 4s; (4) cross-scope / undeclared / out-of-registry targets raise `ERR_INVALID_REFERENCE` vs `ERR_UNSUPPORTED_TARGET` as specified. +- **Impl:** `test/phase4-execution.test.mjs` “19.7.3” only checks one exhibit-scope stream; “19.7.4” only checks behavior/automation `ERR_AUTOMATION_CONFLICT`. +- **Expected:** The behaviors named in 19.7.3–4 actually run (spec: “a parsed stub is never a passed runtime trace”). +- **Actual:** Adjacent properties are tested; the named traces are not. +- **Trigger:** Acceptance of slice 4f against 19.7. +- **Impact:** Spawn-relative automation offset and target-error taxonomy can regress without a failing test. +- **Correction:** Add the two-spawn 4s offset assertion and the four negative target cases from 19.7.4. + +### 4. Blur/bloom radius vs 19.4 “32 scene units” is not enforced in the pixel filter + +- **Priority:** P3 +- **Spec:** §19.5 authoring table / 19.4 — blur and bloom radius **32 scene units**; pipeline clamp of 8.1 on effect parameters. +- **Impl:** `visual-effects.js:4-5` caps box-blur radius at `max(width,height)` **pixels** (device radius from `effectPlan`). Contract numeric clamp is in `effectPlan` via `POST_EFFECTS`; the filter can still run at huge projected radii. +- **Expected:** Authored/resolved radius in scene units stays in the 19.4 table; device radius is a projection of that, not an unbounded pixel kernel. +- **Actual:** Device kernel can equal the long edge of the backing store. +- **Trigger:** Large `zoom` × `deviceRadius` with radius still inside 32 scene units. +- **Impact:** Frame hitch; not a silent spec violation of the authored 32 if `effectPlan` clamps scene units — **partial**. Confirm `POST_EFFECTS` max is 32 and that tests assert it (19.7.15 does not). +- **Correction:** Assert 19.4 ranges in execution tests; cap device radius by a pixel budget derived from the scene-unit max. + +## Specification ambiguities (not defects) + +1. **§19.2 vs Action Model:** spawn/remove *action shapes* are explicitly not fixed in 19.2. Whether `action.lifetime` is DurationSpec, ValueSpec\, or either is **undecided**. Finding 1 is a defect only for `spawn.lifetime` after `sampleTree`; action-lifetime typing needs a decision in the Action Model (Phase 5/6), not a silent assumption. +2. **DurationSpec vs TimeSpec (§6.2):** 19.2 names DurationSpec, not the random TimeSpec object. Whether `spawn.lifetime: { "random": { "min": "1s", "max": "2s" } }` is legal is **unspecified**. Implementation would currently fail `parseDuration` on an object. +3. **19.5 values** are provisional until 4h/trace 21; using 8192 particles / 2048 emitters in tests matches the *current table*, not a measured GC6 result. +4. **`instances.*` addressing** is runtime-only (19.2 / 8.1). Tests use `instances.s#N`; the Action Model still owns the public `remove` target grammar. + +## Verification + +- Read spec §§6, 17–19.7 and traced `spawn`, `advance`, automation, lifecycle, effects, resolution `visualValues`. +- `node --test test/phase4-*.test.mjs test/phase1-runtime.test.mjs`: **116 pass, 0 fail** (prior turn). Tests are evidence of implemented behavior, not of 19.7.3/4 completeness. +- **Not verified:** real-display 19.7.20, GC6 19.7.21, full Action Model document, audio 16.11 cross-check of every visual curve sample. + +Actionable specification/implementation gaps exist (P1–P3 above). Traces 20–21 remain deferred by the spec itself. diff --git a/schema/xzbt-0.1.schema.json b/schema/xzbt-0.1.schema.json index b585ba0..5527e5e 100644 --- a/schema/xzbt-0.1.schema.json +++ b/schema/xzbt-0.1.schema.json @@ -346,14 +346,32 @@ }, "cadence": { "type": "object", - "description": "Procedural rhythm clocks and pulse pools.", + "description": "Procedural rhythm clocks, intensity, and automatic one-shot sound policies (sections 20.3-20.7).", "additionalProperties": false, "properties": { - "clocks": { - "type": "object" + "intensity": { + "$ref": "#/definitions/ValueSpec" }, - "pools": { - "type": "object" + "minGap": { + "$ref": "#/definitions/DurationSpec" + }, + "clocks": { + "type": "object", + "additionalProperties": false, + "properties": { + "routine": { + "$ref": "#/definitions/CadenceClockRange" + }, + "intermittent": { + "$ref": "#/definitions/CadenceClockRange" + }, + "occasional": { + "$ref": "#/definitions/CadenceClockRange" + }, + "rare": { + "$ref": "#/definitions/CadenceClockRange" + } + } } } }, @@ -376,13 +394,13 @@ }, "events": { "type": "object", - "description": "Discrete lifecycle and state change event triggers.", - "patternProperties": { - "^[a-z][a-z0-9_-]*$": { - "type": "object" - } + "description": "Discrete lifecycle and state change event triggers (section 20.10, PRD 90).", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" }, - "additionalProperties": false + "additionalProperties": { + "$ref": "#/definitions/EventDefinition" + } }, "scenarios": { "type": "object", @@ -1969,7 +1987,7 @@ } }, "cadence": { - "type": "object" + "$ref": "#/definitions/SoundCadence" }, "bus": { "type": "string", @@ -1980,6 +1998,178 @@ } } }, + "CadenceClockRange": { + "type": "object", + "description": "Interval range for a cadence clock class (section 20.3).", + "required": [ + "min", + "max" + ], + "additionalProperties": false, + "properties": { + "min": { + "$ref": "#/definitions/DurationSpec" + }, + "max": { + "$ref": "#/definitions/DurationSpec" + } + } + }, + "SoundCadence": { + "type": "object", + "description": "Cadence configuration for a sound definition (section 20.4).", + "required": [ + "class" + ], + "additionalProperties": false, + "properties": { + "class": { + "type": "string", + "enum": [ + "ambient", + "routine", + "intermittent", + "occasional", + "rare", + "scenario" + ] + }, + "weight": { + "$ref": "#/definitions/ValueSpec" + }, + "cooldown": { + "$ref": "#/definitions/DurationSpec" + }, + "overlap": { + "type": "boolean" + }, + "when": { + "$ref": "#/definitions/ConditionSpec" + } + } + }, + "ActionSpec": { + "type": "object", + "description": "An Action Model 0.1 action (sections 22-30, 20.11-20.12).", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "set", + "override", + "sound", + "event", + "spawn", + "remove", + "control" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "when": { + "$ref": "#/definitions/ConditionSpec" + }, + "chance": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "critical": { + "type": "boolean" + }, + "target": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/ValueSpec" + }, + "sound": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "event": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "command": { + "type": "string", + "enum": [ + "start", + "stop", + "enable", + "disable", + "pause", + "resume", + "reset" + ] + }, + "with": { + "type": "object" + }, + "scope": { + "type": "string", + "enum": [ + "scenario", + "duration" + ] + }, + "duration": { + "$ref": "#/definitions/DurationSpec" + }, + "lifetime": { + "anyOf": [ + { + "$ref": "#/definitions/DurationSpec" + }, + { + "$ref": "#/definitions/ValueSpec" + } + ] + }, + "ownership": { + "type": "string", + "enum": [ + "performance", + "scenario", + "persistent" + ] + }, + "transition": { + "type": "object" + } + } + }, + "EventDefinition": { + "type": "object", + "description": "A reusable named action bundle (section 20.10, PRD 90).", + "required": [ + "actions" + ], + "additionalProperties": false, + "properties": { + "inputs": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "additionalProperties": { + "$ref": "#/definitions/ParameterSpec" + } + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/ActionSpec" + } + } + } + }, "VisualPoint": { "type": "object", "additionalProperties": false, @@ -2899,10 +3089,6 @@ "spawn": { "$ref": "#/definitions/VisualSpawn" }, - "release": false, - "ownership": false, - "inputs": false, - "cancelWithScenario": false, "content": { "$ref": "#/definitions/VisualObjectMap" } @@ -2941,10 +3127,6 @@ "spawn": { "$ref": "#/definitions/VisualSpawn" }, - "release": false, - "ownership": false, - "inputs": false, - "cancelWithScenario": false, "position": { "type": "object", "additionalProperties": false, @@ -3102,10 +3284,6 @@ "spawn": { "$ref": "#/definitions/VisualSpawn" }, - "release": false, - "ownership": false, - "inputs": false, - "cancelWithScenario": false, "position": { "type": "object", "additionalProperties": false, @@ -3212,8 +3390,7 @@ }, "trail": { "$ref": "#/definitions/VisualTrail" - }, - "links": false + } } }, { @@ -3250,10 +3427,6 @@ "spawn": { "$ref": "#/definitions/VisualSpawn" }, - "release": false, - "ownership": false, - "inputs": false, - "cancelWithScenario": false, "repeat": { "$ref": "#/definitions/VisualObject" }, @@ -3292,12 +3465,7 @@ }, "links": { "$ref": "#/definitions/VisualLinks" - }, - "rate": false, - "burst": false, - "limit": false, - "capacity": false, - "lifetime": false + } } } ] diff --git a/test/phase4-visual-contract.test.mjs b/test/phase4-visual-contract.test.mjs index 2c8edd2..7761d98 100644 --- a/test/phase4-visual-contract.test.mjs +++ b/test/phase4-visual-contract.test.mjs @@ -14,7 +14,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ResolutionEngine } from '../src/runtime/resolution.js'; import { SeededRNG } from '../src/runtime/rng.js'; -import { RuntimeFault } from '../src/runtime/types.js'; +import { RuntimeFault, parseDuration } from '../src/runtime/types.js'; import { validateExhibit } from '../src/runtime/validator.js'; import { POST_EFFECTS, VISUAL_LIMITS, matchVisualTarget, postEffectPassCount } from '../src/runtime/visual-contract.js'; @@ -107,6 +107,27 @@ test('a top-level lifetime is the per-item duration, and only two types have one expectClean(document({ scene, systems: { motes: { ...particles, lifecycle: 'spawned', spawn: { lifetime: '30s' } } } }), 'both lifetimes'); }); +test('a spawn duration is a literal or a non-negative number of milliseconds (6.1, C5)', () => { + const spawned = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } }); + // Both authored forms of the same duration validate. + expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'literal durations'); + expectClean(spawned({ lifetime: 2000, release: 250 }), 'millisecond durations'); + expectClean(spawned({ lifetime: 0 }), 'zero milliseconds'); + // Everything else at that field is ERR_INVALID_DURATION, negatives included. + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '2 s', '1m30s', true, {}]) { + assert.ok(codes(spawned({ lifetime: value })).includes('ERR_INVALID_DURATION'), `lifetime ${String(value)}`); + } +}); + +test('parseDuration reads both forms and rejects the rest (6.1, C5)', () => { + assert.equal(parseDuration('2s'), 2000); + assert.equal(parseDuration(2000), 2000); + assert.equal(parseDuration(0), 0); + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '1m30s', null, true]) { + assert.throws(() => parseDuration(value), (error) => error instanceof RuntimeFault && error.code === 'ERR_INVALID_DURATION', `parseDuration ${String(value)}`); + } +}); + test('severing the scenario relationship requires persistent ownership (19.2, V23)', () => { const doc = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } }); assert.ok(codes(doc({ cancelWithScenario: false })).includes('ERR_UNSUPPORTED_TARGET')); diff --git a/tools/build-xzbt.mjs b/tools/build-xzbt.mjs index 6f355d8..d1253c0 100644 --- a/tools/build-xzbt.mjs +++ b/tools/build-xzbt.mjs @@ -20,15 +20,26 @@ const sourceFiles = Object.freeze([ 'src/runtime/visual-diagnostics.js', 'src/runtime/visual-math.js', 'src/runtime/visual-geometry.js', + 'src/runtime/visual-noise.js', + 'src/runtime/visual-behaviors.js', + 'src/runtime/visual-distributions.js', + 'src/runtime/visual-fields.js', + 'src/runtime/visual-systems.js', 'src/runtime/visual-validation.js', + 'src/runtime/cadence-validation.js', 'src/runtime/validator.js', 'src/runtime/persistence.js', 'src/runtime/library.js', 'src/runtime/resolution.js', + 'src/runtime/visual-motion.js', + 'src/runtime/visual-automation.js', + 'src/runtime/visual-lifecycle.js', + 'src/runtime/visual-effects.js', 'src/runtime/visual-engine.js', 'src/runtime/visual-canvas2d.js', 'src/runtime/visual-subsystem.js', 'src/runtime/actions.js', + 'src/runtime/cadence.js', 'src/runtime/performance.js', 'src/runtime/activation.js', 'src/runtime/audio-engine.js',