From f11b9ee72da6018ce5804f29e5640a5cdbe951da Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 8 Jun 2026 00:17:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(ring):=20fast-spin=20blended=20pass=20past?= =?UTF-8?q?=20a=20speed=20threshold=20(scales=20design=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/ROADMAP.md | 4 ++ docs/USER_GUIDE.md | 5 +- ...-library-and-right-axis-pipeline-design.md | 11 +++ player/ring.py | 65 ++++++++++++++++- simulator/app.py | 9 ++- simulator/clips.py | 5 +- simulator/static/app.js | 25 +++++-- tests/test_clips.py | 13 ++++ tests/test_player_ring.py | 70 +++++++++++++++++++ tests/test_simulator_api.py | 18 +++++ 10 files changed, 212 insertions(+), 13 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d9485d5..f4d234d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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):** diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 588e204..be2412b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -330,7 +330,10 @@ then open http://localhost:8000. (an endless encoder), distinct from the absolute 0–4 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 (0–4):** - **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal or zero = the raw footage). diff --git a/docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md b/docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md index d455e2c..0d42c50 100644 --- a/docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md +++ b/docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md @@ -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. diff --git a/player/ring.py b/player/ring.py index ab82051..0a4b542 100644 --- a/player/ring.py +++ b/player/ring.py @@ -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 ) diff --git a/simulator/app.py b/simulator/app.py index 45c5f36..27e95a5 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -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(): diff --git a/simulator/clips.py b/simulator/clips.py index 03c27c5..ff38c92 100644 --- a/simulator/clips.py +++ b/simulator/clips.py @@ -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 ], diff --git a/simulator/static/app.js b/simulator/static/app.js index c201e2e..922b6b0 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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 diff --git a/tests/test_clips.py b/tests/test_clips.py index f144d18..e86be37 100644 --- a/tests/test_clips.py +++ b/tests/test_clips.py @@ -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 diff --git a/tests/test_player_ring.py b/tests/test_player_ring.py index 96107c0..1964ca8 100644 --- a/tests/test_player_ring.py +++ b/tests/test_player_ring.py @@ -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 == () diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index 1eefaf0..e50953a 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -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