"""Scale-ring navigation: the endless-encoder closed loop of nature scales (§3). Design: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline- design.md §3 (scale navigation & zoom transitions). The scales-of-nature library is navigated as a CLOSED RING, not a line. A dedicated endless rotary encoder — relative, vs the absolute 0..4 experience knobs — walks the ring one step per detent; diving past the smallest scale wraps around to the largest (the infinite-zoom payoff). Between each adjacent pair of scales a short pre-baked AI zoom/warp transition plays. This is the canonical navigation engine (Python-canonical, shared with the Pi player/firmware); the simulator browser only renders what `advance_ring` returns. Ordering convention: - index 0 = the LARGEST scale (cosmos); increasing index zooms INWARD toward the smallest. - +1 detent zooms in, -1 zooms out; BOTH wrap (past the smallest wraps to the largest, and vice versa). - edge i connects scales[i] -> scales[(i+1) % N]; the last edge is the 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. """ from __future__ import annotations from dataclasses import dataclass class RingError(ValueError): """Raised when a ScaleRing is structurally invalid.""" @dataclass(frozen=True) class Scale: """A node on the ring: a scale of nature and the base clip it plays.""" id: str clip_id: str @dataclass(frozen=True) class Transition: """A pre-baked zoom/warp morph along ONE ring edge. `file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring order); the renderer reverses it for the outward direction, so one clip covers both directions of an edge. """ file: str model: str = "" @dataclass(frozen=True) class ScaleRing: """A closed ring of scales joined by per-edge transitions. Invariant: a ring of N>=2 scales has exactly N transitions (one per edge, including the small->large wrap seam). A degenerate single-scale ring has no edges. An empty ring is rejected. """ scales: tuple[Scale, ...] transitions: tuple[Transition, ...] def __post_init__(self) -> None: n = len(self.scales) if n == 0: raise RingError("a ScaleRing needs at least one scale") expected = 0 if n == 1 else n if len(self.transitions) != expected: raise RingError( f"a {n}-scale ring needs {expected} transitions, " f"got {len(self.transitions)}" ) def __len__(self) -> int: return len(self.scales) @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.""" edge: int reversed: bool file: str to_index: int @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).""" from_index: int to_index: int steps: tuple[TransitionStep, ...] wrapped: bool def scale_at(ring: ScaleRing, index: int) -> Scale: """The scale at `index`, taken modulo the ring length (wraps both ways).""" return ring.scales[index % len(ring)] def advance_ring(ring: ScaleRing, from_index: int, delta: int) -> 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. """ n = len(ring) start = from_index % n if n == 1 or delta == 0: return RingMove(from_index=start, to_index=start, steps=(), wrapped=False) direction = 1 if delta > 0 else -1 steps: list[TransitionStep] = [] wrapped = False index = start for _ in range(abs(delta)): if direction > 0: # zoom inward: edge `index` forward, land at index+1 (mod n) edge = index nxt = (index + 1) % n reversed_ = False else: # zoom outward: cross edge `index-1` reversed, land at index-1 (mod n) edge = (index - 1) % n nxt = (index - 1) % n reversed_ = True if edge == n - 1: wrapped = True steps.append( TransitionStep( edge=edge, reversed=reversed_, file=ring.transitions[edge].file, to_index=nxt, ) ) index = nxt return RingMove( from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped )