Write up the three side quests Epic 2 left behind

All three were debts taken on knowingly, with the reason recorded at the
time. Writing them down so that picking one up does not mean reconstructing
why it exists — each gets the measurement that justifies it, a concrete
approach naming the actual files and lines, the existing gate that says when
it is done, and the wrong fix it must not reach for.

1. Four scenes (Classic Wave, Silk Ribbon, Kaleido Tunnel, Slow Orb) declare
   the style trait and honour it with `col += sigGrain(uv)` and nothing else,
   so they cannot decline the track's grain the way other scenes now can.
   Each already has an edge-weight or softness knob to route u_sigLine and
   u_sigSoft into. Parked at texture: 0.35, which keeps grain on the
   library's cleanest scenes purely to keep a gate green.

2. Curl Flow, Signal Decay and Circuit Bloom have no parameter that changes
   their structure — 1.31x, 1.21x and 1.10x against a 1.25x bar. They are
   statistically identical everywhere and at all times, which is what "it
   looks the same for five minutes" means technically. Needs structure, not
   motion; they already move too much.

3. Horizon Lines reports a 2/255 determinism delta when phase 7 runs alone
   and passes in a full run. Invisible as an image, but a gate whose verdict
   depends on preceding GPU load will eventually stay green through a real
   regression on the one property this whole project is built on. Prime
   suspects are named: 40 accumulated line terms, and a smoothstep edge as
   thin as 0.002 evaluated on a cancelling subtraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 16:37:12 +02:00
parent b7c6fd1c5a
commit 8dfd8392f3

199
flow-state/SIDE-QUESTS.md Normal file
View File

@ -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.