diff --git a/flow-state/SIDE-QUESTS.md b/flow-state/SIDE-QUESTS.md new file mode 100644 index 0000000..2e3e400 --- /dev/null +++ b/flow-state/SIDE-QUESTS.md @@ -0,0 +1,199 @@ +# Side quests + +Three follow-ups left behind by Epic 2. None of them blocks anything; all three are debts that +were taken on knowingly, with the reason recorded at the time. They are written up here so that +picking one up does not require reconstructing why it exists. + +Each is independent. Each has an existing gate that will tell you when it is done. + +| # | quest | size | what it unblocks | +|---|---|---|---| +| 1 | Four scenes express `style` only through grain | small | lets them opt out of grain entirely | +| 2 | Three scenes have no structure a parameter can move | medium, per-scene | the open half of EPIC-2 §3.4 | +| 3 | Horizon Lines determinism flake | small, investigative | a gate that currently depends on GPU load | + +--- + +## 1. Four scenes whose only art direction is grain + +### What is wrong + +`Classic Wave`, `Silk Ribbon`, `Kaleido Tunnel` and `Slow Orb` all declare the `style` +personality trait. A scene that declares a trait is promising it visibly honours it — that is a +contract, not a hint, because the disqualification rule in `Personality.js` is the only thing +keeping off-design scenes out of a track. + +These four honour it with exactly one line: + +```glsl +col += sigGrain(uv); +``` + +That is the whole of their art direction. So when the grain work (commit `14d1204`) let a scene +decline the track's surface grain with `texture: 0`, these four could not take it: Phase 9's +"every declared trait visibly changes the scene that declares it" measured their style response +at exactly **0**. They were parked at `texture: 0.35` as a stopgap, which is the wrong shape of +fix — it keeps grain on scenes that would look better clean, purely so a gate stays green. + +### Why it matters + +The user's original complaint was that grain was everywhere. Three of these four are the +library's cleanest, softest scenes — a drifting orb, a silk ribbon, a ring wave. They are +exactly the scenes that should be able to say "not on me". + +### The approach + +Each already has a parameter that *is* an edge-weight or a softness knob. The work is routing +`u_sigLine` and `u_sigSoft` into it, so the track's hand shows in the line quality rather than +in the dirt. Concretely, per scene: + +- **Classic Wave** — `src/scenes/shader/classic-wave.js:50` already has + `v = mix(v, smoothstep(0.2, 0.8, v), u_softness)`. Widen or narrow that smoothstep by + `u_sigSoft`, and let `u_sigLine` drive how hard the ring crests read. +- **Silk Ribbon** — `:61` is `exp(-best * best / (u_thickness * u_thickness))`. The strand width + is `u_thickness` (range `0.008..0.08`); scale it by `u_sigLine`, and put `u_sigSoft` on the + falloff exponent so a soft-handed track gets a haze and a sharp one gets a filament. +- **Kaleido Tunnel** — draws its grid with `smoothstep(0.42, 0.0, ringLines)`. That constant is + a line weight with no name. Drive the threshold from `u_sigLine` and the smoothstep width from + `u_sigSoft`. +- **Slow Orb** — `:47` is `smoothstep(u_softness * 0.5, -u_softness * 0.5, d)`, the body edge. + Fold `u_sigSoft` into it, and consider a `sigEdge(d)` rim so the shape reads in the track's + hand rather than only in its own. + +Then set `texture: 0` on the ones that genuinely want to be clean — probably all four, but that +is a judgement call to make while looking at them, not now. + +### Done when + +- Phase 9's trait check reports a non-zero style delta for all four with `texture: 0`. +- `npm run lint:scenes` still finds style evidence in each shader (it greps for `sigEdge` / + `sigGrain` / `sigFolded` / `u_sigLine` / `u_sigSoft` / `u_sigTexture` / `u_sigFold`). +- The Phase 11 grain checks are unaffected — this quest must not put grain back. + +### Do not + +Do not solve this by dropping `style` from their `traits` arrays. That would pass every gate and +would shrink the library for every style-signature track, which is the opposite of the point. + +--- + +## 2. Three scenes with no structure to move + +### What is wrong + +EPIC-2 §3.4 gave scenes a **slow axis**: a declared parameter walked one way across the whole +track, so the frame at four minutes is not the frame at thirty seconds. It works — Moiré Grid +3.93×, Gate Corridor 2.88×, Truchet Fold 1.40×, measured as axis-driven structural change +against what the scene does on its own. + +For three scenes it cannot work, because **no parameter changes their structure**: + +| scene | best parameter | ratio | +|---|---|---| +| `curl-flow.js` | `veins` | 1.31× | +| `signal-decay.js` | `traces` | 1.21× | +| `circuit-bloom.js` | `cells` | 1.10× | + +Sweeping any parameter through most of its range barely moves the ten-second time-averaged +image. That is the technical statement of "it looks the same for five minutes": these scenes are +statistically identical everywhere and at all times. They churn — every pixel moving, nothing +developing — and the eye adapts in about two seconds. + +### Why it matters + +Churn is the failure that survives every other fix. Cut rhythm, colour movement and framing all +change *what surrounds* the image; none of them change the fact that the image itself has +nothing to find after two seconds. + +### The approach + +This is genuinely shader work and it is not mechanical — that assessment was wrong when EPIC-2 +was first written and is corrected in §3.4. Each scene needs a persistent large-scale structure +that a parameter can then move. Sketches, one per scene, to be argued with rather than followed: + +- **Curl Flow** is a curl-noise field at one scale, so it looks the same everywhere by + construction. Give it a low-frequency term that varies across the frame — a density gradient, + a region where the flow stalls, a dominant current — and put the parameter that positions or + scales *that* on the axis. +- **Signal Decay** already quantises to an era grid (`floor(u_barPhase * 8.0)`), which is good + rhythm and no structure: every trace is the same kind of trace. Consider making traces + differ from one another — a hierarchy, one dominant channel, a slow reordering — so the + arrangement can change over minutes. +- **Circuit Bloom** is a uniform grid of cells with per-cell hashes. The `reach` term already + gates growth by distance from the centre; that is the seed of a structure. Let the board grow, + reroute or densify along a slow axis instead of being fully populated from frame one. + +Then declare the parameter with `slowAxis: true` in the params block. + +### Done when + +Phase 11's `a declared slow axis actually changes the scene` holds the scene at **1.25×**, and +its companion check still reads ~1.00× with the axis disabled. Run: + +```bash +http://localhost:5180/checks.html?phase=11&slow=1 +``` + +### Do not + +Do not add `slowAxis: true` before the shader can back it up — the gate will fail, correctly. +And do not reach for more motion: these scenes already move too much. The missing thing is +structure, which is the opposite of motion. + +--- + +## 3. The Horizon Lines determinism flake + +### What is wrong + +Phase 7's `every scene is deterministic` renders each scene twice and requires a max channel +delta of ≤ 1/255. `Horizon Lines` reports **2**, but only sometimes: + +```bash +http://localhost:5180/checks.html?phase=7&slow=1 # FAILS — delta 2 +http://localhost:5180/checks.html?slow=1 # PASSES — delta ≤ 1 +``` + +Verified present on `f050eaa`, so it predates Epic 2. The difference is how much GPU work ran +before it: in a full suite, phases 0-6 warm the device first. + +### Why it matters + +Not for the image — 2/255 is invisible, and PLAN.md §1 already accepts 1/255 as the floor the +hardware offers. It matters because **a gate whose verdict depends on preceding load will +eventually stay green through a real determinism regression**, and determinism is the single +architectural constraint this whole project is built on. A flaky guard on the load-bearing +property is worse than no guard, because it is trusted. + +### The approach + +Two steps, in order. + +**First, find out why this scene and not the other 41.** The suspects are in +`src/scenes/shader/horizon-lines.js`: + +- The loop accumulates up to **40** line contributions into `col`. Summing many small terms is + where float ordering shows up, and no other scene in the library sums this many. +- `float line = smoothstep(u_thickness * (0.5 + u_sigLine), 0.0, d)` with `u_thickness` as low + as **0.002**. That is a razor-thin edge: a pixel sitting on it is decided by the last bit of + `d`, and `d = abs(p.y - y)` is a subtraction of two nearby numbers — cancellation, right where + the result is most sensitive. +- `exp(-d * 26.0)` on the same `d`. + +If reformulating the line term stabilises it — accumulating in a saturated or normalised form, +or widening the minimum edge so no pixel sits exactly on a discontinuity — that is the real fix +and it costs nothing visually. + +**Only if it cannot be made stable**, fix the check instead: either warm the GPU to a known +state before measuring, or raise the tolerance with the reason written into the comment that +already explains the 1-LSB rationale. Either is defensible; silently raising the number is not. + +### Done when + +`?phase=7&slow=1` passes in isolation, repeatedly, on a cold device. + +### Do not + +Do not raise the tolerance as the first move. The whole value of this check is that a real bug +scores in the tens or hundreds while the hardware floor is 1 — that separation is what makes it +worth having, and widening it without understanding the cause spends it for nothing.