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