feat(ring): fast-spin blended pass past a speed threshold (scales design §3)

A multi-detent encoder spin previously chained one full transition per scale
crossed (e.g. +5 ≈ 5 placeholder morphs ≈ 12-15s), which feels sluggish for a
fast spin. Design §3 anticipates this: "transitions chain, or past a speed
threshold a faster blended pass is used."

`player.ring.advance_ring` gains an opt-in `fast_spin_threshold` (canonical
default `DEFAULT_FAST_SPIN_THRESHOLD = 3`). A spin batches its detents into one
advance() call, so `abs(delta)` is the input layer's proxy for spin speed; at or
above the threshold the move collapses to a single blended pass — one
`TransitionStep` (the arrival edge, marked `blended`, landing straight on the
destination, `RingMove.fast=True`) instead of the full chain. Landing index,
seam-crossing (`wrapped`), and arrival edge/direction are exactly the full
chain's; only the in-between transitions are dropped.

The policy is opt-in (default off) because the pure function cannot observe
wall-clock spin speed — the simulator/firmware, which can, enables it. This keeps
all existing complete-chain behavior and tests intact. The simulator passes the
default threshold and plays the blended step at 2.5× (FAST_BLEND_RATE); a
dedicated fast-blend clip can replace the accelerated arrival-edge placeholder
when real transition media exists.

- player/ring.py: TransitionStep.blended, RingMove.fast, threshold param + policy
- simulator/clips.py: serialize fast/blended in ring_move_to_dict
- simulator/app.py: apply DEFAULT_FAST_SPIN_THRESHOLD at /api/ring/advance
- simulator/static/app.js: play a blended step at FAST_BLEND_RATE
- docs: scales design §3, USER_GUIDE, ROADMAP slice-3 note

Tests: +9 (ring policy, serializer, API). Suite 224 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-08 00:17:47 -07:00
parent e975ab1f5a
commit f11b9ee72d
10 changed files with 212 additions and 13 deletions
+4
View File
@@ -155,6 +155,10 @@ generated as labelled placeholders by `simulator/setup_scales_media.py`. The
expensive real multi-strength flow-stabilized Right re-bake is **deferred** (new
scales carry a raw base only) until the ring is liked. Plan:
[`2026-06-07-scale-ring-navigation.md`](./superpowers/plans/2026-06-07-scale-ring-navigation.md).
Session 0012 added the design §3 **fast-spin** behavior on top: past an opt-in
speed threshold (`DEFAULT_FAST_SPIN_THRESHOLD = 3`, `abs(delta)` as the spin-speed
proxy) a quick spin collapses to a single **blended pass** instead of chaining
every transition, so fast navigation stays responsive on placeholder media.
**Remaining slices (not started):**
+4 -1
View File
@@ -330,7 +330,10 @@ then open http://localhost:8000.
(an endless encoder), distinct from the absolute 04 knobs: each step plays a
short placeholder zoom/warp **transition**, then settles on the next scale, with
the current knob alteration still applied. The current scale is named beside the
buttons (`name (i/N)`).
buttons (`name (i/N)`). A **fast spin** (scroll several detents at once, ≥3)
collapses to a single quick **blended pass** straight to the destination scale
instead of grinding through every transition (scales design §3); slow single
steps still chain one full transition per scale crossed.
- **Four experience knobs (04):**
- **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal
or zero = the raw footage).
@@ -162,6 +162,17 @@ the ring continuous.
local; one clip per ring edge (N scales → N transitions, including the micro→cosmos
closer). A fast spin may cross several scales — transitions chain, or past a speed
threshold a faster blended pass is used.
- **Implemented (session 0012):** `player.ring.advance_ring` takes an opt-in
`fast_spin_threshold` (canonical default `DEFAULT_FAST_SPIN_THRESHOLD = 3`).
A spin batches its detents into one `advance` call, so `abs(delta)` is the
input layer's proxy for spin speed; at/above the threshold the move collapses
to a **single blended pass** — one `TransitionStep` (the arrival edge, marked
`blended`, landing straight on the destination, `RingMove.fast=True`) instead
of chaining every transition. The policy is opt-in because the pure function
cannot observe wall-clock speed — the simulator/firmware enables it. The
simulator plays the blended step at 2.5× (`FAST_BLEND_RATE`); when real
transition clips exist a dedicated fast-blend clip can replace the
accelerated arrival-edge placeholder. Threshold + rate are tunable by eye.
- **Thesis-safe:** dwells on a scale are the neutral, knob-altered interactive cores;
transitions are fixed connective moments (un-altered, or at most carrying the
current mood grade). The awe lives in the *movement between* scales, not the base.
+62 -3
View File
@@ -21,6 +21,15 @@ Ordering convention:
small->large wrap seam. One transition clip per edge plays FORWARD when
zooming inward and REVERSED when zooming outward, so N scales need only N
transition clips.
Fast spin (§3, "transitions chain, or past a speed threshold a faster blended
pass is used"): a quick encoder spin batches many detents into one advance, so
`abs(delta)` is the input adapter's proxy for spin speed. Past an opt-in
`fast_spin_threshold` the move collapses to a SINGLE blended pass (the
arrival-edge clip, played fast) rather than chaining N full transitions, so a
fast spin stays responsive instead of grinding through every morph. The policy
is opt-in (default off) because this pure function cannot observe wall-clock
spin speed — the input layer (simulator/firmware), which can, enables it.
"""
from __future__ import annotations
@@ -28,6 +37,13 @@ from __future__ import annotations
from dataclasses import dataclass
# Default spin-speed cutoff for the input layer (simulator/firmware) to enable
# the fast-spin blended pass: 3+ detents batched into one advance() is a fast
# spin crossing several scales; 1-2 are deliberate single steps that chain. Tune
# by eye — it is a UX policy, not canonical ring structure.
DEFAULT_FAST_SPIN_THRESHOLD = 3
class RingError(ValueError):
"""Raised when a ScaleRing is structurally invalid."""
@@ -83,23 +99,34 @@ class ScaleRing:
@dataclass(frozen=True)
class TransitionStep:
"""One transition to play during a move: which edge clip, in which direction,
and the scale index it lands on."""
and the scale index it lands on.
`blended` marks the single collapsed step of a fast-spin pass (see
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
accelerated morph straight to the destination instead of a full transition.
"""
edge: int
reversed: bool
file: str
to_index: int
blended: bool = False
@dataclass(frozen=True)
class RingMove:
"""The result of advancing the encoder: where you end up and the ordered
transitions to play getting there (chained for a multi-detent spin)."""
transitions to play getting there (chained for a multi-detent spin).
`fast` is True when the move was collapsed to a single blended pass because
the spin crossed the speed threshold (see `advance_ring`).
"""
from_index: int
to_index: int
steps: tuple[TransitionStep, ...]
wrapped: bool
fast: bool = False
def scale_at(ring: ScaleRing, index: int) -> Scale:
@@ -107,13 +134,25 @@ def scale_at(ring: ScaleRing, index: int) -> Scale:
return ring.scales[index % len(ring)]
def advance_ring(ring: ScaleRing, from_index: int, delta: int) -> RingMove:
def advance_ring(
ring: ScaleRing,
from_index: int,
delta: int,
*,
fast_spin_threshold: int = 0,
) -> RingMove:
"""Walk the endless encoder `delta` detents from `from_index` (signed).
Returns a `RingMove` with the landing index and the ordered `TransitionStep`s
to play. Inward steps (+) play edge i forward; outward steps (-) play the
crossed edge reversed. `wrapped` is True if any step crossed the small<->large
seam. A degenerate single-scale ring (or delta 0) is a no-op.
`fast_spin_threshold` (opt-in, default off): when >= 2 and `abs(delta)` meets
it, the spin is treated as fast and the move collapses to a single blended
pass — one `TransitionStep` (the arrival edge, marked `blended`) landing
straight on the destination, with `RingMove.fast=True` — instead of chaining
every transition. See the module docstring (§3).
"""
n = len(ring)
start = from_index % n
@@ -147,6 +186,26 @@ def advance_ring(ring: ScaleRing, from_index: int, delta: int) -> RingMove:
)
index = nxt
# Fast spin: collapse the whole chain to a single blended arrival pass. The
# landing index, seam-crossing, and arrival edge/direction are exactly those
# of the full chain — only the in-between transitions are dropped.
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
arrival = steps[-1]
blended = TransitionStep(
edge=arrival.edge,
reversed=arrival.reversed,
file=arrival.file,
to_index=arrival.to_index,
blended=True,
)
return RingMove(
from_index=start,
to_index=index,
steps=(blended,),
wrapped=wrapped,
fast=True,
)
return RingMove(
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
)
+7 -2
View File
@@ -23,7 +23,7 @@ from player.alteration import (
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from player.ring import advance_ring
from player.ring import DEFAULT_FAST_SPIN_THRESHOLD, advance_ring
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
STATIC_DIR = Path(__file__).parent / "static"
@@ -112,7 +112,12 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_ring_advance(req: RingAdvanceRequest):
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
move = advance_ring(app.state.ring, req.from_index, req.delta)
move = advance_ring(
app.state.ring,
req.from_index,
req.delta,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
)
return ring_move_to_dict(move, app.state.ring)
if MEDIA_DIR.exists():
+4 -1
View File
@@ -107,11 +107,13 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict:
"""JSON form of an encoder move: the landing scale's clip and the ordered
transition clips to play (with direction)."""
transition clips to play (with direction). `fast` flags a collapsed fast-spin
pass; the single step then carries `blended` so the renderer plays it quick."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"fast": move.fast,
"target_clip_id": scale_at(ring, move.to_index).clip_id,
"steps": [
{
@@ -119,6 +121,7 @@ def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict:
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
"blended": st.blended,
}
for st in move.steps
],
+19 -6
View File
@@ -126,9 +126,13 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
// --- Scale ring (endless encoder) ---
function playTransition(file) {
// Play one placeholder zoom/warp transition clip start-to-finish, with the
// alteration layers hidden. Resolves on 'ended' (or a safety timeout).
const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass
function playTransition(file, blended) {
// Play one zoom/warp transition clip start-to-finish, with the alteration
// layers hidden. A `blended` (fast-spin) pass runs at FAST_BLEND_RATE so a
// quick encoder spin lands fast instead of grinding through every morph.
// Resolves on 'ended' (or a safety timeout that scales with playback rate).
return new Promise((resolve) => {
overlay.style.opacity = "0";
tint.style.opacity = "0";
@@ -136,11 +140,18 @@ function playTransition(file) {
vid.loop = false;
vid.style.opacity = "1";
vid.src = mediaUrl(file);
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
let done = false;
const finish = () => { if (done) return; done = true; vid.removeEventListener("ended", finish); resolve(); };
const finish = () => {
if (done) return;
done = true;
vid.removeEventListener("ended", finish);
vid.playbackRate = 1;
resolve();
};
vid.addEventListener("ended", finish);
vid.play().catch(finish);
setTimeout(finish, 6000);
setTimeout(finish, blended ? 3000 : 6000);
});
}
@@ -154,8 +165,10 @@ async function advance(delta) {
});
if (!resp.ok) return;
const move = await resp.json();
// A fast spin comes back collapsed to a single blended step (move.fast);
// a slow spin chains one full transition per scale crossed.
for (const step of move.steps) {
await playTransition(step.file);
await playTransition(step.file, step.blended);
}
ringIndex = move.to_index;
currentVariant = -1; // force the target clip's variant to (re)load
+13
View File
@@ -143,3 +143,16 @@ def test_ring_move_to_dict_serializes_steps(tmp_path):
assert d["steps"][0]["file"] == "transitions/abyss-cosmos.mp4"
assert d["steps"][0]["reversed"] is False
assert d["steps"][0]["to_index"] == 0
assert d["fast"] is False
assert d["steps"][0]["blended"] is False
def test_ring_move_to_dict_marks_fast_blended_pass(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_ring_manifest_dict()))
ring = load_ring(p)
move = advance_ring(ring, from_index=0, delta=4, fast_spin_threshold=3)
d = ring_move_to_dict(move, ring)
assert d["fast"] is True
assert len(d["steps"]) == 1
assert d["steps"][0]["blended"] is True
+70
View File
@@ -136,3 +136,73 @@ def test_degenerate_single_scale_ring_is_a_noop():
assert move.to_index == 0
assert move.steps == ()
assert move.wrapped is False
# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
#
# A fast encoder spin batches many detents into one advance() call, so |delta| is
# the input adapter's proxy for spin speed. Past a configured threshold the move
# collapses to a SINGLE blended pass (the arrival-edge clip, played fast) instead
# of chaining N full transitions. The policy is opt-in (default off) because the
# pure function cannot know wall-clock spin speed — the input layer enables it.
def test_fast_spin_disabled_by_default_chains_every_detent():
r = _ring()
move = advance_ring(r, from_index=0, delta=5)
assert move.fast is False
assert len(move.steps) == 5
assert all(not s.blended for s in move.steps)
def test_fast_spin_below_threshold_still_chains():
r = _ring()
move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
assert move.fast is False
assert len(move.steps) == 2
assert move.steps[0].edge == 0 and move.steps[1].edge == 1
def test_fast_spin_at_or_above_threshold_collapses_to_one_blended_step():
r = _ring()
# 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
# crosses the abyss->cosmos seam, last edge crossed is edge 0 (forward).
move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
assert move.fast is True
assert move.to_index == 1
assert move.wrapped is True
assert move.steps == (
TransitionStep(
edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1, blended=True
),
)
def test_fast_spin_outward_blended_step_is_reversed():
r = _ring()
# 4 outward detents from cosmos(0): 0->2->1->0->2, lands on abyss(2);
# outward arrival crosses edge 2 (abyss->cosmos) reversed.
move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
assert move.fast is True
assert move.to_index == 2
assert move.wrapped is True
assert move.steps == (
TransitionStep(
edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2, blended=True
),
)
def test_fast_spin_threshold_below_two_is_ignored():
# A threshold of 0 or 1 would blend even single deliberate steps — treat as off.
r = _ring()
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
assert move.fast is False
assert len(move.steps) == 3
def test_fast_spin_noop_on_degenerate_ring():
r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
move = advance_ring(r, from_index=0, delta=9, fast_spin_threshold=3)
assert move.fast is False
assert move.steps == ()
+18
View File
@@ -162,6 +162,24 @@ def test_ring_advance_outward_reverses_edge(ring_client):
assert data["steps"][0]["reversed"] is True
def test_ring_advance_small_delta_chains_not_fast(ring_client):
# 2 detents is below the simulator's fast-spin threshold (3) -> full chain.
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 2})
data = resp.json()
assert data["fast"] is False
assert len(data["steps"]) == 2
def test_ring_advance_fast_spin_collapses_to_one_blended_pass(ring_client):
# A fast spin (4 detents batched) crosses the threshold -> one blended pass.
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
data = resp.json()
assert data["fast"] is True
assert len(data["steps"]) == 1
assert data["steps"][0]["blended"] is True
assert data["to_index"] == 1
def test_ring_404_when_no_ring_in_manifest(client):
assert client.get("/api/ring").status_code == 404
assert client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).status_code == 404